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 6cbd7bec4..eeb6b25c5 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.annotation.Nullable; 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; @@ -46,6 +47,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; 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; @@ -62,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; @@ -243,6 +248,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter { 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 { @@ -305,6 +318,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { 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; @@ -328,6 +342,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { 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); @@ -350,7 +365,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { 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) { @@ -508,11 +522,30 @@ public class NotificationsAdapter extends RecyclerView.Adapter { Spanned content = statusViewData.getContent(); List emojis = statusViewData.getStatusEmojis(); + if (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(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setChecked(false); + statusContent.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + statusContent.setFilters(NO_INPUT_FILTER); + } + 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/adapter/SearchResultsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java index 662e64f4a..c64a55e9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java @@ -53,8 +53,10 @@ public class SearchResultsAdapter extends RecyclerView.Adapter { private LinkListener linkListener; private StatusActionListener statusListener; - public SearchResultsAdapter(boolean mediaPreviewsEnabled, boolean alwaysShowSensitiveMedia, - LinkListener linkListener, StatusActionListener statusListener, + public SearchResultsAdapter(boolean mediaPreviewsEnabled, + boolean alwaysShowSensitiveMedia, + LinkListener linkListener, + StatusActionListener statusListener, boolean useAbsoluteTime) { this.accountList = Collections.emptyList(); @@ -153,7 +155,10 @@ public class SearchResultsAdapter extends RecyclerView.Adapter { accountList = results.getAccounts(); statusList = results.getStatuses(); for(Status status: results.getStatuses()) { - concreteStatusList.add(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia)); + concreteStatusList.add(ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia + )); } hashtagList = results.getHashtags(); 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 2d7f0fa77..ec58c0a0f 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.NonNull; 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; @@ -25,6 +26,7 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper; 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; @@ -39,6 +41,9 @@ import at.connyduck.sparkbutton.SparkButton; 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; @@ -60,6 +65,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private View sensitiveMediaShow; private TextView mediaLabel; private ToggleButton contentWarningButton; + private ToggleButton contentCollapseButton; ImageView avatar; TextView timestampInfo; @@ -97,6 +103,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); this.useAbsoluteTime = useAbsoluteTime; shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); @@ -492,7 +499,6 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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()); @@ -523,7 +529,31 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { 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) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) + listener.onContentCollapsedChange(isChecked, position); + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (status.isCollapsed()) { + contentCollapseButton.setChecked(true); + content.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setChecked(false); + content.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + content.setFilters(NO_INPUT_FILTER); + } + } + + setContent(status.getContent(), status.getMentions(), status.getStatusEmojis(), listener); } - - } 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 2be9d8c42..8d06f66e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -149,7 +149,10 @@ public class NotificationsFragment extends SFragment implements public NotificationViewData apply(Either input) { if (input.isRight()) { Notification notification = input.getAsRight(); - return ViewDataUtils.notificationToViewData(notification, alwaysShowSensitiveMedia); + return ViewDataUtils.notificationToViewData( + notification, + alwaysShowSensitiveMedia + ); } else { return new NotificationViewData.Placeholder(false); } @@ -493,6 +496,52 @@ public class NotificationsFragment extends SFragment implements } } + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + 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)) { + 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; + } + + StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData(); + 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)); + } + + @Override + public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { + onContentCollapsedChange(isCollapsed, position); + } + @Override public void onViewTag(String tag) { super.viewTag(tag); @@ -557,12 +606,18 @@ public class NotificationsFragment extends SFragment implements // 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); 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 81bb71b8a..044bf67c0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -52,7 +52,6 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { private var mediaPreviewEnabled = true private var useAbsoluteTime = false - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_search, container, false) } @@ -65,7 +64,12 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { searchRecyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) searchRecyclerView.layoutManager = LinearLayoutManager(view.context) - searchAdapter = SearchResultsAdapter(mediaPreviewEnabled, alwaysShowSensitiveMedia, this, this, useAbsoluteTime) + searchAdapter = SearchResultsAdapter( + mediaPreviewEnabled, + alwaysShowSensitiveMedia, + this, + this, + useAbsoluteTime) searchRecyclerView.adapter = searchAdapter } @@ -137,17 +141,22 @@ 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 - searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position) + searchAdapter.updateStatusAtPosition( + ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia + ), + position + ) } override fun onFailure(call: Call?, t: Throwable?) { Log.d(TAG, "Failed to reblog status " + status.id, t) } - }) } } @@ -158,7 +167,13 @@ 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 + ), + position + ) } override fun onFailure(call: Call?, t: Throwable?) { @@ -214,6 +229,21 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { // not needed here, search is not paginated } + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + // 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 { const val TAG = "SearchFragment" } @@ -229,4 +259,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 70c6bfb59..d75551ff9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -158,7 +158,10 @@ public class TimelineFragment extends SFragment implements public StatusViewData apply(Either input) { Status status = input.getAsRightOrNull(); if (status != null) { - return ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia); + return ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia + ); } else { Placeholder placeholder = input.getAsLeft(); return new StatusViewData.Placeholder(placeholder.id, false); @@ -239,8 +242,7 @@ public class TimelineFragment extends SFragment implements } 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); @@ -566,6 +568,33 @@ public class TimelineFragment extends SFragment implements } } + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + 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 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; + } + + StatusViewData updatedStatus = new StatusViewData.Builder((StatusViewData.Concrete) status) + .setCollapsed(isCollapsed) + .createStatusViewData(); + statuses.setPairedItem(position, updatedStatus); + updateAdapter(); + } + @Override public void onViewMedia(int position, int attachmentIndex, View view) { Status status = statuses.get(position).getAsRightOrNull(); @@ -650,6 +679,7 @@ public class TimelineFragment extends SFragment implements case "alwaysShowSensitiveMedia": { //it is ok if only newly loaded statuses are affected, no need to fully refresh alwaysShowSensitiveMedia = sharedPreferences.getBoolean("alwaysShowSensitiveMedia", false); + 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 376fab607..175419bbc 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.interfaces.StatusActionListener; 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; @@ -98,7 +99,10 @@ 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 + ); } }); @@ -355,6 +359,36 @@ public final class ViewThreadFragment extends SFragment implements } + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + 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(!SmartLengthInputFilter.hasBadRatio( + status.getContent(), + SmartLengthInputFilter.LENGTH_DEFAULT + )) + .setCollapsed(isCollapsed) + .createStatusViewData(); + statuses.setPairedItem(position, updatedStatus); + recyclerView.post(() -> adapter.setItem(position, updatedStatus, true)); + } + @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 fbd46783c..79c42db92 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); } 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 000000000..41400b253 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.java @@ -0,0 +1,154 @@ +/* + * 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.SpannableStringBuilder; +import android.text.Spanned; + +/** + * 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 { + + /** + * 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 = java.text.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) + "…"; + } + } +} 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 e9fe76f8f..39b08dad1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -58,12 +58,25 @@ public final class ViewDataUtils { .setApplication(visibleStatus.getApplication()) .setStatusEmojis(visibleStatus.getEmojis()) .setAccountEmojis(visibleStatus.getAccount().getEmojis()) + .setCollapsible(!SmartLengthInputFilter.hasBadRatio( + visibleStatus.getContent(), + SmartLengthInputFilter.LENGTH_DEFAULT + )) + .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) { + return new NotificationViewData.Concrete( + notification.getType(), + notification.getId(), + notification.getAccount(), + statusToViewData( + notification.getStatus(), + alwaysShowSensitiveData + ), + false + ); } - } 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 43a692d97..363d07e80 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 @@ package com.keylesspalace.tusky.viewdata; 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 abstract class 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 abstract class NotificationViewData { return account; } + @NonNull public StatusViewData.Concrete getStatusViewData() { return statusViewData; } 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 5da7bb046..413aa0017 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,8 @@ public abstract class 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, @Nullable String spoilerText, Status.Visibility visibility, List attachments, @@ -87,7 +89,8 @@ public abstract class StatusViewData { 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 isCollapsible, boolean isCollapsed) { this.id = id; this.content = content; this.reblogged = reblogged; @@ -114,6 +117,8 @@ public abstract class StatusViewData { this.statusEmojis = statusEmojis; this.accountEmojis = accountEmojis; this.card = card; + this.isCollapsible = isCollapsible; + this.isCollapsed = isCollapsed; } public String getId() { @@ -226,6 +231,26 @@ public abstract class StatusViewData { return card; } + /** + * 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() { + 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 +261,32 @@ public abstract class StatusViewData { 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 +360,8 @@ public abstract class StatusViewData { 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() { } @@ -365,6 +393,8 @@ public abstract class StatusViewData { statusEmojis = viewData.getStatusEmojis(); accountEmojis = viewData.getAccountEmojis(); card = viewData.getCard(); + isCollapsible = viewData.isCollapsible(); + isCollapsed = viewData.isCollapsed(); } public Builder setId(String id) { @@ -497,6 +527,30 @@ public abstract class StatusViewData { 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. + * + * @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 +560,7 @@ public abstract class StatusViewData { attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, - statusEmojis, accountEmojis, card); + statusEmojis, accountEmojis, card, isCollapsible, isCollapsed); } } } diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 61cd1ee02..d5370a96b 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -135,11 +135,32 @@ android:textSize="?attr/status_text_medium" tools:text="This is a status" /> + + @@ -354,4 +375,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index 50437e57c..9f71c1eed 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -115,6 +115,28 @@ android:textSize="?attr/status_text_medium" tools:text="Example status here" /> + + + - \ 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 6151c40bb..b5ee921c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,8 @@ Click to view Show More Show Less + Expand + Collapse Nothing here. Pull down to refresh! diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index d53d0f1a1..373702f54 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -41,12 +41,11 @@ android:dependency="mediaPreviewEnabled" android:key="alwaysShowSensitiveMedia" android:title="@string/pref_title_alway_show_sensitive_media" /> - - + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3fec1f5dc..4ac6b1d06 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -#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