From 90c1a83ba4ff9964ae2cce236628737dd6c975ec Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Wed, 12 Jul 2017 22:54:52 +0300 Subject: [PATCH] Preserve status states on updates. UI layer refactoring. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some things were pulled out of adapters to fragments. New classes were introduced - StatusViewData and NotificationViewData. They not only have view state in them but also help decoupling. Because introducing parallel model list requires a lot of synchronisation PairedList was added. Also synchronisation between fragments and adapters is quiet tedious and error-prone and should be replaces with better solution. Oh, I also couldn’t resist and fixed bug with buttons animation in the same commit. --- .../keylesspalace/tusky/TuskyApplication.java | 4 - .../tusky/adapter/NotificationsAdapter.java | 107 ++----- .../tusky/adapter/StatusViewHolder.java | 107 ++++--- .../tusky/adapter/ThreadAdapter.java | 95 ++---- .../tusky/adapter/TimelineAdapter.java | 97 +----- .../tusky/fragment/NotificationsFragment.java | 164 ++++++++-- .../tusky/fragment/SFragment.java | 39 ++- .../tusky/fragment/TimelineFragment.java | 237 +++++++++++--- .../tusky/fragment/ViewThreadFragment.java | 196 +++++++++++- .../interfaces/StatusActionListener.java | 3 + .../keylesspalace/tusky/util/PairedList.java | 84 +++++ .../tusky/util/ViewDataUtils.java | 78 +++++ .../tusky/viewdata/NotificationViewData.java | 39 +++ .../tusky/viewdata/StatusViewData.java | 296 ++++++++++++++++++ app/src/main/res/layout/item_status.xml | 6 +- 15 files changed, 1194 insertions(+), 358 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/PairedList.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index a58857ee1..1aacd0e8f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -59,10 +59,6 @@ public class TuskyApplication extends Application { throw new RuntimeException(e); } - if (BuildConfig.DEBUG) { - Picasso.with(this).setLoggingEnabled(true); - } - /* Install the new provider or, if there's a pre-existing older version, replace the * existing version of it. */ final String providerName = "BC"; 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 fb0ed466a..b2f0c6ca2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -31,13 +31,13 @@ import android.widget.TextView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; import com.squareup.picasso.Picasso; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; public class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRemover { @@ -46,16 +46,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2; private static final int VIEW_TYPE_FOLLOW = 3; - private List notifications; + private List notifications; private StatusActionListener statusListener; private NotificationActionListener notificationActionListener; private FooterViewHolder.State footerState; private boolean mediaPreviewEnabled; - private String bottomId; - private String topId; public NotificationsAdapter(StatusActionListener statusListener, - NotificationActionListener notificationActionListener) { + NotificationActionListener notificationActionListener) { super(); notifications = new ArrayList<>(); this.statusListener = statusListener; @@ -94,28 +92,29 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte @Override public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { if (position < notifications.size()) { - Notification notification = notifications.get(position); - Notification.Type type = notification.type; + NotificationViewData notification = notifications.get(position); + Notification.Type type = notification.getType(); switch (type) { case MENTION: { StatusViewHolder holder = (StatusViewHolder) viewHolder; - Status status = notification.status; - holder.setupWithStatus(status, statusListener, mediaPreviewEnabled); + StatusViewData status = notification.getStatusViewData(); + holder.setupWithStatus(status, + statusListener, mediaPreviewEnabled); break; } case FAVOURITE: case REBLOG: { StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - holder.setMessage(type, notification.account.getDisplayName(), - notification.status); - holder.setupButtons(notificationActionListener, notification.account.id); + holder.setMessage(type, notification.getStatusViewData().getUserFullName(), + notification.getStatusViewData()); + holder.setupButtons(notificationActionListener, notification.getAccount().id); break; } case FOLLOW: { FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(notification.account.getDisplayName(), - notification.account.username, notification.account.avatar); - holder.setupButtons(notificationActionListener, notification.account.id); + holder.setMessage(notification.getAccount().getDisplayName(), + notification.getAccount().username, notification.getAccount().avatar); + holder.setupButtons(notificationActionListener, notification.getAccount().id); break; } } @@ -135,8 +134,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte if (position == notifications.size()) { return VIEW_TYPE_FOOTER; } else { - Notification notification = notifications.get(position); - switch (notification.type) { + NotificationViewData notification = notifications.get(position); + switch (notification.getType()) { default: case MENTION: { return VIEW_TYPE_MENTION; @@ -160,9 +159,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte @Override public void removeAllByAccountId(String id) { - for (int i = 0; i < notifications.size();) { - Notification notification = notifications.get(i); - if (id.equals(notification.account.id)) { + for (int i = 0; i < notifications.size(); ) { + NotificationViewData notification = notifications.get(i); + if (id.equals(notification.getAccount().id)) { notifications.remove(i); notifyItemRemoved(i); } else { @@ -172,61 +171,31 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte } @Nullable - public Notification getItem(int position) { + public NotificationViewData getItem(int position) { if (position >= 0 && position < notifications.size()) { return notifications.get(position); } return null; } - public void update(@Nullable List newNotifications, @Nullable String fromId, - @Nullable String uptoId) { + public void update(@Nullable List newNotifications) { if (newNotifications == null || newNotifications.isEmpty()) { return; } - if (fromId != null) { - bottomId = fromId; - } - if (uptoId != null) { - topId = uptoId; - } - if (notifications.isEmpty()) { - // This construction removes duplicates. - notifications = new ArrayList<>(new HashSet<>(newNotifications)); - } else { - int index = notifications.indexOf(newNotifications.get(newNotifications.size() - 1)); - for (int i = 0; i < index; i++) { - notifications.remove(0); - } - int newIndex = newNotifications.indexOf(notifications.get(0)); - if (newIndex == -1) { - notifications.addAll(0, newNotifications); - } else { - notifications.addAll(0, newNotifications.subList(0, newIndex)); - } - } + notifications.clear(); + notifications.addAll(newNotifications); notifyDataSetChanged(); } - public void addItems(List newNotifications, @Nullable String fromId) { - if (fromId != null) { - bottomId = fromId; - } - int end = notifications.size(); - Notification last = notifications.get(end - 1); - if (last != null && !findNotification(newNotifications, last.id)) { - notifications.addAll(newNotifications); - notifyItemRangeInserted(end, newNotifications.size()); - } + public void updateItemWithNotify(int position, NotificationViewData notification, + boolean notifyAdapter) { + notifications.set(position, notification); + if (notifyAdapter) notifyDataSetChanged(); } - private static boolean findNotification(List notifications, String id) { - for (Notification notification : notifications) { - if (notification.id.equals(id)) { - return true; - } - } - return false; + public void addItems(List newNotifications, @Nullable String fromId) { + notifications.addAll(newNotifications); + notifyItemRangeInserted(notifications.size(), newNotifications.size()); } public void clear() { @@ -238,16 +207,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte footerState = newFooterState; } - @Nullable - public String getBottomId() { - return bottomId; - } - - @Nullable - public String getTopId() { - return topId; - } - public void setMediaPreviewEnabled(boolean enabled) { mediaPreviewEnabled = enabled; } @@ -314,7 +273,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte container = (ViewGroup) itemView.findViewById(R.id.notification_container); } - void setMessage(Notification.Type type, String displayName, Status status) { + void setMessage(Notification.Type type, String displayName, StatusViewData status) { Context context = message.getContext(); String format; switch (type) { @@ -339,7 +298,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); message.setText(str); - statusContent.setText(status.content); + statusContent.setText(status.getContent()); } void setupButtons(final NotificationActionListener listener, final String accountId) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 49a3c17f0..d327bf105 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -38,13 +38,14 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.util.DateUtils; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.ThemeUtils; +import com.keylesspalace.tusky.viewdata.StatusViewData; import com.squareup.picasso.Picasso; import com.varunest.sparkbutton.SparkButton; import com.varunest.sparkbutton.SparkEventListener; import java.util.Date; -class StatusViewHolder extends RecyclerView.ViewHolder { +public class StatusViewHolder extends RecyclerView.ViewHolder { private View container; private TextView displayName; private TextView username; @@ -173,7 +174,7 @@ class StatusViewHolder extends RecyclerView.ViewHolder { reblogButton.setChecked(reblogged); } - /** This should only be called after setReblogged, in order to override the tint correctly. */ + // This should only be called after setReblogged, in order to override the tint correctly. private void setRebloggingEnabled(boolean enabled, Status.Visibility visibility) { reblogButton.setEnabled(enabled); @@ -202,7 +203,7 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } private void setMediaPreviews(final Status.MediaAttachment[] attachments, boolean sensitive, - final StatusActionListener listener) { + final StatusActionListener listener, boolean showingSensitive) { final ImageView[] previews = { mediaPreview0, mediaPreview1, @@ -257,10 +258,13 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } if (sensitive) { - sensitiveMediaWarning.setVisibility(View.VISIBLE); + sensitiveMediaWarning.setVisibility(showingSensitive ? View.GONE : View.VISIBLE); sensitiveMediaWarning.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(true, getAdapterPosition()); + } v.setVisibility(View.GONE); v.setOnClickListener(null); } @@ -277,23 +281,29 @@ class StatusViewHolder extends RecyclerView.ViewHolder { private static String getLabelTypeText(Context context, Status.MediaAttachment.Type type) { switch (type) { default: - case IMAGE: return context.getString(R.string.status_media_images); + case IMAGE: + return context.getString(R.string.status_media_images); case GIFV: - case VIDEO: return context.getString(R.string.status_media_video); + case VIDEO: + return context.getString(R.string.status_media_video); } } - private static @DrawableRes int getLabelIcon(Status.MediaAttachment.Type type) { + private static + @DrawableRes + int getLabelIcon(Status.MediaAttachment.Type type) { switch (type) { default: - case IMAGE: return R.drawable.ic_photo_24dp; + case IMAGE: + return R.drawable.ic_photo_24dp; case GIFV: - case VIDEO: return R.drawable.ic_videocam_24dp; + case VIDEO: + return R.drawable.ic_videocam_24dp; } } private void setMediaLabel(Status.MediaAttachment[] attachments, boolean sensitive, - final StatusActionListener listener) { + final StatusActionListener listener) { if (attachments.length == 0) { mediaLabel.setVisibility(View.GONE); return; @@ -334,15 +344,17 @@ class StatusViewHolder extends RecyclerView.ViewHolder { sensitiveMediaWarning.setVisibility(View.GONE); } - private void setSpoilerText(String spoilerText) { + private void setSpoilerText(String spoilerText, final boolean expanded, final StatusActionListener listener) { contentWarningDescription.setText(spoilerText); contentWarningBar.setVisibility(View.VISIBLE); - content.setVisibility(View.GONE); - contentWarningButton.setChecked(false); + contentWarningButton.setChecked(expanded); contentWarningButton.setOnCheckedChangeListener( new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onExpandedChange(isChecked, getAdapterPosition()); + } if (isChecked) { content.setVisibility(View.VISIBLE); } else { @@ -350,6 +362,11 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } } }); + if (expanded) { + content.setVisibility(View.VISIBLE); + } else { + content.setVisibility(View.GONE); + } } private void hideSpoilerText() { @@ -378,19 +395,21 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } }); reblogButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onReblog(!reblogged, position); - } + @Override + public void onEvent(ImageView button, boolean buttonState) { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onReblog(!reblogged, position); } + } - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) {} + @Override + public void onEventAnimationEnd(ImageView button, boolean buttonState) { + } - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) {} + @Override + public void onEventAnimationStart(ImageView button, boolean buttonState) { + } }); favouriteButton.setEventListener(new SparkEventListener() { @Override @@ -402,10 +421,12 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) {} + public void onEventAnimationEnd(ImageView button, boolean buttonState) { + } @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) {} + public void onEventAnimationStart(ImageView button, boolean buttonState) { + } }); moreButton.setOnClickListener(new View.OnClickListener() { @Override @@ -433,27 +454,25 @@ class StatusViewHolder extends RecyclerView.ViewHolder { container.setOnClickListener(viewThreadListener); } - void setupWithStatus(Status status, final StatusActionListener listener, + void setupWithStatus(StatusViewData status, final StatusActionListener listener, boolean mediaPreviewEnabled) { - Status realStatus = status.getActionableStatus(); - - setDisplayName(realStatus.account.getDisplayName()); - setUsername(realStatus.account.username); - setCreatedAt(realStatus.createdAt); - setContent(realStatus.content, realStatus.mentions, listener); - setAvatar(realStatus.account.avatar); - setReblogged(realStatus.reblogged); - setFavourited(realStatus.favourited); - String rebloggedByDisplayName = status.account.getDisplayName(); - if (status.reblog == null) { + setDisplayName(status.getUserFullName()); + setUsername(status.getNickname()); + setCreatedAt(status.getCreatedAt()); + setContent(status.getContent(), status.getMentions(), listener); + setAvatar(status.getAvatar()); + setReblogged(status.isReblogged()); + setFavourited(status.isFavourited()); + String rebloggedByDisplayName = status.getRebloggedByUsername(); + if (rebloggedByDisplayName == null) { hideRebloggedByDisplayName(); } else { setRebloggedByDisplayName(rebloggedByDisplayName); } - Status.MediaAttachment[] attachments = realStatus.attachments; - boolean sensitive = realStatus.sensitive; + Status.MediaAttachment[] attachments = status.getAttachments(); + boolean sensitive = status.isSensitive(); if (mediaPreviewEnabled) { - setMediaPreviews(attachments, sensitive, listener); + setMediaPreviews(attachments, sensitive, listener, status.isShowingSensitiveContent()); /* A status without attachments is sometimes still marked sensitive, so it's necessary * to check both whether there are any attachments and if it's marked sensitive. */ if (!sensitive || attachments.length == 0) { @@ -475,12 +494,12 @@ class StatusViewHolder extends RecyclerView.ViewHolder { videoIndicator.setVisibility(View.GONE); } - setupButtons(listener, realStatus.account.id); - setRebloggingEnabled(status.rebloggingAllowed(), status.getVisibility()); - if (realStatus.spoilerText.isEmpty()) { + setupButtons(listener, status.getSenderId()); + setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); + if (status.getSpoilerText() == null || status.getSpoilerText().isEmpty()) { hideSpoilerText(); } else { - setSpoilerText(realStatus.spoilerText); + setSpoilerText(status.getSpoilerText(), status.isExpanded(), listener); } // I think it's not efficient to create new object every time we bind a holder. diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java index 9f99f1631..7dcc2c078 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java @@ -21,23 +21,20 @@ import android.view.View; import android.view.ViewGroup; import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.ArrayList; import java.util.List; -public class ThreadAdapter extends RecyclerView.Adapter implements AdapterItemRemover { - private List statuses; +public class ThreadAdapter extends RecyclerView.Adapter { + private List statuses; private StatusActionListener statusActionListener; - private int statusIndex; private boolean mediaPreviewEnabled; public ThreadAdapter(StatusActionListener listener) { this.statusActionListener = listener; this.statuses = new ArrayList<>(); - this.statusIndex = 0; mediaPreviewEnabled = true; } @@ -51,8 +48,9 @@ public class ThreadAdapter extends RecyclerView.Adapter implements AdapterItemRe @Override public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { StatusViewHolder holder = (StatusViewHolder) viewHolder; - Status status = statuses.get(position); - holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled); + StatusViewData status = statuses.get(position); + holder.setupWithStatus(status, + statusActionListener, mediaPreviewEnabled); } @Override @@ -60,77 +58,42 @@ public class ThreadAdapter extends RecyclerView.Adapter implements AdapterItemRe return statuses.size(); } - @Override - public void removeItem(int position) { - statuses.remove(position); - notifyItemRemoved(position); + public void setStatuses(List statuses) { + this.statuses.clear(); + this.statuses.addAll(statuses); + notifyDataSetChanged(); } - @Override - public void removeAllByAccountId(String accountId) { - for (int i = 0; i < statuses.size();) { - Status status = statuses.get(i); - if (accountId.equals(status.account.id)) { - statuses.remove(i); - notifyItemRemoved(i); - } else { - i += 1; - } - } + public void addItem(int position, StatusViewData statusViewData) { + statuses.add(position, statusViewData); + notifyItemInserted(position); } - public Status getItem(int position) { - return statuses.get(position); - } - - public int setStatus(Status status) { - if (statuses.size() > 0 - && statusIndex < statuses.size() - && statuses.get(statusIndex).equals(status)) { - // Do not add this status on refresh, it's already in there. - statuses.set(statusIndex, status); - return statusIndex; - } - int i = statusIndex; - statuses.add(i, status); - notifyItemInserted(i); - return i; - } - - public void setContext(List ancestors, List descendants) { - Status mainStatus = null; - - // In case of refresh, remove old ancestors and descendants first. We'll remove all blindly, - // as we have no guarantee on their order to be the same as before + public void clearItems() { int oldSize = statuses.size(); - if (oldSize > 1) { - mainStatus = statuses.get(statusIndex); - statuses.clear(); - notifyItemRangeRemoved(0, oldSize); - } + statuses.clear(); + notifyItemRangeRemoved(0, oldSize); + } - // Insert newly fetched ancestors - statusIndex = ancestors.size(); - statuses.addAll(0, ancestors); - notifyItemRangeInserted(0, statusIndex); + public void addAll(int position, List statuses) { + this.statuses.addAll(position, statuses); + notifyItemRangeInserted(position, statuses.size()); + } - if (mainStatus != null) { - // In case we needed to delete everything (which is way easier than deleting - // everything except one), re-insert the remaining status here. - statuses.add(statusIndex, mainStatus); - notifyItemInserted(statusIndex); - } - - // Insert newly fetched descendants + public void addAll(List statuses) { int end = statuses.size(); - statuses.addAll(descendants); - notifyItemRangeInserted(end, descendants.size()); + this.statuses.addAll(statuses); + notifyItemRangeInserted(end, statuses.size()); } public void clear() { statuses.clear(); notifyDataSetChanged(); - statusIndex = 0; + } + + public void setItem(int position, StatusViewData status, boolean notifyAdapter) { + statuses.set(position, status); + if (notifyAdapter) notifyItemChanged(position); } public void setMediaPreviewEnabled(boolean enabled) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java index fa92e32f1..c07a54821 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java @@ -22,24 +22,20 @@ import android.view.View; import android.view.ViewGroup; import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover { +public class TimelineAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_FOOTER = 1; - private List statuses; + private List statuses; private StatusActionListener statusListener; private FooterViewHolder.State footerState; private boolean mediaPreviewEnabled; - private String topId; - private String bottomId; public TimelineAdapter(StatusActionListener statusListener) { super(); @@ -70,7 +66,7 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { if (position < statuses.size()) { StatusViewHolder holder = (StatusViewHolder) viewHolder; - Status status = statuses.get(position); + StatusViewData status = statuses.get(position); holder.setupWithStatus(status, statusListener, mediaPreviewEnabled); } else { FooterViewHolder holder = (FooterViewHolder) viewHolder; @@ -92,73 +88,23 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem } } - @Override - public void removeItem(int position) { - statuses.remove(position); - notifyItemRemoved(position); - } - - @Override - public void removeAllByAccountId(String accountId) { - for (int i = 0; i < statuses.size();) { - Status status = statuses.get(i); - if (accountId.equals(status.account.id)) { - statuses.remove(i); - notifyItemRemoved(i); - } else { - i += 1; - } - } - } - - public void update(@Nullable List newStatuses, @Nullable String fromId, - @Nullable String uptoId) { + public void update(@Nullable List newStatuses) { if (newStatuses == null || newStatuses.isEmpty()) { return; } - if (fromId != null) { - bottomId = fromId; - } - if (uptoId != null) { - topId = uptoId; - } - if (statuses.isEmpty()) { - // This construction removes duplicates. - statuses = new ArrayList<>(new HashSet<>(newStatuses)); - } else { - int index = statuses.indexOf(newStatuses.get(newStatuses.size() - 1)); - for (int i = 0; i < index; i++) { - statuses.remove(0); - } - int newIndex = newStatuses.indexOf(statuses.get(0)); - if (newIndex == -1) { - statuses.addAll(0, newStatuses); - } else { - statuses.addAll(0, newStatuses.subList(0, newIndex)); - } - } + statuses.clear(); + statuses.addAll(newStatuses); notifyDataSetChanged(); } - public void addItems(List newStatuses, @Nullable String fromId) { - if (fromId != null) { - bottomId = fromId; - } - int end = statuses.size(); - Status last = statuses.get(end - 1); - if (last != null && !findStatus(newStatuses, last.id)) { - statuses.addAll(newStatuses); - notifyItemRangeInserted(end, newStatuses.size()); - } + public void addItems(List newStatuses) { + statuses.addAll(newStatuses); + notifyItemRangeInserted(statuses.size(), newStatuses.size()); } - private static boolean findStatus(List statuses, String id) { - for (Status status : statuses) { - if (status.id.equals(id)) { - return true; - } - } - return false; + public void changeItem(int position, StatusViewData newData, boolean notifyAdapter) { + statuses.set(position, newData); + if (notifyAdapter) notifyDataSetChanged(); } public void clear() { @@ -166,13 +112,6 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem notifyDataSetChanged(); } - @Nullable - public Status getItem(int position) { - if (position >= 0 && position < statuses.size()) { - return statuses.get(position); - } - return null; - } public void setFooterState(FooterViewHolder.State newFooterState) { FooterViewHolder.State oldValue = footerState; @@ -185,14 +124,4 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem public void setMediaPreviewEnabled(boolean enabled) { mediaPreviewEnabled = enabled; } - - @Nullable - public String getBottomId() { - return bottomId; - } - - @Nullable - public String getTopId() { - return topId; - } } 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 cf5204edb..70a8480a7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.fragment; +import android.arch.core.util.Function; import android.content.Context; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; @@ -39,12 +40,19 @@ import com.keylesspalace.tusky.adapter.NotificationsAdapter; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.ThemeUtils; +import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; import retrofit2.Call; @@ -54,15 +62,16 @@ import retrofit2.Response; public class NotificationsFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, NotificationsAdapter.NotificationActionListener, - SharedPreferences.OnSharedPreferenceChangeListener { + SharedPreferences.OnSharedPreferenceChangeListener, AdapterItemRemover { private static final String TAG = "Notifications"; // logging tag private enum FetchEnd { TOP, - BOTTOM, + BOTTOM } private SwipeRefreshLayout swipeRefreshLayout; + private LinearLayoutManager layoutManager; private RecyclerView recyclerView; private EndlessOnScrollListener scrollListener; @@ -74,6 +83,16 @@ public class NotificationsFragment extends SFragment implements private int topFetches; private boolean bottomLoading; private int bottomFetches; + private String bottomId; + private String topId; + + private final PairedList notifications + = new PairedList<>(new Function() { + @Override + public NotificationViewData apply(Notification input) { + return ViewDataUtils.notificationToViewData(input); + } + }); public static NotificationsFragment newInstance() { NotificationsFragment fragment = new NotificationsFragment(); @@ -128,10 +147,12 @@ public class NotificationsFragment extends SFragment implements TabLayout layout = (TabLayout) activity.findViewById(R.id.tab_layout); onTabSelectedListener = new TabLayout.OnTabSelectedListener() { @Override - public void onTabSelected(TabLayout.Tab tab) {} + public void onTabSelected(TabLayout.Tab tab) { + } @Override - public void onTabUnselected(TabLayout.Tab tab) {} + public void onTabUnselected(TabLayout.Tab tab) { + } @Override public void onTabReselected(TabLayout.Tab tab) { @@ -167,7 +188,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { - NotificationsFragment.this.onLoadMore(view); + NotificationsFragment.this.onLoadMore(); } }; @@ -187,30 +208,30 @@ public class NotificationsFragment extends SFragment implements @Override public void onRefresh() { - sendFetchNotificationsRequest(null, adapter.getTopId(), FetchEnd.TOP); + sendFetchNotificationsRequest(null, topId, FetchEnd.TOP); } @Override public void onReply(int position) { - Notification notification = adapter.getItem(position); + Notification notification = notifications.get(position); super.reply(notification.status); } @Override public void onReblog(boolean reblog, int position) { - Notification notification = adapter.getItem(position); + Notification notification = notifications.get(position); super.reblog(notification.status, reblog, adapter, position); } @Override public void onFavourite(boolean favourite, int position) { - Notification notification = adapter.getItem(position); + Notification notification = notifications.get(position); super.favourite(notification.status, favourite, adapter, position); } @Override public void onMore(View view, int position) { - Notification notification = adapter.getItem(position); + Notification notification = notifications.get(position); super.more(notification.status, view, adapter, position); } @@ -221,16 +242,42 @@ public class NotificationsFragment extends SFragment implements @Override public void onViewThread(int position) { - Notification notification = adapter.getItem(position); + Notification notification = notifications.get(position); super.viewThread(notification.status); } @Override public void onOpenReblog(int position) { - Notification notification = adapter.getItem(position); + Notification notification = notifications.get(position); if (notification != null) onViewAccount(notification.account.id); } + @Override + public void onExpandedChange(boolean expanded, int position) { + NotificationViewData old = notifications.getPairedItem(position); + StatusViewData statusViewData = + new StatusViewData.Builder(old.getStatusViewData()) + .setIsExpanded(expanded) + .createStatusViewData(); + NotificationViewData notificationViewData = new NotificationViewData(old.getType(), + old.getId(), old.getAccount(), statusViewData); + notifications.setPairedItem(position, notificationViewData); + adapter.updateItemWithNotify(position, notificationViewData, false); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + NotificationViewData old = notifications.getPairedItem(position); + StatusViewData statusViewData = + new StatusViewData.Builder(old.getStatusViewData()) + .setIsShowingSensitiveContent(isShowing) + .createStatusViewData(); + NotificationViewData notificationViewData = new NotificationViewData(old.getType(), + old.getId(), old.getAccount(), statusViewData); + notifications.setPairedItem(position, notificationViewData); + adapter.updateItemWithNotify(position, notificationViewData, false); + } + @Override public void onViewTag(String tag) { super.viewTag(tag); @@ -257,9 +304,27 @@ public class NotificationsFragment extends SFragment implements } } - private void onLoadMore(RecyclerView view) { - NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter(); - sendFetchNotificationsRequest(adapter.getBottomId(), null, FetchEnd.BOTTOM); + @Override + public void removeItem(int position) { + notifications.remove(position); + adapter.update(notifications.getPairedCopy()); + } + + @Override + public void removeAllByAccountId(String accountId) { + // using iterator to safely remove items while iterating + Iterator iterator = notifications.iterator(); + while (iterator.hasNext()) { + Notification notification = iterator.next(); + if (notification.account.id.equals(accountId)) { + iterator.remove(); + } + } + adapter.update(notifications.getPairedCopy()); + } + + private void onLoadMore() { + sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM); } private void jumpToTop() { @@ -268,7 +333,7 @@ public class NotificationsFragment extends SFragment implements } private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd) { + final FetchEnd fetchEnd) { /* If there is a fetch already ongoing, record however many fetches are requested and * fulfill them after it's complete. */ if (fetchEnd == FetchEnd.TOP && topLoading) { @@ -297,7 +362,7 @@ public class NotificationsFragment extends SFragment implements call.enqueue(new Callback>() { @Override public void onResponse(Call> call, - Response> response) { + Response> response) { if (response.isSuccessful()) { String linkHeader = response.headers().get("Link"); onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd); @@ -315,7 +380,7 @@ public class NotificationsFragment extends SFragment implements } private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd) { + FetchEnd fetchEnd) { List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { case TOP: { @@ -324,7 +389,7 @@ public class NotificationsFragment extends SFragment implements if (previous != null) { uptoId = previous.uri.getQueryParameter("since_id"); } - adapter.update(notifications, null, uptoId); + update(notifications, null, uptoId); break; } case BOTTOM: { @@ -334,7 +399,7 @@ public class NotificationsFragment extends SFragment implements fromId = next.uri.getQueryParameter("max_id"); } if (adapter.getItemCount() > 1) { - adapter.addItems(notifications, fromId); + addItems(notifications, fromId); } else { /* If this is the first fetch, also save the id from the "previous" link and * treat this operation as a refresh so the scroll position doesn't get pushed @@ -344,7 +409,7 @@ public class NotificationsFragment extends SFragment implements if (previous != null) { uptoId = previous.uri.getQueryParameter("since_id"); } - adapter.update(notifications, fromId, uptoId); + update(notifications, fromId, uptoId); } /* Set last update id for pull notifications so that we don't get notified * about things we already loaded here */ @@ -363,6 +428,60 @@ public class NotificationsFragment extends SFragment implements swipeRefreshLayout.setRefreshing(false); } + public void update(@Nullable List newNotifications, @Nullable String fromId, + @Nullable String uptoId) { + if (newNotifications == null || newNotifications.isEmpty()) { + return; + } + if (fromId != null) { + bottomId = fromId; + } + if (uptoId != null) { + topId = uptoId; + } + if (notifications.isEmpty()) { + // This construction removes duplicates. + notifications.addAll(new HashSet<>(newNotifications)); + } else { + int index = notifications.indexOf(newNotifications.get(newNotifications.size() - 1)); + for (int i = 0; i < index; i++) { + notifications.remove(0); + } + int newIndex = newNotifications.indexOf(notifications.get(0)); + if (newIndex == -1) { + notifications.addAll(0, newNotifications); + } else { + List sublist = newNotifications.subList(0, newIndex); + notifications.addAll(0, sublist); + } + } + adapter.update(notifications.getPairedCopy()); + } + + public void addItems(List newNotifications, @Nullable String fromId) { + if (fromId != null) { + bottomId = fromId; + } + int end = notifications.size(); + Notification last = notifications.get(end - 1); + if (last != null && !findNotification(newNotifications, last.id)) { + notifications.addAll(newNotifications); + List newViewDatas = notifications.getPairedCopy() + .subList(notifications.size() - newNotifications.size(), + notifications.size() - 1); + adapter.addItems(newViewDatas, fromId); + } + } + + private static boolean findNotification(List notifications, String id) { + for (Notification notification : notifications) { + if (notification.id.equals(id)) { + return true; + } + } + return false; + } + private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd) { swipeRefreshLayout.setRefreshing(false); Log.e(TAG, "Fetch failure: " + exception.getMessage()); @@ -375,7 +494,7 @@ public class NotificationsFragment extends SFragment implements bottomLoading = false; if (bottomFetches > 0) { bottomFetches--; - onLoadMore(recyclerView); + onLoadMore(); } break; } @@ -392,6 +511,7 @@ public class NotificationsFragment extends SFragment implements private void fullyRefresh() { adapter.clear(); + notifications.clear(); sendFetchNotificationsRequest(null, null, FetchEnd.TOP); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 4d7113dfa..d64ae1f30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -23,6 +23,7 @@ import android.support.v4.content.LocalBroadcastManager; import android.support.v7.widget.PopupMenu; import android.support.v7.widget.RecyclerView; import android.text.Spanned; +import android.util.Log; import android.view.MenuItem; import android.view.View; @@ -41,6 +42,7 @@ import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.HtmlUtils; +import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.ArrayList; import java.util.List; @@ -107,9 +109,7 @@ public abstract class SFragment extends BaseFragment { protected void reblog(final Status status, final boolean reblog, final RecyclerView.Adapter adapter, final int position) { - String id = status.getActionableId(); - - Callback cb = new Callback() { + reblogWithCallback(status, reblog, new Callback() { @Override public void onResponse(Call call, retrofit2.Response response) { if (response.isSuccessful()) { @@ -124,8 +124,16 @@ public abstract class SFragment extends BaseFragment { } @Override - public void onFailure(Call call, Throwable t) {} - }; + public void onFailure(Call call, Throwable t) { + Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.id); + t.printStackTrace(); + } + }); + } + + protected void reblogWithCallback(final Status status, final boolean reblog, + Callback callback) { + String id = status.getActionableId(); Call call; if (reblog) { @@ -133,15 +141,12 @@ public abstract class SFragment extends BaseFragment { } else { call = mastodonApi.unreblogStatus(id); } - call.enqueue(cb); - callList.add(call); + call.enqueue(callback); } protected void favourite(final Status status, final boolean favourite, final RecyclerView.Adapter adapter, final int position) { - String id = status.getActionableId(); - - Callback cb = new Callback() { + favouriteWithCallback(status, favourite, new Callback() { @Override public void onResponse(Call call, retrofit2.Response response) { if (response.isSuccessful()) { @@ -156,8 +161,16 @@ public abstract class SFragment extends BaseFragment { } @Override - public void onFailure(Call call, Throwable t) {} - }; + public void onFailure(Call call, Throwable t) { + Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.id); + t.printStackTrace(); + } + }); + } + + protected void favouriteWithCallback(final Status status, final boolean favourite, + final Callback callback) { + String id = status.getActionableId(); Call call; if (favourite) { @@ -165,7 +178,7 @@ public abstract class SFragment extends BaseFragment { } else { call = mastodonApi.unfavouriteStatus(id); } - call.enqueue(cb); + call.enqueue(callback); callList.add(call); } 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 b9e7487c8..ea0c1df3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -33,20 +33,27 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import com.keylesspalace.tusky.BuildConfig; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.FooterViewHolder; import com.keylesspalace.tusky.adapter.TimelineAdapter; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.ThemeUtils; +import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import retrofit2.Call; import retrofit2.Callback; @@ -55,8 +62,11 @@ import retrofit2.Response; public class TimelineFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, - SharedPreferences.OnSharedPreferenceChangeListener { + SharedPreferences.OnSharedPreferenceChangeListener, + AdapterItemRemover { private static final String TAG = "Timeline"; // logging tag + private static final String KIND_ARG = "kind"; + private static final String HASHTAG_OR_ID_ARG = "hashtag_or_id"; public enum Kind { HOME, @@ -88,11 +98,17 @@ public class TimelineFragment extends SFragment implements private int topFetches; private boolean bottomLoading; private int bottomFetches; + @Nullable + private String bottomId; + @Nullable + private String upToId; + private PairedList statuses = + new PairedList<>(ViewDataUtils.statusMapper()); public static TimelineFragment newInstance(Kind kind) { TimelineFragment fragment = new TimelineFragment(); Bundle arguments = new Bundle(); - arguments.putString("kind", kind.name()); + arguments.putString(KIND_ARG, kind.name()); fragment.setArguments(arguments); return fragment; } @@ -100,19 +116,19 @@ public class TimelineFragment extends SFragment implements public static TimelineFragment newInstance(Kind kind, String hashtagOrId) { TimelineFragment fragment = new TimelineFragment(); Bundle arguments = new Bundle(); - arguments.putString("kind", kind.name()); - arguments.putString("hashtag_or_id", hashtagOrId); + arguments.putString(KIND_ARG, kind.name()); + arguments.putString(HASHTAG_OR_ID_ARG, hashtagOrId); fragment.setArguments(arguments); return fragment; } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { + Bundle savedInstanceState) { Bundle arguments = getArguments(); - kind = Kind.valueOf(arguments.getString("kind")); + kind = Kind.valueOf(arguments.getString(KIND_ARG)); if (kind == Kind.TAG || kind == Kind.USER) { - hashtagOrId = arguments.getString("hashtag_or_id"); + hashtagOrId = arguments.getString(HASHTAG_OR_ID_ARG); } final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); @@ -140,7 +156,7 @@ public class TimelineFragment extends SFragment implements adapter.setMediaPreviewEnabled(mediaPreviewEnabled); recyclerView.setAdapter(adapter); - timelineReceiver = new TimelineReceiver(adapter, this); + timelineReceiver = new TimelineReceiver(this, this); LocalBroadcastManager.getInstance(context.getApplicationContext()) .registerReceiver(timelineReceiver, TimelineReceiver.getFilter(kind)); @@ -155,10 +171,12 @@ public class TimelineFragment extends SFragment implements TabLayout layout = (TabLayout) getActivity().findViewById(R.id.tab_layout); onTabSelectedListener = new TabLayout.OnTabSelectedListener() { @Override - public void onTabSelected(TabLayout.Tab tab) {} + public void onTabSelected(TabLayout.Tab tab) { + } @Override - public void onTabUnselected(TabLayout.Tab tab) {} + public void onTabUnselected(TabLayout.Tab tab) { + } @Override public void onTabReselected(TabLayout.Tab tab) { @@ -196,7 +214,7 @@ public class TimelineFragment extends SFragment implements @Override public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { - TimelineFragment.this.onLoadMore(view); + TimelineFragment.this.onLoadMore(); } }; } else { @@ -204,7 +222,7 @@ public class TimelineFragment extends SFragment implements scrollListener = new EndlessOnScrollListener(layoutManager) { @Override public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { - TimelineFragment.this.onLoadMore(view); + TimelineFragment.this.onLoadMore(); } }; } @@ -223,32 +241,98 @@ public class TimelineFragment extends SFragment implements @Override public void onRefresh() { - sendFetchTimelineRequest(null, adapter.getTopId(), FetchEnd.TOP); + sendFetchTimelineRequest(null, upToId, FetchEnd.TOP); } @Override public void onReply(int position) { - super.reply(adapter.getItem(position)); + super.reply(statuses.get(position)); } @Override public void onReblog(final boolean reblog, final int position) { - super.reblog(adapter.getItem(position), reblog, adapter, position); + final Status status = statuses.get(position); + super.reblogWithCallback(status, reblog, new Callback() { + @Override + public void onResponse(Call call, retrofit2.Response response) { + if (response.isSuccessful()) { + status.reblogged = reblog; + + if (status.reblog != null) { + status.reblog.reblogged = reblog; + } + + StatusViewData newViewData = + new StatusViewData.Builder(statuses.getPairedItem(position)) + .setReblogged(reblog) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, true); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.d(TAG, "Failed to reblog status " + status.id); + t.printStackTrace(); + } + }); } @Override public void onFavourite(final boolean favourite, final int position) { - super.favourite(adapter.getItem(position), favourite, adapter, position); + final Status status = statuses.get(position); + + super.favouriteWithCallback(status, favourite, new Callback() { + @Override + public void onResponse(Call call, retrofit2.Response response) { + if (response.isSuccessful()) { + status.favourited = favourite; + + if (status.reblog != null) { + status.reblog.favourited = favourite; + } + StatusViewData newViewData = new StatusViewData + .Builder(statuses.getPairedItem(position)) + .setFavourited(favourite) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, true); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.d(TAG, "Failed to favourite status " + status.id); + t.printStackTrace(); + } + }); } @Override public void onMore(View view, final int position) { - super.more(adapter.getItem(position), view, adapter, position); + super.more(statuses.get(position), view, this, position); } @Override public void onOpenReblog(int position) { - super.openReblog(adapter.getItem(position)); + super.openReblog(statuses.get(position)); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsExpanded(expanded).createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, false); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsShowingSensitiveContent(isShowing).createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, false); } @Override @@ -258,7 +342,7 @@ public class TimelineFragment extends SFragment implements @Override public void onViewThread(int position) { - super.viewThread(adapter.getItem(position)); + super.viewThread(statuses.get(position)); } @Override @@ -314,9 +398,27 @@ public class TimelineFragment extends SFragment implements } } - private void onLoadMore(RecyclerView view) { - TimelineAdapter adapter = (TimelineAdapter) view.getAdapter(); - sendFetchTimelineRequest(adapter.getBottomId(), null, FetchEnd.BOTTOM); + @Override + public void removeItem(int position) { + statuses.remove(position); + adapter.update(statuses.getPairedCopy()); + } + + @Override + public void removeAllByAccountId(String accountId) { + // using iterator to safely remove items while iterating + Iterator iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status status = iterator.next(); + if (status.account.id.equals(accountId)) { + iterator.remove(); + } + } + adapter.update(statuses.getPairedCopy()); + } + + private void onLoadMore() { + sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM); } private void fullyRefresh() { @@ -338,21 +440,27 @@ public class TimelineFragment extends SFragment implements } private Call> getFetchCallByTimelineType(Kind kind, String tagOrId, String fromId, - String uptoId) { + String uptoId) { MastodonApi api = mastodonApi; switch (kind) { default: - case HOME: return api.homeTimeline(fromId, uptoId, null); - case PUBLIC_FEDERATED: return api.publicTimeline(null, fromId, uptoId, null); - case PUBLIC_LOCAL: return api.publicTimeline(true, fromId, uptoId, null); - case TAG: return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null); - case USER: return api.accountStatuses(tagOrId, fromId, uptoId, null); - case FAVOURITES: return api.favourites(fromId, uptoId, null); + case HOME: + return api.homeTimeline(fromId, uptoId, null); + case PUBLIC_FEDERATED: + return api.publicTimeline(null, fromId, uptoId, null); + case PUBLIC_LOCAL: + return api.publicTimeline(true, fromId, uptoId, null); + case TAG: + return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null); + case USER: + return api.accountStatuses(tagOrId, fromId, uptoId, null); + case FAVOURITES: + return api.favourites(fromId, uptoId, null); } } private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId, - final FetchEnd fetchEnd) { + final FetchEnd fetchEnd) { /* If there is a fetch already ongoing, record however many fetches are requested and * fulfill them after it's complete. */ if (fetchEnd == FetchEnd.TOP && topLoading) { @@ -399,7 +507,7 @@ public class TimelineFragment extends SFragment implements } public void onFetchTimelineSuccess(List statuses, String linkHeader, - FetchEnd fetchEnd) { + FetchEnd fetchEnd) { filterStatuses(statuses); List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { @@ -409,7 +517,7 @@ public class TimelineFragment extends SFragment implements if (previous != null) { uptoId = previous.uri.getQueryParameter("since_id"); } - adapter.update(statuses, null, uptoId); + updateStatuses(statuses, null, uptoId); break; } case BOTTOM: { @@ -419,7 +527,7 @@ public class TimelineFragment extends SFragment implements fromId = next.uri.getQueryParameter("max_id"); } if (adapter.getItemCount() > 1) { - adapter.addItems(statuses, fromId); + addItems(statuses, fromId); } else { /* If this is the first fetch, also save the id from the "previous" link and * treat this operation as a refresh so the scroll position doesn't get pushed @@ -429,7 +537,7 @@ public class TimelineFragment extends SFragment implements if (previous != null) { uptoId = previous.uri.getQueryParameter("since_id"); } - adapter.update(statuses, fromId, uptoId); + updateStatuses(statuses, fromId, uptoId); } break; } @@ -455,7 +563,7 @@ public class TimelineFragment extends SFragment implements bottomLoading = false; if (bottomFetches > 0) { bottomFetches--; - onLoadMore(recyclerView); + onLoadMore(); } break; } @@ -480,4 +588,63 @@ public class TimelineFragment extends SFragment implements } } } + + private void updateStatuses(List newStatuses, @Nullable String fromId, + @Nullable String toId) { + if (newStatuses == null || newStatuses.isEmpty()) { + return; + } + if (fromId != null) { + bottomId = fromId; + } + if (toId != null) { + upToId = toId; + } + if (statuses.isEmpty()) { + // This construction removes duplicates. + statuses.addAll(new HashSet<>(newStatuses)); + } else { + Status lastOfNew = newStatuses.get(newStatuses.size() - 1); + int index = statuses.indexOf(lastOfNew); + for (int i = 0; i < index; i++) { + statuses.remove(0); + } + int newIndex = newStatuses.indexOf(statuses.get(0)); + if (newIndex == -1) { + statuses.addAll(0, newStatuses); + } else { + statuses.addAll(0, newStatuses.subList(0, newIndex)); + } + } + adapter.update(statuses.getPairedCopy()); + } + + private void addItems(List newStatuses, @Nullable String fromId) { + int end = statuses.size(); + Status last = statuses.get(end - 1); + if (last != null && !findStatus(newStatuses, last.id)) { + statuses.addAll(newStatuses); + List newViewDatas = statuses.getPairedCopy() + .subList(statuses.size() - newStatuses.size(), statuses.size()); + if (BuildConfig.DEBUG && newStatuses.size() != newViewDatas.size()) { + String error = String.format(Locale.getDefault(), + "Incorrectly got statusViewData sublist." + + " newStatuses.size == %d newViewDatas.size == %d, statuses.size == %d", + newStatuses.size(), newViewDatas.size(), statuses.size()); + throw new AssertionError(error); + } + if (fromId != null) bottomId = fromId; + adapter.addItems(newViewDatas); + } + } + + + private static boolean findStatus(List statuses, String id) { + for (Status status : statuses) { + if (status.id.equals(id)) { + return true; + } + } + return false; + } } 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 3529533ea..c4219e63b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -33,22 +33,32 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; - +import com.keylesspalace.tusky.BuildConfig; +import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.ThreadAdapter; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.StatusContext; -import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.AdapterItemRemover; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.receiver.TimelineReceiver; +import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.ThemeUtils; +import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.ConversationLineItemDecoration; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class ViewThreadFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, StatusActionListener { + SwipeRefreshLayout.OnRefreshListener, StatusActionListener, + AdapterItemRemover { private static final String TAG = "ViewThreadFragment"; private SwipeRefreshLayout swipeRefreshLayout; @@ -57,6 +67,11 @@ public class ViewThreadFragment extends SFragment implements private String thisThreadsStatusId; private TimelineReceiver timelineReceiver; + int statusIndex = 0; + + private final PairedList statuses = + new PairedList<>(ViewDataUtils.statusMapper()); + public static ViewThreadFragment newInstance(String id) { Bundle arguments = new Bundle(); ViewThreadFragment fragment = new ViewThreadFragment(); @@ -96,7 +111,7 @@ public class ViewThreadFragment extends SFragment implements thisThreadsStatusId = null; - timelineReceiver = new TimelineReceiver(adapter, this); + timelineReceiver = new TimelineReceiver(this, this); LocalBroadcastManager.getInstance(context.getApplicationContext()) .registerReceiver(timelineReceiver, TimelineReceiver.getFilter(null)); @@ -125,22 +140,65 @@ public class ViewThreadFragment extends SFragment implements @Override public void onReply(int position) { - super.reply(adapter.getItem(position)); + super.reply(statuses.get(position)); } @Override - public void onReblog(boolean reblog, int position) { - super.reblog(adapter.getItem(position), reblog, adapter, position); + public void onReblog(final boolean reblog, final int position) { + final Status status = statuses.get(position); + super.reblogWithCallback(statuses.get(position), reblog, new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + status.reblogged = reblog; + + if (status.reblog != null) { + status.reblog.reblogged = reblog; + } + // create new viewData as side effect + statuses.set(position, status); + + adapter.setItem(position, statuses.getPairedItem(position), true); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.id); + t.printStackTrace(); + } + }); } @Override - public void onFavourite(boolean favourite, int position) { - super.favourite(adapter.getItem(position), favourite, adapter, position); + public void onFavourite(final boolean favourite, final int position) { + final Status status = statuses.get(position); + super.favouriteWithCallback(statuses.get(position), favourite, new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + status.favourited = favourite; + + if (status.reblog != null) { + status.reblog.favourited = favourite; + } + // create new viewData as side effect + statuses.set(position, status); + adapter.setItem(position, statuses.getPairedItem(position), true); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.id); + t.printStackTrace(); + } + }); } @Override public void onMore(View view, int position) { - super.more(adapter.getItem(position), view, adapter, position); + super.more(statuses.get(position), view, this, position); } @Override @@ -150,7 +208,7 @@ public class ViewThreadFragment extends SFragment implements @Override public void onViewThread(int position) { - Status status = adapter.getItem(position); + Status status = statuses.get(position); if (thisThreadsStatusId.equals(status.id)) { // If already viewing this thread, don't reopen it. return; @@ -161,7 +219,25 @@ public class ViewThreadFragment extends SFragment implements @Override public void onOpenReblog(int position) { // there should be no reblogs in the thread but let's implement it to be sure - super.openReblog(adapter.getItem(position)); + super.openReblog(statuses.get(position)); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsExpanded(expanded) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.setItem(position, newViewData, false); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsShowingSensitiveContent(isShowing) + .createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.setItem(position, newViewData, false); } @Override @@ -174,13 +250,37 @@ public class ViewThreadFragment extends SFragment implements super.viewAccount(id); } + @Override + public void removeItem(int position) { + statuses.remove(position); + adapter.setStatuses(statuses.getPairedCopy()); + } + + @Override + public void removeAllByAccountId(String accountId) { + Status status = null; + if (!statuses.isEmpty()) { + status = statuses.get(statusIndex); + } + // using iterator to safely remove items while iterating + Iterator iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status s = iterator.next(); + if (s.account.id.equals(accountId)) { + iterator.remove(); + } + } + statusIndex = statuses.indexOf(status); + adapter.setStatuses(statuses.getPairedCopy()); + } + private void sendStatusRequest(final String id) { Call call = mastodonApi.status(id); call.enqueue(new Callback() { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { - int position = adapter.setStatus(response.body()); + int position = setStatus(response.body()); recyclerView.scrollToPosition(position); } else { onThreadRequestFailure(id); @@ -203,7 +303,7 @@ public class ViewThreadFragment extends SFragment implements if (response.isSuccessful()) { swipeRefreshLayout.setRefreshing(false); StatusContext context = response.body(); - adapter.setContext(context.ancestors, context.descendants); + setContext(context.ancestors, context.descendants); } else { onThreadRequestFailure(id); } @@ -234,4 +334,72 @@ public class ViewThreadFragment extends SFragment implements Log.e(TAG, "Couldn't display thread fetch error message"); } } + + public int setStatus(Status status) { + if (statuses.size() > 0 + && statusIndex < statuses.size() + && statuses.get(statusIndex).equals(status)) { + // Do not add this status on refresh, it's already in there. + statuses.set(statusIndex, status); + return statusIndex; + } + int i = statusIndex; + statuses.add(i, status); + adapter.addItem(i, statuses.getPairedItem(i)); + return i; + } + + public void setContext(List ancestors, List descendants) { + Status mainStatus = null; + + // In case of refresh, remove old ancestors and descendants first. We'll remove all blindly, + // as we have no guarantee on their order to be the same as before + int oldSize = statuses.size(); + if (oldSize > 1) { + mainStatus = statuses.get(statusIndex); + statuses.clear(); + adapter.clearItems(); + } + + // Insert newly fetched ancestors + statusIndex = ancestors.size(); + statuses.addAll(0, ancestors); + List ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); + if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) { + String error = String.format(Locale.getDefault(), + "Incorrectly got statusViewData sublist." + + " ancestors.size == %d ancestorsViewDatas.size == %d," + + " statuses.size == %d", + ancestors.size(), ancestorsViewDatas.size(), statuses.size()); + throw new AssertionError(error); + } + adapter.addAll(0, ancestorsViewDatas); + + if (mainStatus != null) { + // In case we needed to delete everything (which is way easier than deleting + // everything except one), re-insert the remaining status here. + statuses.add(statusIndex, mainStatus); + adapter.addItem(statusIndex, statuses.getPairedItem(statusIndex)); + } + + // Insert newly fetched descendants + statuses.addAll(descendants); + List descendantsViewData; + descendantsViewData = statuses.getPairedCopy() + .subList(statuses.size() - descendants.size(), statuses.size()); + if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) { + String error = String.format(Locale.getDefault(), + "Incorrectly got statusViewData sublist." + + " descendants.size == %d descendantsViewData.size == %d," + + " statuses.size == %d", + descendants.size(), descendantsViewData.size(), statuses.size()); + throw new AssertionError(error); + } + adapter.addAll(descendantsViewData); + } + + public void clear() { + statuses.clear(); + adapter.clear(); + } } 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 9c00fc42c..6bf13f156 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.interfaces; import android.view.View; +import com.keylesspalace.tusky.adapter.StatusViewHolder; import com.keylesspalace.tusky.entity.Status; public interface StatusActionListener extends LinkListener { @@ -27,4 +28,6 @@ public interface StatusActionListener extends LinkListener { void onViewMedia(String[] urls, int index, Status.MediaAttachment.Type type); void onViewThread(int position); void onOpenReblog(int position); + void onExpandedChange(boolean expanded, int position); + void onContentHiddenChange(boolean isShowing, int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.java b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.java new file mode 100644 index 000000000..41d3ba48e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.java @@ -0,0 +1,84 @@ +package com.keylesspalace.tusky.util; + +import android.arch.core.util.Function; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; + + +/** + * This list implementation can help to keep two lists in sync - like real models and view models. + * Every operation on the main list triggers update of the supplementary list (but not vice versa). + * This makes sure that the main list is always the source of truth. + * Main list is projected to the supplementary list by the passed mapper function. + * Paired list is newer actually exposed and clients are provided with {@code getPairedCopy()}, + * {@code getPairedItem()} and {@code setPairedItem()}. This prevents modifications of the + * supplementary list size so lists are always have the same length. + * This implementation will not try to recover from exceptional cases so lists may be out of sync + * after the exception. + * + * It is most useful with immutable data because we cannot track changes inside stored objects. + * @param type of elements in the main list + * @param type of elements in supplementary list + */ +public final class PairedList extends AbstractList { + private final List main = new ArrayList<>(); + private final List synced = new ArrayList<>(); + private final Function mapper; + + /** + * Construct new paired list. Main and supplementary lists will be empty. + * @param mapper Function, which will be used to translate items from the main list to the + * supplementary one. + */ + public PairedList(Function mapper) { + this.mapper = mapper; + } + + public List getPairedCopy() { + return new ArrayList<>(synced); + } + + public V getPairedItem(int index) { + return synced.get(index); + } + + public void setPairedItem(int index, V element) { + synced.set(index, element); + } + + @Override + public T get(int index) { + return main.get(index); + } + + @Override + public T set(int index, T element) { + synced.set(index, mapper.apply(element)); + return main.set(index, element); + } + + @Override + public boolean add(T t) { + synced.add(mapper.apply(t)); + return main.add(t); + } + + @Override + public void add(int index, T element) { + synced.add(index, mapper.apply(element)); + main.add(element); + } + + @Override + public T remove(int index) { + synced.remove(index); + return main.remove(index); + } + + @Override + public int size() { + return main.size(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java new file mode 100644 index 000000000..91c090735 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -0,0 +1,78 @@ +package com.keylesspalace.tusky.util; + +import android.arch.core.util.Function; +import android.support.annotation.Nullable; + +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.ArrayList; +import java.util.List; + +/** + * Created by charlag on 12/07/2017. + */ + +public final class ViewDataUtils { + @Nullable + public static StatusViewData statusToViewData(@Nullable Status status) { + if (status == null) return null; + Status visibleStatus = status.reblog == null ? status : status.reblog; + return new StatusViewData.Builder() + .setId(status.id) + .setAttachments(status.attachments) + .setAvatar(visibleStatus.account.avatar) + .setContent(visibleStatus.content) + .setCreatedAt(visibleStatus.createdAt) + .setFavourited(visibleStatus.favourited) + .setReblogged(visibleStatus.reblogged) + .setIsExpanded(false) + .setIsShowingSensitiveContent(false) + .setMentions(visibleStatus.mentions) + .setNickname(visibleStatus.account.username) + .setRebloggedAvatar(visibleStatus.account.avatar) + .setSensitive(visibleStatus.sensitive) + .setSpoilerText(visibleStatus.spoilerText) + .setRebloggedByUsername(status.reblog == null ? null : status.account.username) + .setUserFullName(visibleStatus.account.getDisplayName()) + .setSenderId(status.account.id) + .setRebloggingEnabled(visibleStatus.rebloggingAllowed()) + .createStatusViewData(); + } + + public static List statusListToViewDataList(List statuses) { + List viewDatas = new ArrayList<>(statuses.size()); + for (Status s : statuses) { + viewDatas.add(statusToViewData(s)); + } + return viewDatas; + } + + public static Function statusMapper() { + return statusMapper; + } + + public static NotificationViewData notificationToViewData(Notification notification) { + return new NotificationViewData(notification.type, notification.id, notification.account, + statusToViewData(notification.status)); + } + + public static List + notificationListToViewDataList(List notifications) { + List viewDatas = new ArrayList<>(notifications.size()); + for (Notification n : notifications) { + viewDatas.add(notificationToViewData(n)); + } + return viewDatas; + } + + private static final Function statusMapper = + new Function() { + @Override + public StatusViewData apply(Status input) { + return ViewDataUtils.statusToViewData(input); + } + }; +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java new file mode 100644 index 000000000..beec02089 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -0,0 +1,39 @@ +package com.keylesspalace.tusky.viewdata; + +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Notification; + +/** + * Created by charlag on 12/07/2017. + */ + +public final class NotificationViewData { + private final Notification.Type type; + private final String id; + private final Account account; + private final StatusViewData statusViewData; + + public NotificationViewData(Notification.Type type, String id, Account account, + StatusViewData statusViewData) { + this.type = type; + this.id = id; + this.account = account; + this.statusViewData = statusViewData; + } + + public Notification.Type getType() { + return type; + } + + public String getId() { + return id; + } + + public Account getAccount() { + return account; + } + + public StatusViewData 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 new file mode 100644 index 000000000..288769e62 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -0,0 +1,296 @@ +package com.keylesspalace.tusky.viewdata; + +import android.support.annotation.Nullable; +import android.text.Spanned; + +import com.keylesspalace.tusky.entity.Status; + +import java.util.Date; + +/** + * Created by charlag on 11/07/2017. + */ + +public final class StatusViewData { + private final String id; + private final Spanned content; + private final boolean reblogged; + private final boolean favourited; + @Nullable + private final String spoilerText; + private final Status.Visibility visibility; + private final Status.MediaAttachment[] attachments; + @Nullable + private final String rebloggedByUsername; + @Nullable + private final String rebloggedAvatar; + private final boolean isSensitive; + private final boolean isExpanded; + private final boolean isShowingSensitiveContent; + private final String userFullName; + private final String nickname; + private final String avatar; + private final Date createdAt; + // I would rather have something else but it would be too much of a rewrite + @Nullable + private final Status.Mention[] mentions; + private final String senderId; + private final boolean rebloggingEnabled; + + public StatusViewData(String id, Spanned contnet, boolean reblogged, boolean favourited, + String spoilerText, Status.Visibility visibility, + Status.MediaAttachment[] attachments, String rebloggedByUsername, + String rebloggedAvatar, boolean sensitive, boolean isExpanded, + boolean isShowingSensitiveWarning, String userFullName, String nickname, + String avatar, Date createdAt, Status.Mention[] mentions, + String senderId, boolean rebloggingEnabled) { + this.id = id; + this.content = contnet; + this.reblogged = reblogged; + this.favourited = favourited; + this.spoilerText = spoilerText; + this.visibility = visibility; + this.attachments = attachments; + this.rebloggedByUsername = rebloggedByUsername; + this.rebloggedAvatar = rebloggedAvatar; + this.isSensitive = sensitive; + this.isExpanded = isExpanded; + this.isShowingSensitiveContent = isShowingSensitiveWarning; + this.userFullName = userFullName; + this.nickname = nickname; + this.avatar = avatar; + this.createdAt = createdAt; + this.mentions = mentions; + this.senderId = senderId; + this.rebloggingEnabled = rebloggingEnabled; + } + + public String getId() { + return id; + } + + public Spanned getContent() { + return content; + } + + public boolean isReblogged() { + return reblogged; + } + + public boolean isFavourited() { + return favourited; + } + + @Nullable + public String getSpoilerText() { + return spoilerText; + } + + public Status.Visibility getVisibility() { + return visibility; + } + + public Status.MediaAttachment[] getAttachments() { + return attachments; + } + + @Nullable + public String getRebloggedByUsername() { + return rebloggedByUsername; + } + + public boolean isSensitive() { + return isSensitive; + } + + public boolean isExpanded() { + return isExpanded; + } + + public boolean isShowingSensitiveContent() { + return isShowingSensitiveContent; + } + + @Nullable + public String getRebloggedAvatar() { + return rebloggedAvatar; + } + + public String getUserFullName() { + return userFullName; + } + + public String getNickname() { + return nickname; + } + + public String getAvatar() { + return avatar; + } + + public Date getCreatedAt() { + return createdAt; + } + + public String getSenderId() { + return senderId; + } + + public Boolean getRebloggingEnabled() { + return rebloggingEnabled; + } + + @Nullable + public Status.Mention[] getMentions() { + return mentions; + } + + public static class Builder { + private String id; + private Spanned contnet; + private boolean reblogged; + private boolean favourited; + private String spoilerText; + private Status.Visibility visibility; + private Status.MediaAttachment[] attachments; + private String rebloggedByUsername; + private String rebloggedAvatar; + private boolean isSensitive; + private boolean isExpanded; + private boolean isShowingSensitiveContent; + private String userFullName; + private String nickname; + private String avatar; + private Date createdAt; + private Status.Mention[] mentions; + private String senderId; + private boolean rebloggingEnabled; + + public Builder() { + } + + public Builder(final StatusViewData viewData) { + id = viewData.id; + contnet = viewData.content; + reblogged = viewData.reblogged; + favourited = viewData.favourited; + spoilerText = viewData.spoilerText; + visibility = viewData.visibility; + attachments = viewData.attachments == null ? null : viewData.attachments.clone(); + rebloggedByUsername = viewData.rebloggedByUsername; + rebloggedAvatar = viewData.rebloggedAvatar; + isSensitive = viewData.isSensitive; + isExpanded = viewData.isExpanded; + isShowingSensitiveContent = viewData.isShowingSensitiveContent; + userFullName = viewData.userFullName; + nickname = viewData.nickname; + avatar = viewData.avatar; + createdAt = new Date(viewData.createdAt.getTime()); + mentions = viewData.mentions == null ? null : viewData.mentions.clone(); + senderId = viewData.senderId; + rebloggingEnabled = viewData.rebloggingEnabled; + } + + public Builder setId(String id) { + this.id = id; + return this; + } + + public Builder setContent(Spanned content) { + this.contnet = content; + return this; + } + + public Builder setReblogged(boolean reblogged) { + this.reblogged = reblogged; + return this; + } + + public Builder setFavourited(boolean favourited) { + this.favourited = favourited; + return this; + } + + public Builder setSpoilerText(String spoilerText) { + this.spoilerText = spoilerText; + return this; + } + + public Builder setVisibility(Status.Visibility visibility) { + this.visibility = visibility; + return this; + } + + public Builder setAttachments(Status.MediaAttachment[] attachments) { + this.attachments = attachments; + return this; + } + + public Builder setRebloggedByUsername(String rebloggedByUsername) { + this.rebloggedByUsername = rebloggedByUsername; + return this; + } + + public Builder setRebloggedAvatar(String rebloggedAvatar) { + this.rebloggedAvatar = rebloggedAvatar; + return this; + } + + public Builder setSensitive(boolean sensitive) { + this.isSensitive = sensitive; + return this; + } + + public Builder setIsExpanded(boolean isExpanded) { + this.isExpanded = isExpanded; + return this; + } + + public Builder setIsShowingSensitiveContent(boolean isShowingSensitiveContent) { + this.isShowingSensitiveContent = isShowingSensitiveContent; + return this; + } + + public Builder setUserFullName(String userFullName) { + this.userFullName = userFullName; + return this; + } + + public Builder setNickname(String nickname) { + this.nickname = nickname; + return this; + } + + public Builder setAvatar(String avatar) { + this.avatar = avatar; + return this; + } + + public Builder setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + return this; + } + + public Builder setMentions(Status.Mention[] mentions) { + this.mentions = mentions; + return this; + } + + public Builder setSenderId(String senderId) { + this.senderId = senderId; + return this; + } + + public Builder setRebloggingEnabled(boolean rebloggingEnabled) { + this.rebloggingEnabled = rebloggingEnabled; + return this; + } + + public StatusViewData createStatusViewData() { + return new StatusViewData(id, contnet, reblogged, favourited, spoilerText, visibility, + attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, + isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, mentions, + senderId, rebloggingEnabled); + } + } +} diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index f87ce8d52..36ace6d04 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -297,7 +297,8 @@ android:layout_width="40dp" android:layout_height="40dp" android:padding="4dp" - android:contentDescription="@string/action_reblog" /> + android:contentDescription="@string/action_reblog" + android:clipToPadding="false"/> + android:contentDescription="@string/action_favourite" + android:clipToPadding="false"/>