From a0a41ca08f54387879ec3cc115b3b4f6eeafa494 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 17:25:29 +0200 Subject: [PATCH 01/39] Update Gradle plugin to work with Android Studio 3.3 Canary Android Studio 3.1.4 Stable doesn't render layout previews in this project for whatever reason. Switching to the latest 3.3 Canary release fixes the issue without affecting Gradle scripts but requires the new Android Gradle plugin to match the new Android Studio release. This commit will be reverted once development on the feature is done. --- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 01f499b72f..0cb00d2127 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.1.4' + classpath 'com.android.tools.build:gradle:3.3.0-alpha07' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3fec1f5dcc..4ef4be4a61 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Apr 06 21:32:27 MSK 2018 +#Thu Aug 28 15:18:36 CEST 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-all.zip From 8f6c327cc71dde2728b445217f3ccd827d06528f Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 17:39:50 +0200 Subject: [PATCH 02/39] Update gradle build script to allow installing debug builds alongside store version This will allow developers, testers, etc to work on Tusky will not having to worry about overwriting, uninstalling, fiddling with a preinstalled application which would mean having to login again every time the development cycle starts/finishes and manually reinstalling the app. --- app/build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index d9137fe2a5..566060618c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,7 +30,9 @@ android { shrinkResources true proguardFiles 'proguard-rules.pro' } - debug { } + debug { + applicationIdSuffix ".debug" + } } flavorDimensions "color" From 6b1ec083b7e8eb9d29b6ac082824b44f590f4f28 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 17:55:23 +0200 Subject: [PATCH 03/39] Add UI changes to support collapsing statuses The button uses subtle styling to not be distracting like the CW button on the timeline The button is toggleable, full width to match the status textbox hitbox width and also is shorter to not be too intrusive between the status text and images, or the post below --- app/src/main/res/layout/item_status.xml | 16 ++++++++++++++-- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 2520118820..f2ad67fcaa 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -134,11 +134,23 @@ android:textSize="?attr/status_text_medium" tools:text="This is a status" /> + + @@ -353,4 +365,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53ff6feb68..89281958d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,8 @@ Click to view Show More Show Less + Show more + Show less Nothing here. Pull down to refresh! From bdf0264c56e8183de0be9f64fae9097c5cccb4e5 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 21:11:13 +0200 Subject: [PATCH 04/39] Update status data model to store whether the message has been collapsed --- .../tusky/viewdata/StatusViewData.java | 76 +++++++++++++------ 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 5da7bb0468..fbc23c7c1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -80,6 +80,7 @@ public static final class Concrete extends StatusViewData { private final List accountEmojis; @Nullable private final Card card; + private final boolean isCollapsed; /** Whether the status is shown partially or fully */ public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, @Nullable String spoilerText, Status.Visibility visibility, List attachments, @@ -87,7 +88,8 @@ public Concrete(String id, Spanned content, boolean reblogged, boolean favourite boolean isShowingContent, String userFullName, String nickname, String avatar, Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, - Status.Application application, List statusEmojis, List accountEmojis, @Nullable Card card) { + Status.Application application, List statusEmojis, List accountEmojis, @Nullable Card card, + boolean isCollapsed) { this.id = id; this.content = content; this.reblogged = reblogged; @@ -114,6 +116,7 @@ public Concrete(String id, Spanned content, boolean reblogged, boolean favourite this.statusEmojis = statusEmojis; this.accountEmojis = accountEmojis; this.card = card; + this.isCollapsed = isCollapsed; } public String getId() { @@ -226,6 +229,15 @@ public Card getCard() { return card; } + /** + * Specifies whether the content of this post is castrated to a certain length or if it is + * currently shown at its full length. + * @return Whether the post is collapsed or fully expanded. + */ + public boolean isCollapsed() { + return isCollapsed; + } + @Override public long getViewDataId() { // Chance of collision is super low and impact of mistake is low as well return getId().hashCode(); @@ -236,31 +248,32 @@ public boolean deepEquals(StatusViewData o) { if (o == null || getClass() != o.getClass()) return false; Concrete concrete = (Concrete) o; return reblogged == concrete.reblogged && - favourited == concrete.favourited && - isSensitive == concrete.isSensitive && - isExpanded == concrete.isExpanded && - isShowingContent == concrete.isShowingContent && - reblogsCount == concrete.reblogsCount && - favouritesCount == concrete.favouritesCount && - rebloggingEnabled == concrete.rebloggingEnabled && - Objects.equals(id, concrete.id) && - Objects.equals(content, concrete.content) && - Objects.equals(spoilerText, concrete.spoilerText) && - visibility == concrete.visibility && - Objects.equals(attachments, concrete.attachments) && - Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) && - Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) && - Objects.equals(userFullName, concrete.userFullName) && - Objects.equals(nickname, concrete.nickname) && - Objects.equals(avatar, concrete.avatar) && - Objects.equals(createdAt, concrete.createdAt) && - Objects.equals(inReplyToId, concrete.inReplyToId) && - Arrays.equals(mentions, concrete.mentions) && - Objects.equals(senderId, concrete.senderId) && - Objects.equals(application, concrete.application) && + favourited == concrete.favourited && + isSensitive == concrete.isSensitive && + isExpanded == concrete.isExpanded && + isShowingContent == concrete.isShowingContent && + reblogsCount == concrete.reblogsCount && + favouritesCount == concrete.favouritesCount && + rebloggingEnabled == concrete.rebloggingEnabled && + Objects.equals(id, concrete.id) && + Objects.equals(content, concrete.content) && + Objects.equals(spoilerText, concrete.spoilerText) && + visibility == concrete.visibility && + Objects.equals(attachments, concrete.attachments) && + Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) && + Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) && + Objects.equals(userFullName, concrete.userFullName) && + Objects.equals(nickname, concrete.nickname) && + Objects.equals(avatar, concrete.avatar) && + Objects.equals(createdAt, concrete.createdAt) && + Objects.equals(inReplyToId, concrete.inReplyToId) && + Arrays.equals(mentions, concrete.mentions) && + Objects.equals(senderId, concrete.senderId) && + Objects.equals(application, concrete.application) && Objects.equals(statusEmojis, concrete.statusEmojis) && Objects.equals(accountEmojis, concrete.accountEmojis) && - Objects.equals(card, concrete.card); + Objects.equals(card, concrete.card) + && isCollapsed == concrete.isCollapsed; } } @@ -334,6 +347,7 @@ public static class Builder { private List statusEmojis; private List accountEmojis; private Card card; + private boolean isCollapsed; /** Whether the status is shown partially or fully */ public Builder() { } @@ -497,6 +511,18 @@ public Builder setCard(Card card) { return this; } + /** + * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed + * state, hiding partially the content of the post if it exceeds a certain amount of characters. + * + * @param collapsed Whether to show the full content of the status or not. + * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. + */ + public Builder setCollapsed(boolean collapsed) { + isCollapsed = collapsed; + return this; + } + public StatusViewData.Concrete createStatusViewData() { if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); @@ -506,7 +532,7 @@ public StatusViewData.Concrete createStatusViewData() { attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, - statusEmojis, accountEmojis, card); + statusEmojis, accountEmojis, card, isCollapsed); } } } From 39305558000f1b2fe58eb87c077c9a65849464ff Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 21:21:13 +0200 Subject: [PATCH 05/39] Update status action listener to notify of collapsed state changing Provide stubs in all implementing classes and mark as TODO the stubs that require a proper implementation for the feature to work. --- .../tusky/fragment/NotificationsFragment.java | 5 +++++ .../com/keylesspalace/tusky/fragment/SearchFragment.kt | 6 +++++- .../keylesspalace/tusky/fragment/TimelineFragment.java | 5 +++++ .../keylesspalace/tusky/fragment/ViewThreadFragment.java | 5 +++++ .../tusky/interfaces/StatusActionListener.java | 9 +++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index bf0749bf51..d8a6bef76c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -491,6 +491,11 @@ public void onLoadMore(int position) { } } + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + // TODO: Implement this method. + } + @Override public void onViewTag(String tag) { super.viewTag(tag); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index 9aca783e72..2e93247a96 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -212,6 +212,10 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { // not needed here, search is not paginated } + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + TODO("not implemented") + } + companion object { const val TAG = "SearchFragment" } @@ -227,4 +231,4 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { startActivity(intent) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index dc8644d693..c9a0fd510a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -561,6 +561,11 @@ public void onLoadMore(int position) { } } + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + // TODO: Implement this method. + } + @Override public void onViewMedia(int position, int attachmentIndex, View view) { Status status = statuses.get(position).getAsRightOrNull(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 93cb007177..94c9fe09d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -353,6 +353,11 @@ public void onLoadMore(int pos) { } + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + // No need to implement this method as status threads always show all content in a status. + } + @Override public void onViewTag(String tag) { super.viewTag(tag); diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index fbd46783cf..79c42db92b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -28,4 +28,13 @@ public interface StatusActionListener extends LinkListener { void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); void onLoadMore(int position); + + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onContentCollapsedChange(boolean isCollapsed, int position); } From b41599e6d576ecc6193afa6c1cf98b799e6ba51f Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 21:32:27 +0200 Subject: [PATCH 06/39] Add implementation code to handle status collapse/expand in timeline Code has not been added elsewhere to simplify testing. Once the code will be considered stable it will be also included in other status action listener implementers. --- .../tusky/fragment/TimelineFragment.java | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index c9a0fd510a..78a806c1fc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -561,9 +561,46 @@ public void onLoadMore(int position) { } } + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - // TODO: Implement this method. + if(position < 0 || position >= statuses.size()) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); + return; + } + + StatusViewData status = statuses.getPairedItem(position); + if(!(status instanceof StatusViewData.Concrete)) { + // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't + // check for null values when adding values to it. + + // TODO: Implement @NonNull/@Nullable in PairedList insert methods + + if(status == null) { + Log.e(TAG, String.format("Tried to access status but got null at position: %d of %d", position, statuses.size() - 1)); + } else { + Log.e(TAG, String.format( + "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", + status.getClass().getSimpleName(), + position, + statuses.size() -1 + )); + } + + return; + } + + StatusViewData updatedStatus = new StatusViewData.Builder((StatusViewData.Concrete) status) + .setCollapsed(isCollapsed) + .createStatusViewData(); + statuses.setPairedItem(position, updatedStatus); + updateAdapter(); } @Override From f66664a8a2a82308b6c8b3ddeaf5f0ddb0655c82 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 30 Aug 2018 22:44:51 +0200 Subject: [PATCH 07/39] Add preferences so that users can toggle the collapsing of long posts This is currently limited to a simple toggle, it would be nice to implement a more advanced UI to offer the user more control over the feature. --- app/src/main/res/values/strings.xml | 5 +++++ app/src/main/res/xml/preferences.xml | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89281958d1..8ae41f9b0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,6 +175,11 @@ Appearance App Theme + Collapse long posts + Long posts will only show the first 500 characters. + Long posts (over 500 characters) will not be collapsed to save space. + + Dark Light diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 1dd472b85d..8b209c614b 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -42,6 +42,12 @@ android:key="alwaysShowSensitiveMedia" android:title="@string/pref_title_alway_show_sensitive_media" /> + From 0ee004d78d788aefea1e099009ee74e4a9eba4d3 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 00:46:54 +0200 Subject: [PATCH 08/39] Update Gradle plugin to work with latest Android Studio 3.3 Canary 8 Just like the other commit, this will be reverted once the feature is working. I simply don't want to deal with what changes in my installation of Android Studio 3.1.4 Stable which breaks the layout preview rendering. --- app/build.gradle | 2 +- build.gradle | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 566060618c..ea918fb973 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ def getGitSha = { -> android { compileSdkVersion 27 - buildToolsVersion '27.0.3' +// buildToolsVersion '27.0.3' defaultConfig { applicationId "com.keylesspalace.tusky" minSdkVersion 19 diff --git a/build.gradle b/build.gradle index 0cb00d2127..f0d4904696 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,8 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0-alpha07' +// classpath 'com.android.tools.build:gradle:3.1.4' + classpath 'com.android.tools.build:gradle:3.3.0-alpha08' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } From 472effd4a0e06544dca612594cd84c9347c0ee6a Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 02:13:31 +0200 Subject: [PATCH 09/39] Update data models and utils for statuses to better handle collapsing I forgot that data isn't available from the API and can't really be built from scratch using existing data due to preferences. A new, extra boolean should fix the issue. --- .../tusky/util/ViewDataUtils.java | 23 +++++++++--- .../tusky/viewdata/StatusViewData.java | 36 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index e9fe76f8fd..aadb1e3f0d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -29,7 +29,8 @@ public final class ViewDataUtils { @Nullable public static StatusViewData.Concrete statusToViewData(@Nullable Status status, - boolean alwaysShowSensitiveMedia) { + boolean alwaysShowSensitiveMedia, + boolean collapseLongStatusContent) { if (status == null) return null; Status visibleStatus = status.getReblog() == null ? status : status.getReblog(); return new StatusViewData.Builder().setId(status.getId()) @@ -58,12 +59,24 @@ public static StatusViewData.Concrete statusToViewData(@Nullable Status status, .setApplication(visibleStatus.getApplication()) .setStatusEmojis(visibleStatus.getEmojis()) .setAccountEmojis(visibleStatus.getAccount().getEmojis()) + .setCollapsible(collapseLongStatusContent && status.getContent().length() > 500) + .setCollapsed(true) .createStatusViewData(); } - public static NotificationViewData.Concrete notificationToViewData(Notification notification, boolean alwaysShowSensitiveData) { - return new NotificationViewData.Concrete(notification.getType(), notification.getId(), notification.getAccount(), - statusToViewData(notification.getStatus(), alwaysShowSensitiveData), false); + public static NotificationViewData.Concrete notificationToViewData(Notification notification, + boolean alwaysShowSensitiveData, + boolean collapseLongStatusContent) { + return new NotificationViewData.Concrete( + notification.getType(), + notification.getId(), + notification.getAccount(), + statusToViewData( + notification.getStatus(), + alwaysShowSensitiveData, + collapseLongStatusContent + ), + false + ); } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index fbc23c7c1f..413aa00179 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -80,6 +80,7 @@ public static final class Concrete extends StatusViewData { private final List accountEmojis; @Nullable private final Card card; + private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ private final boolean isCollapsed; /** Whether the status is shown partially or fully */ public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, @@ -89,7 +90,7 @@ public Concrete(String id, Spanned content, boolean reblogged, boolean favourite Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, Status.Application application, List statusEmojis, List accountEmojis, @Nullable Card card, - boolean isCollapsed) { + boolean isCollapsible, boolean isCollapsed) { this.id = id; this.content = content; this.reblogged = reblogged; @@ -116,6 +117,7 @@ public Concrete(String id, Spanned content, boolean reblogged, boolean favourite this.statusEmojis = statusEmojis; this.accountEmojis = accountEmojis; this.card = card; + this.isCollapsible = isCollapsible; this.isCollapsed = isCollapsed; } @@ -230,8 +232,19 @@ public Card getCard() { } /** - * Specifies whether the content of this post is castrated to a certain length or if it is - * currently shown at its full length. + * Specifies whether the content of this post is allowed to be collapsed or if it should show + * all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + public boolean isCollapsible() { + return isCollapsible; + } + + /** + * Specifies whether the content of this post is currently limited in visibility to the first + * 500 characters or not. + * * @return Whether the post is collapsed or fully expanded. */ public boolean isCollapsed() { @@ -347,6 +360,7 @@ public static class Builder { private List statusEmojis; private List accountEmojis; private Card card; + private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */ private boolean isCollapsed; /** Whether the status is shown partially or fully */ public Builder() { @@ -379,6 +393,8 @@ public Builder(final StatusViewData.Concrete viewData) { statusEmojis = viewData.getStatusEmojis(); accountEmojis = viewData.getAccountEmojis(); card = viewData.getCard(); + isCollapsible = viewData.isCollapsible(); + isCollapsed = viewData.isCollapsed(); } public Builder setId(String id) { @@ -511,6 +527,18 @@ public Builder setCard(Card card) { return this; } + /** + * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to support collapsing + * its content limiting the visible length when collapsed at 500 characters, + * + * @param collapsible Whether the status should support being collapsed or not. + * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. + */ + public Builder setCollapsible(boolean collapsible) { + isCollapsible = collapsible; + return this; + } + /** * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed * state, hiding partially the content of the post if it exceeds a certain amount of characters. @@ -532,7 +560,7 @@ public StatusViewData.Concrete createStatusViewData() { attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, - statusEmojis, accountEmojis, card, isCollapsed); + statusEmojis, accountEmojis, card, isCollapsible, isCollapsed); } } } From 73dc85a1156d929ca5663bfc7b5aa8d65a363bd0 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 02:16:31 +0200 Subject: [PATCH 10/39] Fix search breaking due to newly introduced variables in utils classes --- .../tusky/adapter/SearchResultsAdapter.java | 15 ++++++++-- .../tusky/fragment/SearchFragment.kt | 29 +++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java index 24aea0bec1..ce3c718ce0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java @@ -48,12 +48,16 @@ public class SearchResultsAdapter extends RecyclerView.Adapter { private boolean mediaPreviewsEnabled; private boolean alwaysShowSensitiveMedia; + private boolean collapseLongStatusContent; private LinkListener linkListener; private StatusActionListener statusListener; - public SearchResultsAdapter(boolean mediaPreviewsEnabled, boolean alwaysShowSensitiveMedia, - LinkListener linkListener, StatusActionListener statusListener) { + public SearchResultsAdapter(boolean mediaPreviewsEnabled, + boolean alwaysShowSensitiveMedia, + boolean collapseLongStatusContent, + LinkListener linkListener, + StatusActionListener statusListener) { this.accountList = Collections.emptyList(); this.statusList = Collections.emptyList(); @@ -62,6 +66,7 @@ public SearchResultsAdapter(boolean mediaPreviewsEnabled, boolean alwaysShowSens this.mediaPreviewsEnabled = mediaPreviewsEnabled; this.alwaysShowSensitiveMedia = alwaysShowSensitiveMedia; + this.collapseLongStatusContent = collapseLongStatusContent; this.linkListener = linkListener; this.statusListener = statusListener; @@ -150,7 +155,11 @@ public void updateSearchResults(SearchResults results) { accountList = results.getAccounts(); statusList = results.getStatuses(); for(Status status: results.getStatuses()) { - concreteStatusList.add(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia)); + concreteStatusList.add(ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia, + collapseLongStatusContent + )); } hashtagList = results.getHashtags(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index 2e93247a96..769bec9b86 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -50,6 +50,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { private var alwaysShowSensitiveMedia = false private var mediaPreviewEnabled = true + private var collapseLongStatusContent = true; override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -61,9 +62,17 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false) mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true) + collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); + searchRecyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) searchRecyclerView.layoutManager = LinearLayoutManager(view.context) - searchAdapter = SearchResultsAdapter(mediaPreviewEnabled, alwaysShowSensitiveMedia, this, this) + searchAdapter = SearchResultsAdapter( + mediaPreviewEnabled, + alwaysShowSensitiveMedia, + collapseLongStatusContent, + this, + this + ) searchRecyclerView.adapter = searchAdapter } @@ -139,7 +148,14 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { timelineCases.reblogWithCallback(status, reblog, object: Callback { override fun onResponse(call: Call?, response: Response?) { status.reblogged = true - searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position) + searchAdapter.updateStatusAtPosition( + ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia, + collapseLongStatusContent + ), + position + ) } override fun onFailure(call: Call?, t: Throwable?) { @@ -156,7 +172,14 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { timelineCases.favouriteWithCallback(status, favourite, object: Callback { override fun onResponse(call: Call?, response: Response?) { status.favourited = true - searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position) + searchAdapter.updateStatusAtPosition( + ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia, + collapseLongStatusContent + ), + position + ) } override fun onFailure(call: Call?, t: Throwable?) { From 95976d686983d9b7e687e0a156b7f51b00e41b4a Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 02:17:56 +0200 Subject: [PATCH 11/39] Fix timeline breaking due to newly introduced variables in utils classes --- .../tusky/fragment/TimelineFragment.java | 14 +++++++++++++- .../tusky/fragment/ViewThreadFragment.java | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 78a806c1fc..e5530a739c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -146,6 +146,7 @@ private enum FetchEnd { private boolean didLoadEverythingBottom; private boolean alwaysShowSensitiveMedia; + private boolean collapseLongStatusContent; @Override protected TimelineCases timelineCases() { @@ -158,7 +159,11 @@ protected TimelineCases timelineCases() { public StatusViewData apply(Either input) { Status status = input.getAsRightOrNull(); if (status != null) { - return ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia); + return ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia, + collapseLongStatusContent + ); } else { Placeholder placeholder = input.getAsLeft(); return new StatusViewData.Placeholder(placeholder.id, false); @@ -262,6 +267,8 @@ private void setupTimelinePreferences() { filterRemoveRegexMatcher = Pattern.compile(regexFilter, Pattern.CASE_INSENSITIVE) .matcher(""); } + + collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); } private void setupSwipeRefreshLayout() { @@ -687,7 +694,12 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin case "alwaysShowSensitiveMedia": { //it is ok if only newly loaded statuses are affected, no need to fully refresh alwaysShowSensitiveMedia = sharedPreferences.getBoolean("alwaysShowSensitiveMedia", false); + break; } + case "collapseLongStatuses": + // As for "always show sensitive media" settings, only apply this to newer posts + collapseLongStatusContent = sharedPreferences.getBoolean("collapseLongStatuses", true); + break; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 94c9fe09d4..a56df78954 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -91,6 +91,7 @@ public final class ViewThreadFragment extends SFragment implements private String thisThreadsStatusId; private Card card; private boolean alwaysShowSensitiveMedia; + private boolean collapseLongStatusContent; private int statusIndex = 0; @@ -98,7 +99,11 @@ public final class ViewThreadFragment extends SFragment implements new PairedList<>(new Function() { @Override public StatusViewData.Concrete apply(Status input) { - return ViewDataUtils.statusToViewData(input, alwaysShowSensitiveMedia); + return ViewDataUtils.statusToViewData( + input, + alwaysShowSensitiveMedia, + collapseLongStatusContent + ); } }); @@ -154,6 +159,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( getActivity()); alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false); + collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true); adapter.setMediaPreviewEnabled(mediaPreviewEnabled); recyclerView.setAdapter(adapter); From 6e2de0784534ec4c173dc1d1dd530966c059811a Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 04:45:09 +0200 Subject: [PATCH 12/39] Fix item status text for collapsed toggle being shown in the wrong state --- app/src/main/res/layout/item_status.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index f2ad67fcaa..4129ac37bf 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -142,8 +142,8 @@ android:padding="0dp" android:layout_toEndOf="@id/status_avatar" android:layout_below="@id/status_content" - android:textOff="@string/status_content_show_more" - android:textOn="@string/status_content_show_less" + android:textOff="@string/status_content_show_less" + android:textOn="@string/status_content_show_more" android:visibility="gone" /> Date: Fri, 31 Aug 2018 04:47:15 +0200 Subject: [PATCH 13/39] Update timeline fragment to refresh the list when collapsed settings change --- .../java/com/keylesspalace/tusky/fragment/TimelineFragment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index e5530a739c..9a8c793f1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -697,8 +697,8 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin break; } case "collapseLongStatuses": - // As for "always show sensitive media" settings, only apply this to newer posts collapseLongStatusContent = sharedPreferences.getBoolean("collapseLongStatuses", true); + fullyRefresh(); break; } } From 215b47809cbd1804cf416fc8dbb6c57d67ad8007 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 04:47:50 +0200 Subject: [PATCH 14/39] Add support for status content collapse in timeline viewholder --- .../tusky/adapter/StatusBaseViewHolder.java | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 4a920c07f3..570a986602 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -7,6 +7,7 @@ import android.support.annotation.Nullable; import android.support.v7.content.res.AppCompatResources; import android.support.v7.widget.RecyclerView; +import android.text.InputFilter; import android.text.Spanned; import android.text.TextUtils; import android.view.View; @@ -58,6 +59,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private View sensitiveMediaShow; private TextView mediaLabel; private ToggleButton contentWarningButton; + private ToggleButton contentCollapseButton; ImageView avatar; TextView timestampInfo; @@ -91,6 +93,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaLabel = itemView.findViewById(R.id.status_media_label); contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description); contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); } protected abstract int getMediaPreviewHeight(Context context); @@ -468,7 +471,6 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener setUsername(status.getNickname()); setCreatedAt(status.getCreatedAt()); setIsReply(status.getInReplyToId() != null); - setContent(status.getContent(), status.getMentions(), status.getStatusEmojis(), listener); setAvatar(status.getAvatar(), status.getRebloggedAvatar()); setReblogged(status.isReblogged()); setFavourited(status.isFavourited()); @@ -499,7 +501,54 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener } else { setSpoilerText(status.getSpoilerText(), status.getStatusEmojis(), status.isExpanded(), listener); } - } + if(contentCollapseButton != null && status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { + contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { + int position = getAdapterPosition(); + if(position != RecyclerView.NO_POSITION) listener.onContentCollapsedChange(isChecked, position); + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if(status.isCollapsed()) { + contentCollapseButton.setChecked(true); + content.setFilters(new InputFilter[] {(source, start, end, dest, dstart, dend) -> { + + // Code imported from InputFilter.LengthFilter + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 + + // Changes: + // - After the text it adds and ellipsis to make it feel like the text continues + // - Max value is 500 rather than a variable + // - Trim invisible characters off the end of the 500-limited string + // - Slimmed code for saving LOCs + + int keep = 50 - (dest.length() - (dend - dstart)); + if(keep <= 0) return ""; + if(keep >= end - start) return null; // keep original + + keep += start; + while(Character.isWhitespace(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } + + if(Character.isHighSurrogate(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } + + return source.subSequence(start, keep) + "…"; + }}); + } else { + contentCollapseButton.setChecked(false); + content.setFilters(new InputFilter[] {}); + } + } else if(contentCollapseButton != null) { + contentCollapseButton.setVisibility(View.GONE); + content.setFilters(new InputFilter[] {}); + } + + setContent(status.getContent(), status.getMentions(), status.getStatusEmojis(), listener); + } } From ff659f1ee89b789709bbf38e5badf2008bc059bd Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 04:52:04 +0200 Subject: [PATCH 15/39] Fix view holder truncating posts using temporary debug settings at 50 chars --- .../com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 570a986602..cc5a5e2747 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -522,7 +522,7 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener // - Trim invisible characters off the end of the 500-limited string // - Slimmed code for saving LOCs - int keep = 50 - (dest.length() - (dend - dstart)); + int keep = 500 - (dest.length() - (dend - dstart)); if(keep <= 0) return ""; if(keep >= end - start) return null; // keep original From 4dbed269f33bba79986ac3af543e40a89ac344b1 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 05:10:43 +0200 Subject: [PATCH 16/39] Add toggle support to notification layout as well --- .../main/res/layout/item_status_notification.xml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index 50437e57c1..5da1da582a 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -115,6 +115,19 @@ android:textSize="?attr/status_text_medium" tools:text="Example status here" /> + + + - \ No newline at end of file + From d64573de8cf701a0c7fa13f423ba061b246ba16d Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 05:19:56 +0200 Subject: [PATCH 17/39] Add support for collapsed statuses to search results --- .../keylesspalace/tusky/fragment/SearchFragment.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index 769bec9b86..ff40159d5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -236,7 +236,18 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - TODO("not implemented") + // TODO: No out-of-bounds check in getConcreteStatusAtPosition + val status = searchAdapter.getConcreteStatusAtPosition(position) + if(status == null) { + Log.e(TAG, String.format("Tried to access status but got null at position: %d", position)) + return + } + + val updatedStatus = StatusViewData.Builder(status) + .setCollapsed(isCollapsed) + .createStatusViewData() + searchAdapter.updateStatusAtPosition(updatedStatus, position) + searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) } } companion object { From f1c71de19acf8d25c46ddc2b85cd8235e0aea676 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 05:20:35 +0200 Subject: [PATCH 18/39] Add support for expandable content to notifications too --- .../tusky/adapter/NotificationsAdapter.java | 64 ++++++++++- .../tusky/fragment/NotificationsFragment.java | 106 ++++++++++++++++-- 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 52d2055523..dec3335764 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -25,6 +25,7 @@ import android.support.v4.content.ContextCompat; import android.support.v4.text.BidiFormatter; import android.support.v7.widget.RecyclerView; +import android.text.InputFilter; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; @@ -244,6 +245,14 @@ public interface NotificationActionListener { void onExpandedChange(boolean expanded, int position); + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onNotificationContentCollapsedChange(boolean isCollapsed, int position); } private static class FollowViewHolder extends RecyclerView.ViewHolder { @@ -309,6 +318,7 @@ private static class StatusNotificationViewHolder extends RecyclerView.ViewHolde private final ImageView notificationAvatar; private final TextView contentWarningDescriptionTextView; private final ToggleButton contentWarningButton; + private final ToggleButton contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder private String accountId; private String notificationId; @@ -337,6 +347,8 @@ private static class StatusNotificationViewHolder extends RecyclerView.ViewHolde message.setOnClickListener(this); statusContent.setOnClickListener(this); contentWarningButton.setOnCheckedChangeListener(this); + + contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); } private void showNotificationContent(boolean show) { @@ -346,7 +358,6 @@ private void showNotificationContent(boolean show) { statusContent.setVisibility(show ? View.VISIBLE : View.GONE); statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - } private void setDisplayName(String name, List emojis) { @@ -488,11 +499,58 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi Spanned content = statusViewData.getContent(); List emojis = statusViewData.getStatusEmojis(); - Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent); + if(contentCollapseButton != null && statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) { + contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { + int position = getAdapterPosition(); + if(position != RecyclerView.NO_POSITION && notificationActionListener != null) { + notificationActionListener.onNotificationContentCollapsedChange(isChecked, position); + } + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if(statusViewData.isCollapsed()) { + contentCollapseButton.setChecked(true); + statusContent.setFilters(new InputFilter[]{(source, start, end, dest, dstart, dend) -> { + + // Code imported from InputFilter.LengthFilter + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 + + // Changes: + // - After the text it adds and ellipsis to make it feel like the text continues + // - Max value is 500 rather than a variable + // - Trim invisible characters off the end of the 500-limited string + // - Slimmed code for saving LOCs + + int keep = 500 - (dest.length() - (dend - dstart)); + if(keep <= 0) return ""; + if(keep >= end - start) return null; // keep original + + keep += start; + + while(Character.isWhitespace(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } + + if(Character.isHighSurrogate(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } + + return source.subSequence(start, keep) + "…"; + }}); + } else { + contentCollapseButton.setChecked(false); + statusContent.setFilters(new InputFilter[]{}); + } + } else if(contentCollapseButton != null) { + contentCollapseButton.setVisibility(View.GONE); + statusContent.setFilters(new InputFilter[]{}); + } + Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent); LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); - Spanned emojifiedContentWarning = CustomEmojiHelper.emojifyString(statusViewData.getSpoilerText(), statusViewData.getStatusEmojis(), contentWarningDescriptionTextView); contentWarningDescriptionTextView.setText(emojifiedContentWarning); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index d8a6bef76c..e147e9bfb5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -40,6 +40,7 @@ import android.view.ViewGroup; import android.widget.ProgressBar; import android.widget.TextView; +import android.widget.ToggleButton; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; @@ -136,6 +137,7 @@ private Placeholder() { private String bottomId; private String topId; private boolean alwaysShowSensitiveMedia; + private boolean collapseLongStatusContent; @Override protected TimelineCases timelineCases() { @@ -149,7 +151,11 @@ protected TimelineCases timelineCases() { public NotificationViewData apply(Either input) { if (input.isRight()) { Notification notification = input.getAsRight(); - return ViewDataUtils.notificationToViewData(notification, alwaysShowSensitiveMedia); + return ViewDataUtils.notificationToViewData( + notification, + alwaysShowSensitiveMedia, + collapseLongStatusContent + ); } else { return new NotificationViewData.Placeholder(false); } @@ -194,6 +200,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( getActivity()); alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false); + collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true); adapter.setMediaPreviewEnabled(mediaPreviewEnabled); recyclerView.setAdapter(adapter); @@ -491,9 +498,82 @@ public void onLoadMore(int position) { } } + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - // TODO: Implement this method. + if(position < 0 || position >= notifications.size()) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1)); + return; + } + + NotificationViewData notification = notifications.getPairedItem(position); + if(!(notification instanceof NotificationViewData.Concrete)) { + if(notification == null) { + Log.e(TAG, String.format( + "Tried to access notification but got null at position: %d of %d", + position, + notifications.size() - 1) + ); + } else { + Log.e(TAG, String.format( + "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", + notification.getClass().getSimpleName(), + position, + notifications.size() - 1 + )); + } + + return; + } + + StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData(); + if(status == null) { + Log.e(TAG, String.format( + "Tried to access status in notification but got null at position: %d of %d", + position, + notifications.size() - 1) + ); + return; + } + + StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) + .setCollapsed(isCollapsed) + .createStatusViewData(); + + NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification; + NotificationViewData updatedNotification = new NotificationViewData.Concrete( + concreteNotification.getType(), + concreteNotification.getId(), + concreteNotification.getAccount(), + updatedStatus, + concreteNotification.isExpanded() + ); + notifications.setPairedItem(position, updatedNotification); + adapter.updateItemWithNotify(position, updatedNotification, false); + + // Since we cannot notify to the RecyclerView right away because it may be scrolling + // we run this when the RecyclerView is done doing measurements and other calculations. + // To test this is not bs: try getting a notification while scrolling, without wrapping + // notifyItemChanged in a .post() call. App will crash. + recyclerView.post(() -> adapter.notifyItemChanged(position, notification)); + } + + /** + * Called when the status {@link ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + @Override + public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { + onContentCollapsedChange(isCollapsed, position); } @Override @@ -533,6 +613,10 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin } break; } + case "collapseLongStatuses": + collapseLongStatusContent = sharedPreferences.getBoolean("collapseLongStatuses", true); + fullyRefresh(); + break; } } @@ -560,12 +644,18 @@ private void onLoadMore() { // already loaded everything return; } - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - notifications.add(Either.left(Placeholder.getInstance())); - NotificationViewData viewData = new NotificationViewData.Placeholder(true); - notifications.setPairedItem(notifications.size() - 1, viewData); - recyclerView.post(() -> adapter.addItems(Collections.singletonList(viewData))); + + // Check for out-of-bounds when loading + // This is required to allow full-timeline reloads of collapsible statuses when the settings + // change. + if(notifications.size() > 0) { + Either last = notifications.get(notifications.size() - 1); + if(last.isRight()) { + notifications.add(Either.left(Placeholder.getInstance())); + NotificationViewData viewData = new NotificationViewData.Placeholder(true); + notifications.setPairedItem(notifications.size() - 1, viewData); + recyclerView.post(() -> adapter.addItems(Collections.singletonList(viewData))); + } } sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); From d5d57aae04b5bc365e437928119e12472db39c55 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 22:06:58 +0200 Subject: [PATCH 19/39] Update codebase with some suggested changes by @charlang --- .../keylesspalace/tusky/adapter/NotificationsAdapter.java | 7 +++---- .../keylesspalace/tusky/adapter/StatusBaseViewHolder.java | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index dec3335764..a1b5e129f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -338,6 +338,7 @@ private static class StatusNotificationViewHolder extends RecyclerView.ViewHolde notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); int darkerFilter = Color.rgb(123, 123, 123); statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); @@ -347,8 +348,6 @@ private static class StatusNotificationViewHolder extends RecyclerView.ViewHolde message.setOnClickListener(this); statusContent.setOnClickListener(this); contentWarningButton.setOnCheckedChangeListener(this); - - contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); } private void showNotificationContent(boolean show) { @@ -499,7 +498,7 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi Spanned content = statusViewData.getContent(); List emojis = statusViewData.getStatusEmojis(); - if(contentCollapseButton != null && statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) { + if(statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) { contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { int position = getAdapterPosition(); if(position != RecyclerView.NO_POSITION && notificationActionListener != null) { @@ -543,7 +542,7 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi contentCollapseButton.setChecked(false); statusContent.setFilters(new InputFilter[]{}); } - } else if(contentCollapseButton != null) { + } else { contentCollapseButton.setVisibility(View.GONE); statusContent.setFilters(new InputFilter[]{}); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index cc5a5e2747..54c682288b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -502,7 +502,7 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener setSpoilerText(status.getSpoilerText(), status.getStatusEmojis(), status.isExpanded(), listener); } - if(contentCollapseButton != null && status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { + if(status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { int position = getAdapterPosition(); if(position != RecyclerView.NO_POSITION) listener.onContentCollapsedChange(isChecked, position); @@ -544,7 +544,7 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener contentCollapseButton.setChecked(false); content.setFilters(new InputFilter[] {}); } - } else if(contentCollapseButton != null) { + } else { contentCollapseButton.setVisibility(View.GONE); content.setFilters(new InputFilter[] {}); } From 07dce8c4d1c732a7d93a606a3764e54585160a4d Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 22:20:48 +0200 Subject: [PATCH 20/39] Update more code with more suggestions and move null-safety into view data --- .../tusky/fragment/NotificationsFragment.java | 9 --------- .../tusky/viewdata/NotificationViewData.java | 6 +++++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index e147e9bfb5..e964445faf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -533,15 +533,6 @@ public void onContentCollapsedChange(boolean isCollapsed, int position) { } StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData(); - if(status == null) { - Log.e(TAG, String.format( - "Tried to access status in notification but got null at position: %d of %d", - position, - notifications.size() - 1) - ); - return; - } - StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) .setCollapsed(isCollapsed) .createStatusViewData(); diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index 43a692d973..363d07e80f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -18,6 +18,8 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Notification; +import io.reactivex.annotations.NonNull; + /** * Created by charlag on 12/07/2017. * @@ -37,11 +39,12 @@ public static final class Concrete extends NotificationViewData { private final Notification.Type type; private final String id; private final Account account; + @NonNull private final StatusViewData.Concrete statusViewData; private final boolean isExpanded; public Concrete(Notification.Type type, String id, Account account, - StatusViewData.Concrete statusViewData, boolean isExpanded) { + @NonNull StatusViewData.Concrete statusViewData, boolean isExpanded) { this.type = type; this.id = id; this.account = account; @@ -61,6 +64,7 @@ public Account getAccount() { return account; } + @NonNull public StatusViewData.Concrete getStatusViewData() { return statusViewData; } From d3d72f2e505bce2ccdd4ce7228f77dd392048082 Mon Sep 17 00:00:00 2001 From: HellPie Date: Fri, 31 Aug 2018 22:36:27 +0200 Subject: [PATCH 21/39] Update even more code with even more suggested code changes --- .../tusky/fragment/NotificationsFragment.java | 15 --------- .../tusky/fragment/TimelineFragment.java | 32 +++++-------------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index e964445faf..a152c36b1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -40,7 +40,6 @@ import android.view.ViewGroup; import android.widget.ProgressBar; import android.widget.TextView; -import android.widget.ToggleButton; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; @@ -498,13 +497,6 @@ public void onLoadMore(int position) { } } - /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { if(position < 0 || position >= notifications.size()) { @@ -555,13 +547,6 @@ public void onContentCollapsedChange(boolean isCollapsed, int position) { recyclerView.post(() -> adapter.notifyItemChanged(position, notification)); } - /** - * Called when the status {@link ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ @Override public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { onContentCollapsedChange(isCollapsed, position); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 9a8c793f1b..1a573e03e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -244,8 +244,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, } private void setupTimelinePreferences() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( - getActivity()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); preferences.registerOnSharedPreferenceChangeListener(this); alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false); boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true); @@ -568,13 +567,6 @@ public void onLoadMore(int position) { } } - /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { if(position < 0 || position >= statuses.size()) { @@ -585,21 +577,13 @@ public void onContentCollapsedChange(boolean isCollapsed, int position) { StatusViewData status = statuses.getPairedItem(position); if(!(status instanceof StatusViewData.Concrete)) { // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't - // check for null values when adding values to it. - - // TODO: Implement @NonNull/@Nullable in PairedList insert methods - - if(status == null) { - Log.e(TAG, String.format("Tried to access status but got null at position: %d of %d", position, statuses.size() - 1)); - } else { - Log.e(TAG, String.format( - "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", - status.getClass().getSimpleName(), - position, - statuses.size() -1 - )); - } - + // check for null values when adding values to it although this doesn't seem to be an issue. + Log.e(TAG, String.format( + "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", + status == null ? "" : status.getClass().getSimpleName(), + position, + statuses.size() -1 + )); return; } From 6891e55b604234c7b34a3946ba806a0fe4289e62 Mon Sep 17 00:00:00 2001 From: HellPie Date: Mon, 3 Sep 2018 13:36:02 +0200 Subject: [PATCH 22/39] Revert a0a41ca and 0ee004d (Android Studio 3.1 to Android Studio 3.3 updates) --- app/build.gradle | 2 +- build.gradle | 3 +-- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ea918fb973..566060618c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ def getGitSha = { -> android { compileSdkVersion 27 -// buildToolsVersion '27.0.3' + buildToolsVersion '27.0.3' defaultConfig { applicationId "com.keylesspalace.tusky" minSdkVersion 19 diff --git a/build.gradle b/build.gradle index f0d4904696..01f499b72f 100644 --- a/build.gradle +++ b/build.gradle @@ -7,8 +7,7 @@ buildscript { google() } dependencies { -// classpath 'com.android.tools.build:gradle:3.1.4' - classpath 'com.android.tools.build:gradle:3.3.0-alpha08' + classpath 'com.android.tools.build:gradle:3.1.4' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4ef4be4a61..4ac6b1d068 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip From 3c6648b12f1a8cf98e12c98ab0dca42a46de1f41 Mon Sep 17 00:00:00 2001 From: HellPie Date: Mon, 3 Sep 2018 16:08:23 +0200 Subject: [PATCH 23/39] Add an input filter utility class to reuse code for trimming statuses --- .../tusky/adapter/NotificationsAdapter.java | 31 +------ .../tusky/adapter/StatusBaseViewHolder.java | 31 +------ .../tusky/util/SmartLengthInputFilter.java | 83 +++++++++++++++++++ 3 files changed, 87 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index a1b5e129f1..43bb66b270 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -47,6 +47,7 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.DateUtils; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import com.squareup.picasso.Picasso; @@ -509,35 +510,7 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi contentCollapseButton.setVisibility(View.VISIBLE); if(statusViewData.isCollapsed()) { contentCollapseButton.setChecked(true); - statusContent.setFilters(new InputFilter[]{(source, start, end, dest, dstart, dend) -> { - - // Code imported from InputFilter.LengthFilter - // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 - - // Changes: - // - After the text it adds and ellipsis to make it feel like the text continues - // - Max value is 500 rather than a variable - // - Trim invisible characters off the end of the 500-limited string - // - Slimmed code for saving LOCs - - int keep = 500 - (dest.length() - (dend - dstart)); - if(keep <= 0) return ""; - if(keep >= end - start) return null; // keep original - - keep += start; - - while(Character.isWhitespace(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; - } - - if(Character.isHighSurrogate(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; - } - - return source.subSequence(start, keep) + "…"; - }}); + statusContent.setFilters(new InputFilter[]{new SmartLengthInputFilter(500)}); } else { contentCollapseButton.setChecked(false); statusContent.setFilters(new InputFilter[]{}); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 54c682288b..7e988f762c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.util.DateUtils; import com.keylesspalace.tusky.util.HtmlUtils; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.viewdata.StatusViewData; import com.mikepenz.iconics.utils.Utils; @@ -511,35 +512,7 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener contentCollapseButton.setVisibility(View.VISIBLE); if(status.isCollapsed()) { contentCollapseButton.setChecked(true); - content.setFilters(new InputFilter[] {(source, start, end, dest, dstart, dend) -> { - - // Code imported from InputFilter.LengthFilter - // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 - - // Changes: - // - After the text it adds and ellipsis to make it feel like the text continues - // - Max value is 500 rather than a variable - // - Trim invisible characters off the end of the 500-limited string - // - Slimmed code for saving LOCs - - int keep = 500 - (dest.length() - (dend - dstart)); - if(keep <= 0) return ""; - if(keep >= end - start) return null; // keep original - - keep += start; - - while(Character.isWhitespace(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; - } - - if(Character.isHighSurrogate(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; - } - - return source.subSequence(start, keep) + "…"; - }}); + content.setFilters(new InputFilter[] {new SmartLengthInputFilter(500)}); } else { contentCollapseButton.setChecked(false); content.setFilters(new InputFilter[] {}); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java new file mode 100644 index 0000000000..78b389b179 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -0,0 +1,83 @@ +/* + * Copyright 2018 Diego Rossi (@_HellPie) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.keylesspalace.tusky.util; + +import android.text.InputFilter; +import android.text.Spanned; + +public class SmartLengthInputFilter implements InputFilter { + + private final int max; + + public SmartLengthInputFilter(int max) { + this.max = max; + } + + /** + * This method is called when the buffer is going to replace the + * range dstart … dend of dest + * with the new text from the range start … end + * of source. Return the CharSequence that you would + * like to have placed there instead, including an empty string + * if appropriate, or null to accept the original + * replacement. Be careful to not to reject 0-length replacements, + * as this is what happens when you delete text. Also beware that + * you should not attempt to make any changes to dest + * from this method; you may only examine it for context. + *

+ * Note: If source is an instance of {@link Spanned} or + * {@link Spannable}, the span objects in the source should be + * copied into the filtered result (i.e. the non-null return value). + * {@link TextUtils#copySpansFrom} can be used for convenience if the + * span boundary indices would be remaining identical relative to the source. + * + * @param source + * @param start + * @param end + * @param dest + * @param dstart + * @param dend + */ + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { + // Code imported from InputFilter.LengthFilter + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 + + // Changes: + // - After the text it adds and ellipsis to make it feel like the text continues + // - Trim invisible characters off the end of the already filtered string + // - Slimmed code for saving LOCs + + int keep = max - (dest.length() - (dend - dstart)); + if(keep <= 0) return ""; + if(keep >= end - start) return null; // keep original + + keep += start; + + while(Character.isWhitespace(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } + + if(Character.isHighSurrogate(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } + + return source.subSequence(start, keep) + "…"; + } +} From 40ae45e1268d811ecf594be4cb1182539008728f Mon Sep 17 00:00:00 2001 From: HellPie Date: Mon, 3 Sep 2018 20:24:23 +0200 Subject: [PATCH 24/39] Update UI of statuses to show a taller collapsible button --- app/src/main/res/layout/item_status.xml | 2 +- app/src/main/res/layout/item_status_notification.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 4129ac37bf..3daf6353d7 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -138,7 +138,7 @@ style="@style/Widget.AppCompat.Button.Borderless" android:id="@+id/button_toggle_content" android:layout_width="match_parent" - android:layout_height="32dp" + android:layout_height="wrap_content" android:padding="0dp" android:layout_toEndOf="@id/status_avatar" android:layout_below="@id/status_content" diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index 5da1da582a..3f899c762c 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -120,7 +120,7 @@ style="@style/Widget.AppCompat.Button.Borderless" android:id="@+id/button_toggle_notification_content" android:layout_width="match_parent" - android:layout_height="32dp" + android:layout_height="wrap_content" android:padding="0dp" android:layout_toEndOf="@id/status_avatar" android:layout_below="@id/notification_content" From c62b523152f54c850e384b768e1e47a9a5591c08 Mon Sep 17 00:00:00 2001 From: HellPie Date: Tue, 4 Sep 2018 08:51:17 +0200 Subject: [PATCH 25/39] Update notification fragment logging to simplify null checks --- .../tusky/fragment/NotificationsFragment.java | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index a152c36b1f..f443b89870 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -506,21 +506,12 @@ public void onContentCollapsedChange(boolean isCollapsed, int position) { NotificationViewData notification = notifications.getPairedItem(position); if(!(notification instanceof NotificationViewData.Concrete)) { - if(notification == null) { - Log.e(TAG, String.format( - "Tried to access notification but got null at position: %d of %d", - position, - notifications.size() - 1) - ); - } else { - Log.e(TAG, String.format( - "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", - notification.getClass().getSimpleName(), - position, - notifications.size() - 1 - )); - } - + Log.e(TAG, String.format( + "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", + notification == null ? "null" : notification.getClass().getSimpleName(), + position, + notifications.size() - 1 + )); return; } From 0d83ff9c5e78917efc6434c68000e7dba7650ea9 Mon Sep 17 00:00:00 2001 From: HellPie Date: Wed, 5 Sep 2018 22:38:36 +0200 Subject: [PATCH 26/39] Add smartness to SmartLengthInputFilter such as word trimming and runway --- .../tusky/adapter/NotificationsAdapter.java | 4 +- .../tusky/adapter/StatusBaseViewHolder.java | 35 +++--- .../tusky/util/SmartLengthInputFilter.java | 109 +++++++++++++----- .../tusky/util/ViewDataUtils.java | 2 +- 4 files changed, 101 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 43bb66b270..9a2ce4e8a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -510,7 +510,9 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi contentCollapseButton.setVisibility(View.VISIBLE); if(statusViewData.isCollapsed()) { contentCollapseButton.setChecked(true); - statusContent.setFilters(new InputFilter[]{new SmartLengthInputFilter(500)}); + statusContent.setFilters(new InputFilter[]{ + new SmartLengthInputFilter(SmartLengthInputFilter.LENGTH_DEFAULT) + }); } else { contentCollapseButton.setChecked(false); statusContent.setFilters(new InputFilter[]{}); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 7e988f762c..961c0b4f40 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -503,23 +503,28 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener setSpoilerText(status.getSpoilerText(), status.getStatusEmojis(), status.isExpanded(), listener); } - if(status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { - contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { - int position = getAdapterPosition(); - if(position != RecyclerView.NO_POSITION) listener.onContentCollapsedChange(isChecked, position); - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if(status.isCollapsed()) { - contentCollapseButton.setChecked(true); - content.setFilters(new InputFilter[] {new SmartLengthInputFilter(500)}); + if(contentCollapseButton != null) { + if(status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { + contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { + int position = getAdapterPosition(); + if(position != RecyclerView.NO_POSITION) + listener.onContentCollapsedChange(isChecked, position); + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if(status.isCollapsed()) { + contentCollapseButton.setChecked(true); + content.setFilters(new InputFilter[]{ + new SmartLengthInputFilter(SmartLengthInputFilter.LENGTH_DEFAULT) + }); + } else { + contentCollapseButton.setChecked(false); + content.setFilters(new InputFilter[]{}); + } } else { - contentCollapseButton.setChecked(false); - content.setFilters(new InputFilter[] {}); + contentCollapseButton.setVisibility(View.GONE); + content.setFilters(new InputFilter[]{}); } - } else { - contentCollapseButton.setVisibility(View.GONE); - content.setFilters(new InputFilter[] {}); } setContent(status.getContent(), status.getMentions(), status.getStatusEmojis(), listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java index 78b389b179..76650bd7c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -19,58 +19,103 @@ import android.text.InputFilter; import android.text.Spanned; +import java.text.BreakIterator; + +/** + * A customized version of {@link android.text.InputFilter.LengthFilter} which allows smarter + * constraints and adds better visuals such as: + *

    + *
  • Ellipsis at the end of the constrained text to show continuation.
  • + *
  • Trimming of invisible characters (new lines, spaces, etc.) from the constrained text.
  • + *
  • Constraints end at the end of the last "word", before a whitespace.
  • + *
  • Expansion of the limit by up to 10 characters to facilitate the previous constraint.
  • + *
  • Constraints are not applied if the percentage of hidden content is too small.
  • + *
+ * + * Some of these features are configurable through at instancing time. + */ public class SmartLengthInputFilter implements InputFilter { + /** + * Default for maximum status length on Mastodon and default collapsing + * length on Pleroma. + */ + public static final int LENGTH_DEFAULT = 50; + private final int max; + private final boolean allowRunway; + private final boolean skipIfBadRatio; + /** + * Creates a new {@link SmartLengthInputFilter} instance with a predefined maximum length and + * all the smart constraint features this class supports. + * + * @param max The maximum length before trimming. May change based on other constraints. + */ public SmartLengthInputFilter(int max) { - this.max = max; + this(max, true, true); } /** - * This method is called when the buffer is going to replace the - * range dstart … dend of dest - * with the new text from the range start … end - * of source. Return the CharSequence that you would - * like to have placed there instead, including an empty string - * if appropriate, or null to accept the original - * replacement. Be careful to not to reject 0-length replacements, - * as this is what happens when you delete text. Also beware that - * you should not attempt to make any changes to dest - * from this method; you may only examine it for context. - *

- * Note: If source is an instance of {@link Spanned} or - * {@link Spannable}, the span objects in the source should be - * copied into the filtered result (i.e. the non-null return value). - * {@link TextUtils#copySpansFrom} can be used for convenience if the - * span boundary indices would be remaining identical relative to the source. + * Fully configures a new {@link SmartLengthInputFilter} to fine tune the state of the + * supported smart constraints this class supports. * - * @param source - * @param start - * @param end - * @param dest - * @param dstart - * @param dend + * @param max The maximum length before trimming. + * @param allowRunway Whether to extend {@param max} by an extra 10 characters + * and trim precisely at the end of the closest word. + * @param skipIfBadRatio Whether to skip trimming entirely if the trimmed content + * will be less than 25% of the shown content. */ + public SmartLengthInputFilter(int max, boolean allowRunway, boolean skipIfBadRatio) { + this.max = max; + this.allowRunway = allowRunway; + this.skipIfBadRatio = skipIfBadRatio; + } + + /** {@inheritDoc} */ @Override public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { - // Code imported from InputFilter.LengthFilter + // Code originally imported from InputFilter.LengthFilter but heavily customized. // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 - // Changes: - // - After the text it adds and ellipsis to make it feel like the text continues - // - Trim invisible characters off the end of the already filtered string - // - Slimmed code for saving LOCs - + int sourceLength = source.length(); int keep = max - (dest.length() - (dend - dstart)); if(keep <= 0) return ""; if(keep >= end - start) return null; // keep original keep += start; - while(Character.isWhitespace(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; + // Enable skipping trimming if the ratio is not good enough + if(skipIfBadRatio && (double)keep / sourceLength > 0.75) + return null; + + // Enable trimming at the end of the closest word if possible + if(allowRunway && Character.isLetterOrDigit(source.charAt(keep))) { + int boundary; + + // Android N+ offer a clone of the ICU APIs in Java for better internationalization and + // unicode support. Using the ICU version of BreakIterator grants better support for + // those without having to add the ICU4J library at a minimum Api trade-off. + if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + android.icu.text.BreakIterator iterator = android.icu.text.BreakIterator.getWordInstance(); + iterator.setText(source.toString()); + boundary = iterator.following(keep); + if(keep - boundary > 10) boundary = iterator.preceding(keep); + } else { + java.text.BreakIterator iterator = BreakIterator.getWordInstance(); + iterator.setText(source.toString()); + boundary = iterator.following(keep); + if(keep - boundary > 10) boundary = iterator.preceding(keep); + } + + keep = boundary; + } else { + + // If no runway is allowed simply remove whitespaces if present + while(Character.isWhitespace(source.charAt(keep - 1))) { + --keep; + if(keep == start) return ""; + } } if(Character.isHighSurrogate(source.charAt(keep - 1))) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index aadb1e3f0d..e28a6b204e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -59,7 +59,7 @@ public static StatusViewData.Concrete statusToViewData(@Nullable Status status, .setApplication(visibleStatus.getApplication()) .setStatusEmojis(visibleStatus.getEmojis()) .setAccountEmojis(visibleStatus.getAccount().getEmojis()) - .setCollapsible(collapseLongStatusContent && status.getContent().length() > 500) + .setCollapsible(collapseLongStatusContent && status.getContent().length() > SmartLengthInputFilter.LENGTH_DEFAULT) .setCollapsed(true) .createStatusViewData(); } From b2dbd16678cbec4c767ec47168186b8315214218 Mon Sep 17 00:00:00 2001 From: HellPie Date: Wed, 5 Sep 2018 22:47:31 +0200 Subject: [PATCH 27/39] Fix posts with show more button even if bad ratio didn't collapse --- .../tusky/util/SmartLengthInputFilter.java | 12 ++++++++++++ .../com/keylesspalace/tusky/util/ViewDataUtils.java | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java index 76650bd7c6..3f92c2309a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -72,6 +72,18 @@ public SmartLengthInputFilter(int max, boolean allowRunway, boolean skipIfBadRat this.skipIfBadRatio = skipIfBadRatio; } + /** + * Calculates if it's worth trimming the message at a specific limit or if the content + * that will be hidden will not be enough to justify the operation. + * + * @param message The message to trim. + * @param limit The maximum length after trimming. + * @return Whether the message should be trimmed or not. + */ + public static boolean hasBadRatio(Spanned message, int limit) { + return (double) limit / message.length() > 0.75; + } + /** {@inheritDoc} */ @Override public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index e28a6b204e..9822cbeb97 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -59,7 +59,10 @@ public static StatusViewData.Concrete statusToViewData(@Nullable Status status, .setApplication(visibleStatus.getApplication()) .setStatusEmojis(visibleStatus.getEmojis()) .setAccountEmojis(visibleStatus.getAccount().getEmojis()) - .setCollapsible(collapseLongStatusContent && status.getContent().length() > SmartLengthInputFilter.LENGTH_DEFAULT) + .setCollapsible(collapseLongStatusContent && !SmartLengthInputFilter.hasBadRatio( + visibleStatus.getContent(), + SmartLengthInputFilter.LENGTH_DEFAULT + )) .setCollapsed(true) .createStatusViewData(); } From ba7ccf97dec35ef470498e09668ede7fe6159bcf Mon Sep 17 00:00:00 2001 From: HellPie Date: Wed, 5 Sep 2018 23:05:23 +0200 Subject: [PATCH 28/39] Fix thread view showing button but not collapsing by implementing the feature --- .../tusky/adapter/StatusBaseViewHolder.java | 2 ++ .../tusky/fragment/ViewThreadFragment.java | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 961c0b4f40..e39b492094 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -503,6 +503,8 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener setSpoilerText(status.getSpoilerText(), status.getStatusEmojis(), status.isExpanded(), listener); } + // When viewing threads this ViewHolder is used and the main post does not have a collapse + // button by design so avoid crashing the app when that happens if(contentCollapseButton != null) { if(status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index a56df78954..b80c64bc55 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -55,6 +55,7 @@ import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.ConversationLineItemDecoration; @@ -361,7 +362,32 @@ public void onLoadMore(int pos) { @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - // No need to implement this method as status threads always show all content in a status. + if(position < 0 || position >= statuses.size()) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); + return; + } + + StatusViewData.Concrete status = statuses.getPairedItem(position); + if(status == null) { + // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't + // check for null values when adding values to it although this doesn't seem to be an issue. + Log.e(TAG, String.format( + "Expected StatusViewData.Concrete, got null instead at position: %d of %d", + position, + statuses.size() - 1 + )); + return; + } + + StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) + .setCollapsible(collapseLongStatusContent && !SmartLengthInputFilter.hasBadRatio( + status.getContent(), + SmartLengthInputFilter.LENGTH_DEFAULT + )) + .setCollapsed(isCollapsed) + .createStatusViewData(); + statuses.setPairedItem(position, updatedStatus); + recyclerView.post(() -> adapter.setItem(position, updatedStatus, true)); } @Override From 7cddab41d98dd98d32979d0b9eb7b7bf719a1739 Mon Sep 17 00:00:00 2001 From: HellPie Date: Wed, 5 Sep 2018 23:59:13 +0200 Subject: [PATCH 29/39] Fix spannable losing spans when collapsed and restore length to 500 characters --- .../keylesspalace/tusky/util/SmartLengthInputFilter.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java index 3f92c2309a..7fa6821b5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.util; import android.text.InputFilter; +import android.text.SpannableStringBuilder; import android.text.Spanned; import java.text.BreakIterator; @@ -40,7 +41,7 @@ public class SmartLengthInputFilter implements InputFilter { * Default for maximum status length on Mastodon and default collapsing * length on Pleroma. */ - public static final int LENGTH_DEFAULT = 50; + public static final int LENGTH_DEFAULT = 500; private final int max; private final boolean allowRunway; @@ -135,6 +136,10 @@ public CharSequence filter(CharSequence source, int start, int end, Spanned dest if(keep == start) return ""; } - return source.subSequence(start, keep) + "…"; + if(source instanceof Spanned) { + return new SpannableStringBuilder(source, start, keep).append("…"); + } else { + return source.subSequence(start, keep) + "…"; + } } } From 16ceb60a288de29b95f5012d2cdda9a3f31ddea0 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 6 Sep 2018 00:05:05 +0200 Subject: [PATCH 30/39] Remove debug build suffix as per request --- app/build.gradle | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 566060618c..d9137fe2a5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,9 +30,7 @@ android { shrinkResources true proguardFiles 'proguard-rules.pro' } - debug { - applicationIdSuffix ".debug" - } + debug { } } flavorDimensions "color" From 3af692ef41c7596a4fc152dc590f4d57423ea867 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 13 Sep 2018 22:03:22 +0200 Subject: [PATCH 31/39] Fix all the merging happened in f66d689, 623cad2 and 7056ba5 --- .../main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index 2ce9ae5373..a16c63a309 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -72,6 +72,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { alwaysShowSensitiveMedia, collapseLongStatusContent, this, + this, useAbsoluteTime) searchRecyclerView.adapter = searchAdapter From 4545b73339114b0e7143e8d66747e343e6a3b841 Mon Sep 17 00:00:00 2001 From: HellPie Date: Thu, 13 Sep 2018 22:05:27 +0200 Subject: [PATCH 32/39] Fix notification button spanning full width rather than content width --- app/src/main/res/layout/item_status_notification.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index 3f899c762c..02a6670b61 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -122,7 +122,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="0dp" - android:layout_toEndOf="@id/status_avatar" + android:layout_toEndOf="@id/notification_status_avatar" android:layout_below="@id/notification_content" android:textOff="@string/status_content_show_less" android:textOn="@string/status_content_show_more" From 4a3c8bb9333ebee26bba81e73fd6a79d0a3a0d82 Mon Sep 17 00:00:00 2001 From: HellPie Date: Sat, 15 Sep 2018 00:10:27 +0200 Subject: [PATCH 33/39] Add a way to access a singleton to smart filter and use clearer code --- .../tusky/util/SmartLengthInputFilter.java | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java index 7fa6821b5c..25f68d6450 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -38,11 +38,22 @@ public class SmartLengthInputFilter implements InputFilter { /** - * Default for maximum status length on Mastodon and default collapsing - * length on Pleroma. + * Defines how many characters to extend beyond the limit to cut at the end of the word on the + * boundary of it rather than cutting at the word preceding that one. + */ + private static final int RUNWAY = 10; + + /** + * Default for maximum status length on Mastodon and default collapsing length on Pleroma. */ public static final int LENGTH_DEFAULT = 500; + /** + * Stores a reusable singleton instance of a {@link SmartLengthInputFilter} already configured + * to the default maximum length of {@value #LENGTH_DEFAULT}. + */ + public static final SmartLengthInputFilter INSTANCE = new SmartLengthInputFilter(LENGTH_DEFAULT); + private final int max; private final boolean allowRunway; private final boolean skipIfBadRatio; @@ -58,8 +69,8 @@ public SmartLengthInputFilter(int max) { } /** - * Fully configures a new {@link SmartLengthInputFilter} to fine tune the state of the - * supported smart constraints this class supports. + * Fully configures a new {@link SmartLengthInputFilter} to fine tune the state of the supported + * smart constraints this class supports. * * @param max The maximum length before trimming. * @param allowRunway Whether to extend {@param max} by an extra 10 characters @@ -74,8 +85,8 @@ public SmartLengthInputFilter(int max, boolean allowRunway, boolean skipIfBadRat } /** - * Calculates if it's worth trimming the message at a specific limit or if the content - * that will be hidden will not be enough to justify the operation. + * Calculates if it's worth trimming the message at a specific limit or if the content that will + * be hidden will not be enough to justify the operation. * * @param message The message to trim. * @param limit The maximum length after trimming. @@ -113,12 +124,12 @@ public CharSequence filter(CharSequence source, int start, int end, Spanned dest android.icu.text.BreakIterator iterator = android.icu.text.BreakIterator.getWordInstance(); iterator.setText(source.toString()); boundary = iterator.following(keep); - if(keep - boundary > 10) boundary = iterator.preceding(keep); + if(keep - boundary > RUNWAY) boundary = iterator.preceding(keep); } else { java.text.BreakIterator iterator = BreakIterator.getWordInstance(); iterator.setText(source.toString()); boundary = iterator.following(keep); - if(keep - boundary > 10) boundary = iterator.preceding(keep); + if(keep - boundary > RUNWAY) boundary = iterator.preceding(keep); } keep = boundary; From a38676f724b76d60d7fd23d28ed28c6636cdf947 Mon Sep 17 00:00:00 2001 From: HellPie Date: Sat, 15 Sep 2018 00:11:11 +0200 Subject: [PATCH 34/39] Update view holders using smart input filters to use more singletons --- .../tusky/adapter/NotificationsAdapter.java | 11 ++++++----- .../tusky/adapter/StatusBaseViewHolder.java | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 0a894ecedc..d6b358246f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -64,6 +64,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_FOLLOW = 2; private static final int VIEW_TYPE_PLACEHOLDER = 3; + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[] { SmartLengthInputFilter.INSTANCE }; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + private List notifications; private StatusActionListener statusListener; private NotificationActionListener notificationActionListener; @@ -533,16 +536,14 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi contentCollapseButton.setVisibility(View.VISIBLE); if(statusViewData.isCollapsed()) { contentCollapseButton.setChecked(true); - statusContent.setFilters(new InputFilter[]{ - new SmartLengthInputFilter(SmartLengthInputFilter.LENGTH_DEFAULT) - }); + statusContent.setFilters(COLLAPSE_INPUT_FILTER); } else { contentCollapseButton.setChecked(false); - statusContent.setFilters(new InputFilter[]{}); + statusContent.setFilters(NO_INPUT_FILTER); } } else { contentCollapseButton.setVisibility(View.GONE); - statusContent.setFilters(new InputFilter[]{}); + statusContent.setFilters(NO_INPUT_FILTER); } Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index cffa51604f..57669ba36b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -41,6 +41,9 @@ import at.connyduck.sparkbutton.SparkEventListener; abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[] { SmartLengthInputFilter.INSTANCE }; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + private View container; private TextView displayName; private TextView username; @@ -540,16 +543,14 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener contentCollapseButton.setVisibility(View.VISIBLE); if(status.isCollapsed()) { contentCollapseButton.setChecked(true); - content.setFilters(new InputFilter[]{ - new SmartLengthInputFilter(SmartLengthInputFilter.LENGTH_DEFAULT) - }); + content.setFilters(COLLAPSE_INPUT_FILTER); } else { contentCollapseButton.setChecked(false); - content.setFilters(new InputFilter[]{}); + content.setFilters(NO_INPUT_FILTER); } } else { contentCollapseButton.setVisibility(View.GONE); - content.setFilters(new InputFilter[]{}); + content.setFilters(NO_INPUT_FILTER); } } From b5d13ed4f16a55bd3747fcd2c4557ff4860e3e66 Mon Sep 17 00:00:00 2001 From: HellPie Date: Sat, 15 Sep 2018 00:28:56 +0200 Subject: [PATCH 35/39] Fix code style lacking spaces before boolean checks in ifs and others --- .../tusky/adapter/NotificationsAdapter.java | 6 +- .../tusky/adapter/StatusBaseViewHolder.java | 8 +- .../tusky/fragment/NotificationsFragment.java | 8 +- .../tusky/fragment/SearchFragment.kt | 3 +- .../tusky/fragment/TimelineFragment.java | 4 +- .../tusky/fragment/ViewThreadFragment.java | 4 +- .../tusky/util/SmartLengthInputFilter.java | 232 +++++++++--------- 7 files changed, 132 insertions(+), 133 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index d6b358246f..8a319ed5f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -525,16 +525,16 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi Spanned content = statusViewData.getContent(); List emojis = statusViewData.getStatusEmojis(); - if(statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) { + if (statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) { contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { int position = getAdapterPosition(); - if(position != RecyclerView.NO_POSITION && notificationActionListener != null) { + if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { notificationActionListener.onNotificationContentCollapsedChange(isChecked, position); } }); contentCollapseButton.setVisibility(View.VISIBLE); - if(statusViewData.isCollapsed()) { + if (statusViewData.isCollapsed()) { contentCollapseButton.setChecked(true); statusContent.setFilters(COLLAPSE_INPUT_FILTER); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 57669ba36b..ec58c0a0f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -532,16 +532,16 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener // When viewing threads this ViewHolder is used and the main post does not have a collapse // button by design so avoid crashing the app when that happens - if(contentCollapseButton != null) { - if(status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { + if (contentCollapseButton != null) { + if (status.isCollapsible() && (status.isExpanded() || status.getSpoilerText() == null || status.getSpoilerText().isEmpty())) { contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { int position = getAdapterPosition(); - if(position != RecyclerView.NO_POSITION) + if (position != RecyclerView.NO_POSITION) listener.onContentCollapsedChange(isChecked, position); }); contentCollapseButton.setVisibility(View.VISIBLE); - if(status.isCollapsed()) { + if (status.isCollapsed()) { contentCollapseButton.setChecked(true); content.setFilters(COLLAPSE_INPUT_FILTER); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index fac113f553..63af2e6638 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -501,13 +501,13 @@ public void onLoadMore(int position) { @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if(position < 0 || position >= notifications.size()) { + if (position < 0 || position >= notifications.size()) { Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1)); return; } NotificationViewData notification = notifications.getPairedItem(position); - if(!(notification instanceof NotificationViewData.Concrete)) { + if (!(notification instanceof NotificationViewData.Concrete)) { Log.e(TAG, String.format( "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", notification == null ? "null" : notification.getClass().getSimpleName(), @@ -617,9 +617,9 @@ private void onLoadMore() { // Check for out-of-bounds when loading // This is required to allow full-timeline reloads of collapsible statuses when the settings // change. - if(notifications.size() > 0) { + if (notifications.size() > 0) { Either last = notifications.get(notifications.size() - 1); - if(last.isRight()) { + if (last.isRight()) { notifications.add(Either.left(Placeholder.getInstance())); NotificationViewData viewData = new NotificationViewData.Placeholder(true); notifications.setPairedItem(notifications.size() - 1, viewData); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index a16c63a309..a5b2bfa75d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -145,7 +145,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onReblog(reblog: Boolean, position: Int) { val status = searchAdapter.getStatusAtPosition(position) - if(status != null) { + if (status != null) { timelineCases.reblogWithCallback(status, reblog, object: Callback { override fun onResponse(call: Call?, response: Response?) { status.reblogged = true @@ -162,7 +162,6 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onFailure(call: Call?, t: Throwable?) { Log.d(TAG, "Failed to reblog status " + status.id, t) } - }) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index bc5cd4a2ad..9294221d93 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -574,13 +574,13 @@ public void onLoadMore(int position) { @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if(position < 0 || position >= statuses.size()) { + if (position < 0 || position >= statuses.size()) { Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); return; } StatusViewData status = statuses.getPairedItem(position); - if(!(status instanceof StatusViewData.Concrete)) { + if (!(status instanceof StatusViewData.Concrete)) { // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't // check for null values when adding values to it although this doesn't seem to be an issue. Log.e(TAG, String.format( diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 9031b49ac2..2fa699ce90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -364,13 +364,13 @@ public void onLoadMore(int pos) { @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if(position < 0 || position >= statuses.size()) { + if (position < 0 || position >= statuses.size()) { Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); return; } StatusViewData.Concrete status = statuses.getPairedItem(position); - if(status == null) { + if (status == null) { // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't // check for null values when adding values to it although this doesn't seem to be an issue. Log.e(TAG, String.format( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java index 25f68d6450..d961ba5906 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -37,120 +37,120 @@ */ public class SmartLengthInputFilter implements InputFilter { - /** - * Defines how many characters to extend beyond the limit to cut at the end of the word on the - * boundary of it rather than cutting at the word preceding that one. - */ - private static final int RUNWAY = 10; - - /** - * Default for maximum status length on Mastodon and default collapsing length on Pleroma. - */ - public static final int LENGTH_DEFAULT = 500; - - /** - * Stores a reusable singleton instance of a {@link SmartLengthInputFilter} already configured - * to the default maximum length of {@value #LENGTH_DEFAULT}. - */ - public static final SmartLengthInputFilter INSTANCE = new SmartLengthInputFilter(LENGTH_DEFAULT); - - private final int max; - private final boolean allowRunway; - private final boolean skipIfBadRatio; - - /** - * Creates a new {@link SmartLengthInputFilter} instance with a predefined maximum length and - * all the smart constraint features this class supports. - * - * @param max The maximum length before trimming. May change based on other constraints. - */ - public SmartLengthInputFilter(int max) { - this(max, true, true); - } - - /** - * Fully configures a new {@link SmartLengthInputFilter} to fine tune the state of the supported - * smart constraints this class supports. - * - * @param max The maximum length before trimming. - * @param allowRunway Whether to extend {@param max} by an extra 10 characters - * and trim precisely at the end of the closest word. - * @param skipIfBadRatio Whether to skip trimming entirely if the trimmed content - * will be less than 25% of the shown content. - */ - public SmartLengthInputFilter(int max, boolean allowRunway, boolean skipIfBadRatio) { - this.max = max; - this.allowRunway = allowRunway; - this.skipIfBadRatio = skipIfBadRatio; - } - - /** - * Calculates if it's worth trimming the message at a specific limit or if the content that will - * be hidden will not be enough to justify the operation. - * - * @param message The message to trim. - * @param limit The maximum length after trimming. - * @return Whether the message should be trimmed or not. - */ - public static boolean hasBadRatio(Spanned message, int limit) { - return (double) limit / message.length() > 0.75; - } - - /** {@inheritDoc} */ - @Override - public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { - // Code originally imported from InputFilter.LengthFilter but heavily customized. - // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 - - int sourceLength = source.length(); - int keep = max - (dest.length() - (dend - dstart)); - if(keep <= 0) return ""; - if(keep >= end - start) return null; // keep original - - keep += start; - - // Enable skipping trimming if the ratio is not good enough - if(skipIfBadRatio && (double)keep / sourceLength > 0.75) - return null; - - // Enable trimming at the end of the closest word if possible - if(allowRunway && Character.isLetterOrDigit(source.charAt(keep))) { - int boundary; - - // Android N+ offer a clone of the ICU APIs in Java for better internationalization and - // unicode support. Using the ICU version of BreakIterator grants better support for - // those without having to add the ICU4J library at a minimum Api trade-off. - if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - android.icu.text.BreakIterator iterator = android.icu.text.BreakIterator.getWordInstance(); - iterator.setText(source.toString()); - boundary = iterator.following(keep); - if(keep - boundary > RUNWAY) boundary = iterator.preceding(keep); - } else { - java.text.BreakIterator iterator = BreakIterator.getWordInstance(); - iterator.setText(source.toString()); - boundary = iterator.following(keep); - if(keep - boundary > RUNWAY) boundary = iterator.preceding(keep); - } - - keep = boundary; - } else { - - // If no runway is allowed simply remove whitespaces if present - while(Character.isWhitespace(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; - } - } - - if(Character.isHighSurrogate(source.charAt(keep - 1))) { - --keep; - if(keep == start) return ""; - } - - if(source instanceof Spanned) { - return new SpannableStringBuilder(source, start, keep).append("…"); - } else { - return source.subSequence(start, keep) + "…"; - } - } + /** + * Defines how many characters to extend beyond the limit to cut at the end of the word on the + * boundary of it rather than cutting at the word preceding that one. + */ + private static final int RUNWAY = 10; + + /** + * Default for maximum status length on Mastodon and default collapsing length on Pleroma. + */ + public static final int LENGTH_DEFAULT = 500; + + /** + * Stores a reusable singleton instance of a {@link SmartLengthInputFilter} already configured + * to the default maximum length of {@value #LENGTH_DEFAULT}. + */ + public static final SmartLengthInputFilter INSTANCE = new SmartLengthInputFilter(LENGTH_DEFAULT); + + private final int max; + private final boolean allowRunway; + private final boolean skipIfBadRatio; + + /** + * Creates a new {@link SmartLengthInputFilter} instance with a predefined maximum length and + * all the smart constraint features this class supports. + * + * @param max The maximum length before trimming. May change based on other constraints. + */ + public SmartLengthInputFilter(int max) { + this(max, true, true); + } + + /** + * Fully configures a new {@link SmartLengthInputFilter} to fine tune the state of the supported + * smart constraints this class supports. + * + * @param max The maximum length before trimming. + * @param allowRunway Whether to extend {@param max} by an extra 10 characters + * and trim precisely at the end of the closest word. + * @param skipIfBadRatio Whether to skip trimming entirely if the trimmed content + * will be less than 25% of the shown content. + */ + public SmartLengthInputFilter(int max, boolean allowRunway, boolean skipIfBadRatio) { + this.max = max; + this.allowRunway = allowRunway; + this.skipIfBadRatio = skipIfBadRatio; + } + + /** + * Calculates if it's worth trimming the message at a specific limit or if the content that will + * be hidden will not be enough to justify the operation. + * + * @param message The message to trim. + * @param limit The maximum length after trimming. + * @return Whether the message should be trimmed or not. + */ + public static boolean hasBadRatio(Spanned message, int limit) { + return (double) limit / message.length() > 0.75; + } + + /** {@inheritDoc} */ + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { + // Code originally imported from InputFilter.LengthFilter but heavily customized. + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 + + int sourceLength = source.length(); + int keep = max - (dest.length() - (dend - dstart)); + if (keep <= 0) return ""; + if (keep >= end - start) return null; // keep original + + keep += start; + + // Enable skipping trimming if the ratio is not good enough + if (skipIfBadRatio && (double)keep / sourceLength > 0.75) + return null; + + // Enable trimming at the end of the closest word if possible + if (allowRunway && Character.isLetterOrDigit(source.charAt(keep))) { + int boundary; + + // Android N+ offer a clone of the ICU APIs in Java for better internationalization and + // unicode support. Using the ICU version of BreakIterator grants better support for + // those without having to add the ICU4J library at a minimum Api trade-off. + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + android.icu.text.BreakIterator iterator = android.icu.text.BreakIterator.getWordInstance(); + iterator.setText(source.toString()); + boundary = iterator.following(keep); + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep); + } else { + java.text.BreakIterator iterator = BreakIterator.getWordInstance(); + iterator.setText(source.toString()); + boundary = iterator.following(keep); + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep); + } + + keep = boundary; + } else { + + // If no runway is allowed simply remove whitespaces if present + while (Character.isWhitespace(source.charAt(keep - 1))) { + --keep; + if (keep == start) return ""; + } + } + + if (Character.isHighSurrogate(source.charAt(keep - 1))) { + --keep; + if (keep == start) return ""; + } + + if (source instanceof Spanned) { + return new SpannableStringBuilder(source, start, keep).append("…"); + } else { + return source.subSequence(start, keep) + "…"; + } + } } From 33200bb5ae7945884f16ad2a5975c445ea46a61d Mon Sep 17 00:00:00 2001 From: HellPie Date: Sat, 15 Sep 2018 00:43:04 +0200 Subject: [PATCH 36/39] Remove all code related to collapsibility preferences, strings included --- .../tusky/adapter/SearchResultsAdapter.java | 6 +----- .../tusky/fragment/NotificationsFragment.java | 9 +-------- .../keylesspalace/tusky/fragment/SearchFragment.kt | 10 ++-------- .../tusky/fragment/TimelineFragment.java | 10 +--------- .../tusky/fragment/ViewThreadFragment.java | 7 ++----- .../com/keylesspalace/tusky/util/ViewDataUtils.java | 11 ++++------- app/src/main/res/values/strings.xml | 5 ----- app/src/main/res/xml/preferences.xml | 8 -------- 8 files changed, 11 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java index b797c08885..c64a55e9f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java @@ -48,7 +48,6 @@ public class SearchResultsAdapter extends RecyclerView.Adapter { private boolean mediaPreviewsEnabled; private boolean alwaysShowSensitiveMedia; - private boolean collapseLongStatusContent; private boolean useAbsoluteTime; private LinkListener linkListener; @@ -56,7 +55,6 @@ public class SearchResultsAdapter extends RecyclerView.Adapter { public SearchResultsAdapter(boolean mediaPreviewsEnabled, boolean alwaysShowSensitiveMedia, - boolean collapseLongStatusContent, LinkListener linkListener, StatusActionListener statusListener, boolean useAbsoluteTime) { @@ -68,7 +66,6 @@ public SearchResultsAdapter(boolean mediaPreviewsEnabled, this.mediaPreviewsEnabled = mediaPreviewsEnabled; this.alwaysShowSensitiveMedia = alwaysShowSensitiveMedia; - this.collapseLongStatusContent = collapseLongStatusContent; this.useAbsoluteTime = useAbsoluteTime; this.linkListener = linkListener; @@ -160,8 +157,7 @@ public void updateSearchResults(SearchResults results) { for(Status status: results.getStatuses()) { concreteStatusList.add(ViewDataUtils.statusToViewData( status, - alwaysShowSensitiveMedia, - collapseLongStatusContent + alwaysShowSensitiveMedia )); } hashtagList = results.getHashtags(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 63af2e6638..8d06f66e9c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -136,7 +136,6 @@ private Placeholder() { private String bottomId; private String topId; private boolean alwaysShowSensitiveMedia; - private boolean collapseLongStatusContent; @Override protected TimelineCases timelineCases() { @@ -152,8 +151,7 @@ public NotificationViewData apply(Either input) { Notification notification = input.getAsRight(); return ViewDataUtils.notificationToViewData( notification, - alwaysShowSensitiveMedia, - collapseLongStatusContent + alwaysShowSensitiveMedia ); } else { return new NotificationViewData.Placeholder(false); @@ -199,7 +197,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( getActivity()); alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false); - collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true); adapter.setMediaPreviewEnabled(mediaPreviewEnabled); boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); @@ -582,10 +579,6 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin } break; } - case "collapseLongStatuses": - collapseLongStatusContent = sharedPreferences.getBoolean("collapseLongStatuses", true); - fullyRefresh(); - break; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index a5b2bfa75d..044bf67c08 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -50,7 +50,6 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { private var alwaysShowSensitiveMedia = false private var mediaPreviewEnabled = true - private var collapseLongStatusContent = true; private var useAbsoluteTime = false override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -63,14 +62,11 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true) useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) - collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); - searchRecyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) searchRecyclerView.layoutManager = LinearLayoutManager(view.context) searchAdapter = SearchResultsAdapter( mediaPreviewEnabled, alwaysShowSensitiveMedia, - collapseLongStatusContent, this, this, useAbsoluteTime) @@ -152,8 +148,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { searchAdapter.updateStatusAtPosition( ViewDataUtils.statusToViewData( status, - alwaysShowSensitiveMedia, - collapseLongStatusContent + alwaysShowSensitiveMedia ), position ) @@ -175,8 +170,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { searchAdapter.updateStatusAtPosition( ViewDataUtils.statusToViewData( status, - alwaysShowSensitiveMedia, - collapseLongStatusContent + alwaysShowSensitiveMedia ), position ) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 9294221d93..d75551ff95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -146,7 +146,6 @@ private enum FetchEnd { private boolean didLoadEverythingBottom; private boolean alwaysShowSensitiveMedia; - private boolean collapseLongStatusContent; @Override protected TimelineCases timelineCases() { @@ -161,8 +160,7 @@ public StatusViewData apply(Either input) { if (status != null) { return ViewDataUtils.statusToViewData( status, - alwaysShowSensitiveMedia, - collapseLongStatusContent + alwaysShowSensitiveMedia ); } else { Placeholder placeholder = input.getAsLeft(); @@ -268,8 +266,6 @@ private void setupTimelinePreferences() { filterRemoveRegexMatcher = Pattern.compile(regexFilter, Pattern.CASE_INSENSITIVE) .matcher(""); } - - collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); } private void setupSwipeRefreshLayout() { @@ -685,10 +681,6 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin alwaysShowSensitiveMedia = sharedPreferences.getBoolean("alwaysShowSensitiveMedia", false); break; } - case "collapseLongStatuses": - collapseLongStatusContent = sharedPreferences.getBoolean("collapseLongStatuses", true); - fullyRefresh(); - break; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 2fa699ce90..175419bbca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -92,7 +92,6 @@ public final class ViewThreadFragment extends SFragment implements private String thisThreadsStatusId; private Card card; private boolean alwaysShowSensitiveMedia; - private boolean collapseLongStatusContent; private int statusIndex = 0; @@ -102,8 +101,7 @@ public final class ViewThreadFragment extends SFragment implements public StatusViewData.Concrete apply(Status input) { return ViewDataUtils.statusToViewData( input, - alwaysShowSensitiveMedia, - collapseLongStatusContent + alwaysShowSensitiveMedia ); } }); @@ -160,7 +158,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( getActivity()); alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false); - collapseLongStatusContent = preferences.getBoolean("collapseLongStatuses", true); boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true); adapter.setMediaPreviewEnabled(mediaPreviewEnabled); boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); @@ -382,7 +379,7 @@ public void onContentCollapsedChange(boolean isCollapsed, int position) { } StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) - .setCollapsible(collapseLongStatusContent && !SmartLengthInputFilter.hasBadRatio( + .setCollapsible(!SmartLengthInputFilter.hasBadRatio( status.getContent(), SmartLengthInputFilter.LENGTH_DEFAULT )) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index 9822cbeb97..39b08dad1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -29,8 +29,7 @@ public final class ViewDataUtils { @Nullable public static StatusViewData.Concrete statusToViewData(@Nullable Status status, - boolean alwaysShowSensitiveMedia, - boolean collapseLongStatusContent) { + boolean alwaysShowSensitiveMedia) { if (status == null) return null; Status visibleStatus = status.getReblog() == null ? status : status.getReblog(); return new StatusViewData.Builder().setId(status.getId()) @@ -59,7 +58,7 @@ public static StatusViewData.Concrete statusToViewData(@Nullable Status status, .setApplication(visibleStatus.getApplication()) .setStatusEmojis(visibleStatus.getEmojis()) .setAccountEmojis(visibleStatus.getAccount().getEmojis()) - .setCollapsible(collapseLongStatusContent && !SmartLengthInputFilter.hasBadRatio( + .setCollapsible(!SmartLengthInputFilter.hasBadRatio( visibleStatus.getContent(), SmartLengthInputFilter.LENGTH_DEFAULT )) @@ -68,16 +67,14 @@ public static StatusViewData.Concrete statusToViewData(@Nullable Status status, } public static NotificationViewData.Concrete notificationToViewData(Notification notification, - boolean alwaysShowSensitiveData, - boolean collapseLongStatusContent) { + boolean alwaysShowSensitiveData) { return new NotificationViewData.Concrete( notification.getType(), notification.getId(), notification.getAccount(), statusToViewData( notification.getStatus(), - alwaysShowSensitiveData, - collapseLongStatusContent + alwaysShowSensitiveData ), false ); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 675cf464b9..261ca68596 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -176,11 +176,6 @@ Appearance App Theme - Collapse long posts - Long posts will only show the first 500 characters. - Long posts (over 500 characters) will not be collapsed to save space. - - Dark Light diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index e022498ecc..373702f540 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -41,14 +41,6 @@ android:dependency="mediaPreviewEnabled" android:key="alwaysShowSensitiveMedia" android:title="@string/pref_title_alway_show_sensitive_media" /> - - - Date: Sat, 15 Sep 2018 11:04:23 +0200 Subject: [PATCH 37/39] Update style to match content warning toggle button --- app/src/main/res/layout/item_status.xml | 15 ++++++++++++--- .../res/layout/item_status_notification.xml | 17 +++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 3daf6353d7..706e98d031 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -135,15 +135,24 @@ tools:text="This is a status" /> + android:background="?attr/content_warning_button" + android:minHeight="0dp" + android:minWidth="150dp" + android:paddingBottom="4dp" + android:paddingLeft="16dp" + android:paddingRight="16dp" + android:paddingTop="4dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="4dp" + android:textAllCaps="true" + android:textSize="?attr/status_text_medium" + android:visibility="gone" /> Date: Sat, 15 Sep 2018 11:05:44 +0200 Subject: [PATCH 38/39] Update strings to give cleaner differentiation between CW and collapse --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 261ca68596..b5ee921c59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,8 +47,8 @@ Click to view Show More Show Less - Show more - Show less + Expand + Collapse Nothing here. Pull down to refresh! From 50774ef109fd5cd7e048ce5802c5f86332af0ca5 Mon Sep 17 00:00:00 2001 From: HellPie Date: Sun, 16 Sep 2018 21:26:37 +0200 Subject: [PATCH 39/39] Update smart filter code to use fully qualified names to avoid confusion --- .../com/keylesspalace/tusky/util/SmartLengthInputFilter.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java index d961ba5906..41400b2530 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -20,8 +20,6 @@ import android.text.SpannableStringBuilder; import android.text.Spanned; -import java.text.BreakIterator; - /** * A customized version of {@link android.text.InputFilter.LengthFilter} which allows smarter * constraints and adds better visuals such as: @@ -126,7 +124,7 @@ public CharSequence filter(CharSequence source, int start, int end, Spanned dest boundary = iterator.following(keep); if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep); } else { - java.text.BreakIterator iterator = BreakIterator.getWordInstance(); + java.text.BreakIterator iterator = java.text.BreakIterator.getWordInstance(); iterator.setText(source.toString()); boundary = iterator.following(keep); if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep);