Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add support for collapsible statuses when they exceed 500 characters #825

Merged
merged 42 commits into from
Sep 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a0a41ca
Update Gradle plugin to work with Android Studio 3.3 Canary
HellPie Aug 30, 2018
8f6c327
Update gradle build script to allow installing debug builds alongside…
HellPie Aug 30, 2018
6b1ec08
Add UI changes to support collapsing statuses
HellPie Aug 30, 2018
bdf0264
Update status data model to store whether the message has been collapsed
HellPie Aug 30, 2018
3930555
Update status action listener to notify of collapsed state changing
HellPie Aug 30, 2018
b41599e
Add implementation code to handle status collapse/expand in timeline
HellPie Aug 30, 2018
f66664a
Add preferences so that users can toggle the collapsing of long posts
HellPie Aug 30, 2018
0ee004d
Update Gradle plugin to work with latest Android Studio 3.3 Canary 8
HellPie Aug 30, 2018
472effd
Update data models and utils for statuses to better handle collapsing
HellPie Aug 31, 2018
73dc85a
Fix search breaking due to newly introduced variables in utils classes
HellPie Aug 31, 2018
95976d6
Fix timeline breaking due to newly introduced variables in utils classes
HellPie Aug 31, 2018
6e2de07
Fix item status text for collapsed toggle being shown in the wrong state
HellPie Aug 31, 2018
2889797
Update timeline fragment to refresh the list when collapsed settings …
HellPie Aug 31, 2018
215b478
Add support for status content collapse in timeline viewholder
HellPie Aug 31, 2018
ff659f1
Fix view holder truncating posts using temporary debug settings at 50…
HellPie Aug 31, 2018
4dbed26
Add toggle support to notification layout as well
HellPie Aug 31, 2018
d64573d
Add support for collapsed statuses to search results
HellPie Aug 31, 2018
f1c71de
Add support for expandable content to notifications too
HellPie Aug 31, 2018
d5d57aa
Update codebase with some suggested changes by @charlang
HellPie Aug 31, 2018
07dce8c
Update more code with more suggestions and move null-safety into view…
HellPie Aug 31, 2018
d3d72f2
Update even more code with even more suggested code changes
HellPie Aug 31, 2018
6891e55
Revert a0a41ca and 0ee004d (Android Studio 3.1 to Android Studio 3.3 …
HellPie Sep 3, 2018
3c6648b
Add an input filter utility class to reuse code for trimming statuses
HellPie Sep 3, 2018
40ae45e
Update UI of statuses to show a taller collapsible button
HellPie Sep 3, 2018
c62b523
Update notification fragment logging to simplify null checks
HellPie Sep 4, 2018
0d83ff9
Add smartness to SmartLengthInputFilter such as word trimming and runway
HellPie Sep 5, 2018
b2dbd16
Fix posts with show more button even if bad ratio didn't collapse
HellPie Sep 5, 2018
ba7ccf9
Fix thread view showing button but not collapsing by implementing the…
HellPie Sep 5, 2018
7cddab4
Fix spannable losing spans when collapsed and restore length to 500 c…
HellPie Sep 5, 2018
16ceb60
Remove debug build suffix as per request
HellPie Sep 5, 2018
f66d689
Merge branch 'master' into feature_collapse_status
HellPie Sep 10, 2018
623cad2
Merge remote-tracking branch 'upstream/master' into feature_collapse_…
HellPie Sep 13, 2018
7056ba5
Merge remote-tracking branch 'origin/feature_collapse_status' into fe…
HellPie Sep 13, 2018
3af692e
Fix all the merging happened in f66d689, 623cad2 and 7056ba5
HellPie Sep 13, 2018
4545b73
Fix notification button spanning full width rather than content width
HellPie Sep 13, 2018
4a3c8bb
Add a way to access a singleton to smart filter and use clearer code
HellPie Sep 14, 2018
a38676f
Update view holders using smart input filters to use more singletons
HellPie Sep 14, 2018
b5d13ed
Fix code style lacking spaces before boolean checks in ifs and others
HellPie Sep 14, 2018
33200bb
Remove all code related to collapsibility preferences, strings included
HellPie Sep 14, 2018
cc5a921
Update style to match content warning toggle button
HellPie Sep 15, 2018
9860d39
Update strings to give cleaner differentiation between CW and collapse
HellPie Sep 15, 2018
50774ef
Update smart filter code to use fully qualified names to avoid confusion
HellPie Sep 16, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,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;
Expand All @@ -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<NotificationViewData> notifications;
private StatusActionListener statusListener;
private NotificationActionListener notificationActionListener;
Expand Down Expand Up @@ -243,6 +248,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 {
Expand Down Expand Up @@ -308,6 +321,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;
Expand All @@ -331,6 +345,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);
Expand All @@ -353,7 +368,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<Emoji> emojis) {
Expand Down Expand Up @@ -511,10 +525,29 @@ private void setupContentAndSpoiler(NotificationViewData.Concrete notificationVi
Spanned content = statusViewData.getContent();
List<Emoji> emojis = statusViewData.getStatusEmojis();

Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent);
if (statusViewData.isCollapsible() && (notificationViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
notificationActionListener.onNotificationContentCollapsedChange(isChecked, position);
}
});

LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener);
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -153,7 +155,10 @@ 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
));
}
hashtagList = results.getHashtags();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,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;
Expand All @@ -39,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;
Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -492,7 +499,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());
Expand Down Expand Up @@ -523,7 +529,31 @@ void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener
} 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ protected TimelineCases timelineCases() {
public NotificationViewData apply(Either<Placeholder, Notification> input) {
if (input.isRight()) {
Notification notification = input.getAsRight();
return ViewDataUtils.notificationToViewData(notification, alwaysShowSensitiveMedia);
return ViewDataUtils.notificationToViewData(
notification,
alwaysShowSensitiveMedia
);
} else {
return new NotificationViewData.Placeholder(false);
}
Expand Down Expand Up @@ -493,6 +496,52 @@ public void onLoadMore(int position) {
}
}

@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));
HellPie marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) {
onContentCollapsedChange(isCollapsed, position);
}

@Override
public void onViewTag(String tag) {
super.viewTag(tag);
Expand Down Expand Up @@ -557,12 +606,18 @@ private void onLoadMore() {
// already loaded everything
return;
}
Either<Placeholder, Notification> 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<Placeholder, Notification> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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

}
Expand Down Expand Up @@ -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<Status> {
override fun onResponse(call: Call<Status>?, response: Response<Status>?) {
status.reblogged = true
searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position)
searchAdapter.updateStatusAtPosition(
ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
),
position
)
}

override fun onFailure(call: Call<Status>?, t: Throwable?) {
Log.d(TAG, "Failed to reblog status " + status.id, t)
}

})
}
}
Expand All @@ -158,7 +167,13 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable {
timelineCases.favouriteWithCallback(status, favourite, object: Callback<Status> {
override fun onResponse(call: Call<Status>?, response: Response<Status>?) {
status.favourited = true
searchAdapter.updateStatusAtPosition(ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia), position)
searchAdapter.updateStatusAtPosition(
ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
),
position
)
}

override fun onFailure(call: Call<Status>?, t: Throwable?) {
Expand Down Expand Up @@ -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"
}
Expand All @@ -229,4 +259,4 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable {
startActivity(intent)
}

}
}
Loading