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 31a77a4f..619e6bd1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -47,6 +47,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_FOOTER = 1; private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2; private static final int VIEW_TYPE_FOLLOW = 3; + private static final int VIEW_TYPE_PLACEHOLDER = 4; private List notifications; private StatusActionListener statusListener; @@ -88,6 +89,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter { .inflate(R.layout.item_follow, parent, false); return new FollowViewHolder(view); } + case VIEW_TYPE_PLACEHOLDER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_status_placeholder, parent, false); + return new PlaceholderViewHolder(view); + } } } @@ -95,11 +101,19 @@ public class NotificationsAdapter extends RecyclerView.Adapter { public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { if (position < notifications.size()) { NotificationViewData notification = notifications.get(position); - Notification.Type type = notification.getType(); + if (notification instanceof NotificationViewData.Placeholder) { + NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(!placeholder.isLoading(), statusListener); + return; + } + NotificationViewData.Concrete concreteNotificaton = + (NotificationViewData.Concrete) notification; + Notification.Type type = concreteNotificaton.getType(); switch (type) { case MENTION: { StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData status = notification.getStatusViewData(); + StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); holder.setupWithStatus(status, statusListener, mediaPreviewEnabled); break; @@ -107,18 +121,18 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case FAVOURITE: case REBLOG: { StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - holder.setMessage(type, notification.getAccount().getDisplayName(), - notification.getStatusViewData()); - holder.setupButtons(notificationActionListener, notification.getAccount().id); - holder.setAvatars(notification.getStatusViewData().getAvatar(), - notification.getAccount().avatar); + holder.setMessage(type, concreteNotificaton.getAccount().getDisplayName(), + concreteNotificaton.getStatusViewData()); + holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().id); + holder.setAvatars(concreteNotificaton.getStatusViewData().getAvatar(), + concreteNotificaton.getAccount().avatar); break; } case FOLLOW: { FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(notification.getAccount().getDisplayName(), - notification.getAccount().username, notification.getAccount().avatar); - holder.setupButtons(notificationActionListener, notification.getAccount().id); + holder.setMessage(concreteNotificaton.getAccount().getDisplayName(), + concreteNotificaton.getAccount().username, concreteNotificaton.getAccount().avatar); + holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().id); break; } } @@ -139,18 +153,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter { return VIEW_TYPE_FOOTER; } else { NotificationViewData notification = notifications.get(position); - switch (notification.getType()) { - default: - case MENTION: { - return VIEW_TYPE_MENTION; - } - case FAVOURITE: - case REBLOG: { - return VIEW_TYPE_STATUS_NOTIFICATION; - } - case FOLLOW: { - return VIEW_TYPE_FOLLOW; + if (notification instanceof NotificationViewData.Concrete) { + NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); + switch (concrete.getType()) { + default: + case MENTION: { + return VIEW_TYPE_MENTION; + } + case FAVOURITE: + case REBLOG: { + return VIEW_TYPE_STATUS_NOTIFICATION; + } + case FOLLOW: { + return VIEW_TYPE_FOLLOW; + } } + } else if (notification instanceof NotificationViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + throw new AssertionError("Unknown notification type"); } } } @@ -258,7 +279,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); } - void setMessage(Notification.Type type, String displayName, StatusViewData status) { + void setMessage(Notification.Type type, String displayName, + StatusViewData.Concrete status) { Context context = message.getContext(); String format; switch (type) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java new file mode 100644 index 00000000..7129ab16 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java @@ -0,0 +1,49 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.interfaces.StatusActionListener; + +public class PlaceholderViewHolder extends RecyclerView.ViewHolder { + + private Button loadMoreButton; + + + PlaceholderViewHolder(View itemView) { + super(itemView); + loadMoreButton = itemView.findViewById(R.id.button_load_more); + + } + + public void setup(boolean enabled, final StatusActionListener listener){ + loadMoreButton.setEnabled(enabled); + if(enabled) { + loadMoreButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + loadMoreButton.setEnabled(false); + listener.onLoadMore(getAdapterPosition()); + } + }); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 56a203a1..2bb0594b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -271,6 +271,9 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { sensitiveMediaShow.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(false, getAdapterPosition()); + } v.setVisibility(View.GONE); sensitiveMediaWarning.setVisibility(View.VISIBLE); } @@ -470,7 +473,7 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { container.setOnClickListener(viewThreadListener); } - void setupWithStatus(StatusViewData status, final StatusActionListener listener, + void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, boolean mediaPreviewEnabled) { setDisplayName(status.getUserFullName()); setUsername(status.getNickname()); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 4098e325..8461d776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -85,7 +85,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - void setupWithStatus(final StatusViewData status, final StatusActionListener listener, + void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener, boolean mediaPreviewEnabled) { super.setupWithStatus(status, listener, mediaPreviewEnabled); reblogs.setText(status.getReblogsCount()); 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 7992eeb5..943e7d72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -67,7 +67,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - void setupWithStatus(StatusViewData status, final StatusActionListener listener, + void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, boolean mediaPreviewEnabled) { super.setupWithStatus(status, listener, mediaPreviewEnabled); 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 46481792..c743a54b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java @@ -33,7 +33,7 @@ public class ThreadAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_STATUS_DETAILED = 1; - private List statuses; + private List statuses; private StatusActionListener statusActionListener; private boolean mediaPreviewEnabled; private int detailedStatusPosition; @@ -66,13 +66,12 @@ public class ThreadAdapter extends RecyclerView.Adapter { @Override public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + StatusViewData.Concrete status = statuses.get(position); if (position == detailedStatusPosition) { StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder; - StatusViewData status = statuses.get(position); holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled); } else { StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData status = statuses.get(position); holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled); } } @@ -91,13 +90,13 @@ public class ThreadAdapter extends RecyclerView.Adapter { return statuses.size(); } - public void setStatuses(List statuses) { + public void setStatuses(List statuses) { this.statuses.clear(); this.statuses.addAll(statuses); notifyDataSetChanged(); } - public void addItem(int position, StatusViewData statusViewData) { + public void addItem(int position, StatusViewData.Concrete statusViewData) { statuses.add(position, statusViewData); notifyItemInserted(position); } @@ -109,12 +108,12 @@ public class ThreadAdapter extends RecyclerView.Adapter { notifyItemRangeRemoved(0, oldSize); } - public void addAll(int position, List statuses) { + public void addAll(int position, List statuses) { this.statuses.addAll(position, statuses); notifyItemRangeInserted(position, statuses.size()); } - public void addAll(List statuses) { + public void addAll(List statuses) { int end = statuses.size(); this.statuses.addAll(statuses); notifyItemRangeInserted(end, statuses.size()); @@ -126,7 +125,7 @@ public class ThreadAdapter extends RecyclerView.Adapter { notifyDataSetChanged(); } - public void setItem(int position, StatusViewData status, boolean notifyAdapter) { + public void setItem(int position, StatusViewData.Concrete status, boolean notifyAdapter) { statuses.set(position, status); if (notifyAdapter) { notifyItemChanged(position); @@ -134,7 +133,7 @@ public class ThreadAdapter extends RecyclerView.Adapter { } @Nullable - public StatusViewData getItem(int position) { + public StatusViewData.Concrete getItem(int position) { if (position != RecyclerView.NO_POSITION && position >= 0 && position < statuses.size()) { return statuses.get(position); } else { 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 5b3d6cb1..1041551d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java @@ -31,6 +31,7 @@ import java.util.List; public class TimelineAdapter extends RecyclerView.Adapter { private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_FOOTER = 1; + private static final int VIEW_TYPE_PLACEHOLDER = 2; private List statuses; private StatusActionListener statusListener; @@ -59,15 +60,28 @@ public class TimelineAdapter extends RecyclerView.Adapter { .inflate(R.layout.item_footer, viewGroup, false); return new FooterViewHolder(view); } + case VIEW_TYPE_PLACEHOLDER: { + View view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_status_placeholder, viewGroup, false); + return new PlaceholderViewHolder(view); + } } } @Override public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { if (position < statuses.size()) { - StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewData status = statuses.get(position); - holder.setupWithStatus(status, statusListener, mediaPreviewEnabled); + if (status instanceof StatusViewData.Placeholder) { + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(!((StatusViewData.Placeholder) status).isLoading(), statusListener); + } else { + + StatusViewHolder holder = (StatusViewHolder) viewHolder; + holder.setupWithStatus((StatusViewData.Concrete) status, + statusListener, mediaPreviewEnabled); + } + } else { FooterViewHolder holder = (FooterViewHolder) viewHolder; holder.setState(footerState); @@ -84,7 +98,11 @@ public class TimelineAdapter extends RecyclerView.Adapter { if (position == statuses.size()) { return VIEW_TYPE_FOOTER; } else { - return VIEW_TYPE_STATUS; + if (statuses.get(position) instanceof StatusViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + return VIEW_TYPE_STATUS; + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java index 3c1c3629..6bc81c59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java @@ -106,19 +106,17 @@ public class Status { public static final int MAX_MEDIA_ATTACHMENTS = 4; @Override - public int hashCode() { - return id.hashCode(); + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Status status = (Status) o; + return id != null ? id.equals(status.id) : status.id == null; } @Override - public boolean equals(Object other) { - if (this.id == null) { - return this == other; - } else if (!(other instanceof Status)) { - return false; - } - Status status = (Status) other; - return status.id.equals(this.id); + public int hashCode() { + return id != null ? id.hashCode() : 0; } public static class MediaAttachment { 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 018bf372..583ee0ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -44,6 +44,8 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.receiver.TimelineReceiver; +import com.keylesspalace.tusky.util.CollectionUtil; +import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.PairedList; @@ -54,7 +56,6 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.Iterator; -import java.util.LinkedHashSet; import java.util.List; import retrofit2.Call; @@ -65,11 +66,29 @@ public class NotificationsFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, NotificationsAdapter.NotificationActionListener, SharedPreferences.OnSharedPreferenceChangeListener { - private static final String TAG = "Notifications"; // logging tag + private static final String TAG = "NotificationF"; // logging tag + + private static final int LOAD_AT_ONCE = 30; private enum FetchEnd { TOP, - BOTTOM + BOTTOM, + MIDDLE + } + + /** + * Placeholder for the notifications. Consider moving to the separate class to hide constructor + * and reuse in different places as needed. + */ + private static final class Placeholder { + private static final Placeholder INSTANCE = new Placeholder(); + + public static Placeholder getInstance() { + return INSTANCE; + } + + private Placeholder() { + } } private SwipeRefreshLayout swipeRefreshLayout; @@ -87,11 +106,17 @@ public class NotificationsFragment extends SFragment implements private String bottomId; private String topId; - private final PairedList notifications - = new PairedList<>(new Function() { + // Each element is either a Notification for loading data or a Placeholder + private final PairedList, NotificationViewData> notifications + = new PairedList<>(new Function, NotificationViewData>() { @Override - public NotificationViewData apply(Notification input) { - return ViewDataUtils.notificationToViewData(input); + public NotificationViewData apply(Either input) { + if (input.isRight()) { + Notification notification = input.getAsRight(); + return ViewDataUtils.notificationToViewData(notification); + } else { + return new NotificationViewData.Placeholder(false); + } } }); @@ -156,12 +181,10 @@ public class NotificationsFragment extends SFragment implements TabLayout layout = 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) { @@ -185,7 +208,7 @@ public class NotificationsFragment extends SFragment implements ActionButtonActivity activity = (ActionButtonActivity) getActivity(); FloatingActionButton composeButton = activity.getActionButton(); - if(composeButton != null) { + if (composeButton != null) { if (hideFab) { if (dy > 0 && composeButton.isShown()) { composeButton.hide(); // hides the button if we're scrolling down @@ -220,18 +243,17 @@ public class NotificationsFragment extends SFragment implements @Override public void onRefresh() { - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP); + sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); } @Override public void onReply(int position) { - Notification notification = notifications.get(position); - super.reply(notification.status); + super.reply(notifications.get(position).getAsRight().status); } @Override public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position); + final Notification notification = notifications.get(position).getAsRight(); final Status status = notification.status; reblogWithCallback(status, reblog, new Callback() { @Override @@ -242,7 +264,9 @@ public class NotificationsFragment extends SFragment implements if (status.reblog != null) { status.reblog.reblogged = reblog; } - notifications.set(position, notification); + // Java's type inference *eyeroll* + notifications.set(position, + Either.right(notification)); adapter.updateItemWithNotify(position, notifications.getPairedItem(position), true); @@ -252,8 +276,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.id); - t.printStackTrace(); + Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.id, t); } }); } @@ -261,7 +284,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position); + final Notification notification = notifications.get(position).getAsRight(); final Status status = notification.status; favouriteWithCallback(status, favourite, new Callback() { @Override @@ -273,7 +296,8 @@ public class NotificationsFragment extends SFragment implements status.reblog.favourited = favourite; } - notifications.set(position, notification); + notifications.set(position, + Either.right(notification)); adapter.updateItemWithNotify(position, notifications.getPairedItem(position), true); @@ -283,15 +307,14 @@ public class NotificationsFragment extends SFragment implements @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.id); - t.printStackTrace(); + Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.id, t); } }); } @Override public void onMore(View view, int position) { - Notification notification = notifications.get(position); + Notification notification = notifications.get(position).getAsRight(); super.more(notification.status, view, position); } @@ -303,24 +326,25 @@ public class NotificationsFragment extends SFragment implements @Override public void onViewThread(int position) { - Notification notification = notifications.get(position); + Notification notification = notifications.get(position).getAsRight(); super.viewThread(notification.status); } @Override public void onOpenReblog(int position) { - Notification notification = notifications.get(position); - if (notification != null) onViewAccount(notification.account.id); + Notification notification = notifications.get(position).getAsRight(); + onViewAccount(notification.account.id); } @Override public void onExpandedChange(boolean expanded, int position) { - NotificationViewData old = notifications.getPairedItem(position); - StatusViewData statusViewData = + NotificationViewData.Concrete old = + (NotificationViewData.Concrete) notifications.getPairedItem(position); + StatusViewData.Concrete statusViewData = new StatusViewData.Builder(old.getStatusViewData()) .setIsExpanded(expanded) .createStatusViewData(); - NotificationViewData notificationViewData = new NotificationViewData(old.getType(), + NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), old.getId(), old.getAccount(), statusViewData); notifications.setPairedItem(position, notificationViewData); adapter.updateItemWithNotify(position, notificationViewData, false); @@ -328,17 +352,38 @@ public class NotificationsFragment extends SFragment implements @Override public void onContentHiddenChange(boolean isShowing, int position) { - NotificationViewData old = notifications.getPairedItem(position); - StatusViewData statusViewData = + NotificationViewData.Concrete old = + (NotificationViewData.Concrete) notifications.getPairedItem(position); + StatusViewData.Concrete statusViewData = new StatusViewData.Builder(old.getStatusViewData()) .setIsShowingSensitiveContent(isShowing) .createStatusViewData(); - NotificationViewData notificationViewData = new NotificationViewData(old.getType(), + NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), old.getId(), old.getAccount(), statusViewData); notifications.setPairedItem(position, notificationViewData); adapter.updateItemWithNotify(position, notificationViewData, false); } + @Override + public void onLoadMore(int position) { + //check bounds before accessing list, + if (notifications.size() >= position && position > 0) { + Notification previous = notifications.get(position - 1).getAsRightOrNull(); + Notification next = notifications.get(position + 1).getAsRightOrNull(); + if (previous == null || next == null) { + Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); + return; + } + sendFetchNotificationsRequest(previous.id, next.id, FetchEnd.MIDDLE, position); + NotificationViewData notificationViewData = + new NotificationViewData.Placeholder(true); + notifications.setPairedItem(position, notificationViewData); + adapter.updateItemWithNotify(position, notificationViewData, false); + } else { + Log.d(TAG, "error loading more"); + } + } + @Override public void onViewTag(String tag) { super.viewTag(tag); @@ -374,10 +419,11 @@ public class NotificationsFragment extends SFragment implements @Override public void removeAllByAccountId(String accountId) { // using iterator to safely remove items while iterating - Iterator iterator = notifications.iterator(); + Iterator> iterator = notifications.iterator(); while (iterator.hasNext()) { - Notification notification = iterator.next(); - if (notification.account.id.equals(accountId)) { + Either notification = iterator.next(); + Notification maybeNotification = notification.getAsRightOrNull(); + if (maybeNotification != null && maybeNotification.account.id.equals(accountId)) { iterator.remove(); } } @@ -385,7 +431,7 @@ public class NotificationsFragment extends SFragment implements } private void onLoadMore() { - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM); + sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); } private void jumpToTop() { @@ -394,7 +440,7 @@ public class NotificationsFragment extends SFragment implements } private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd) { + final FetchEnd fetchEnd, final int pos) { /* 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) { @@ -418,7 +464,7 @@ public class NotificationsFragment extends SFragment implements }); } - Call> call = mastodonApi.notifications(fromId, uptoId, null); + Call> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE); call.enqueue(new Callback>() { @Override @@ -426,22 +472,22 @@ public class NotificationsFragment extends SFragment implements @NonNull Response> response) { if (response.isSuccessful()) { String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd); + onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd); + onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); } } @Override public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - onFetchNotificationsFailure((Exception) t, fetchEnd); + onFetchNotificationsFailure((Exception) t, fetchEnd, pos); } }); callList.add(call); } private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd) { + FetchEnd fetchEnd, int pos) { List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { case TOP: { @@ -453,6 +499,10 @@ public class NotificationsFragment extends SFragment implements update(notifications, null, uptoId); break; } + case MIDDLE: { + replacePlaceholderWithNotifications(notifications, pos); + break; + } case BOTTOM: { HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next"); String fromId = null; @@ -489,8 +539,20 @@ public class NotificationsFragment extends SFragment implements swipeRefreshLayout.setRefreshing(false); } - public void update(@Nullable List newNotifications, @Nullable String fromId, - @Nullable String uptoId) { + private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) { + swipeRefreshLayout.setRefreshing(false); + if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { + NotificationViewData placeholderVD = + new NotificationViewData.Placeholder(false); + notifications.setPairedItem(position, placeholderVD); + adapter.updateItemWithNotify(position, placeholderVD, true); + } + Log.e(TAG, "Fetch failure: " + exception.getMessage()); + fulfillAnyQueuedFetches(fetchEnd); + } + + private void update(@Nullable List newNotifications, @Nullable String fromId, + @Nullable String uptoId) { if (ListUtils.isEmpty(newNotifications)) { return; } @@ -500,26 +562,31 @@ public class NotificationsFragment extends SFragment implements if (uptoId != null) { topId = uptoId; } + List> liftedNew = + liftNotificationList(newNotifications); if (notifications.isEmpty()) { - // This construction removes duplicates while preserving order. - notifications.addAll(new LinkedHashSet<>(newNotifications)); + notifications.addAll(liftedNew); } else { - int index = notifications.indexOf(newNotifications.get(newNotifications.size() - 1)); + int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); for (int i = 0; i < index; i++) { notifications.remove(0); } - int newIndex = newNotifications.indexOf(notifications.get(0)); + + + int newIndex = liftedNew.indexOf(notifications.get(0)); if (newIndex == -1) { - notifications.addAll(0, newNotifications); + if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { + liftedNew.add(Either.left(Placeholder.getInstance())); + } + notifications.addAll(0, liftedNew); } else { - List sublist = newNotifications.subList(0, newIndex); - notifications.addAll(0, sublist); + notifications.addAll(0, liftedNew.subList(0, newIndex)); } } adapter.update(notifications.getPairedCopy()); } - public void addItems(List newNotifications, @Nullable String fromId) { + private void addItems(List newNotifications, @Nullable String fromId) { if (ListUtils.isEmpty(newNotifications)) { return; } @@ -527,9 +594,10 @@ public class NotificationsFragment extends SFragment implements bottomId = fromId; } int end = notifications.size(); - Notification last = notifications.get(end - 1); - if (last != null && !findNotification(newNotifications, last.id)) { - notifications.addAll(newNotifications); + List> liftedNew = liftNotificationList(newNotifications); + Either last = notifications.get(end - 1); + if (last != null && liftedNew.indexOf(last) == -1) { + notifications.addAll(liftedNew); List newViewDatas = notifications.getPairedCopy() .subList(notifications.size() - newNotifications.size(), notifications.size()); @@ -537,21 +605,6 @@ public class NotificationsFragment extends SFragment implements } } - 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()); - fulfillAnyQueuedFetches(fetchEnd); - } - private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) { switch (fetchEnd) { case BOTTOM: { @@ -573,9 +626,43 @@ public class NotificationsFragment extends SFragment implements } } + private void replacePlaceholderWithNotifications(List newNotifications, int pos) { + // Remove placeholder + notifications.remove(pos); + + if (ListUtils.isEmpty(newNotifications)) { + adapter.update(notifications.getPairedCopy()); + return; + } + + List> liftedNew = liftNotificationList(newNotifications); + + // If we fetched less posts than in the limit, it means that the hole is not filled + // If we fetched at least as much it means that there are more posts to load and we should + // insert new placeholder + if (newNotifications.size() >= LOAD_AT_ONCE) { + liftedNew.add(Either.left(Placeholder.getInstance())); + } + + notifications.addAll(pos, liftedNew); + adapter.update(notifications.getPairedCopy()); + } + + private final Function> notificationLifter = + new Function>() { + @Override + public Either apply(Notification input) { + return Either.right(input); + } + }; + + private List> liftNotificationList(List list) { + return CollectionUtil.map(list, notificationLifter); + } + private void fullyRefresh() { adapter.clear(); notifications.clear(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP); + sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); } } 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 77918c7f..87395791 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -21,6 +21,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.ActivityOptionsCompat; import android.support.v4.content.LocalBroadcastManager; @@ -148,10 +149,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov Call call = mastodonApi.muteAccount(id); call.enqueue(new Callback() { @Override - public void onResponse(Call call, Response response) {} + public void onResponse(@NonNull Call call, @NonNull Response response) {} @Override - public void onFailure(Call call, Throwable t) {} + public void onFailure(@NonNull Call call, @NonNull Throwable t) {} }); callList.add(call); Intent intent = new Intent(TimelineReceiver.Types.MUTE_ACCOUNT); @@ -164,10 +165,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov Call call = mastodonApi.blockAccount(id); call.enqueue(new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) {} + public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) {} @Override - public void onFailure(Call call, Throwable t) {} + public void onFailure(@NonNull Call call, @NonNull Throwable t) {} }); callList.add(call); Intent intent = new Intent(TimelineReceiver.Types.BLOCK_ACCOUNT); @@ -180,10 +181,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov Call call = mastodonApi.deleteStatus(id); call.enqueue(new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) {} + public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) {} @Override - public void onFailure(Call call, Throwable t) {} + public void onFailure(@NonNull Call call, @NonNull Throwable t) {} }); 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 d3c9b3df..0861a86a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.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; @@ -25,6 +26,7 @@ import android.support.annotation.Nullable; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.TabLayout; import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.util.Pair; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; @@ -43,6 +45,8 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.receiver.TimelineReceiver; +import com.keylesspalace.tusky.util.CollectionUtil; +import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.PairedList; @@ -52,7 +56,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.Iterator; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -64,10 +67,12 @@ public class TimelineFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, SharedPreferences.OnSharedPreferenceChangeListener { - private static final String TAG = "Timeline"; // logging tag + private static final String TAG = "TimelineF"; // logging tag private static final String KIND_ARG = "kind"; private static final String HASHTAG_OR_ID_ARG = "hashtag_or_id"; + private static final int LOAD_AT_ONCE = 30; + public enum Kind { HOME, PUBLIC_LOCAL, @@ -80,6 +85,7 @@ public class TimelineFragment extends SFragment implements private enum FetchEnd { TOP, BOTTOM, + MIDDLE } private SwipeRefreshLayout swipeRefreshLayout; @@ -102,8 +108,18 @@ public class TimelineFragment extends SFragment implements private String bottomId; @Nullable private String topId; - private PairedList statuses = - new PairedList<>(ViewDataUtils.statusMapper()); + private PairedList, StatusViewData> statuses = + new PairedList<>(new Function, StatusViewData>() { + @Override + public StatusViewData apply(Either input) { + Status status = input.getAsRightOrNull(); + if (status != null) { + return ViewDataUtils.statusToViewData(status); + } else { + return new StatusViewData.Placeholder(false); + } + } + }); public static TimelineFragment newInstance(Kind kind) { TimelineFragment fragment = new TimelineFragment(); @@ -122,6 +138,17 @@ public class TimelineFragment extends SFragment implements return fragment; } + private static final class Placeholder { + private final static Placeholder INSTANCE = new Placeholder(); + + public static Placeholder getInstance() { + return INSTANCE; + } + + private Placeholder() { + } + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -179,12 +206,10 @@ public class TimelineFragment extends SFragment implements TabLayout layout = 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) { @@ -219,7 +244,7 @@ public class TimelineFragment extends SFragment implements } else if (!composeButton.isShown()) { composeButton.show(); } - } + } } @Override @@ -251,86 +276,97 @@ public class TimelineFragment extends SFragment implements @Override public void onRefresh() { - sendFetchTimelineRequest(null, topId, FetchEnd.TOP); + sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1); } @Override public void onReply(int position) { - super.reply(statuses.get(position)); + super.reply(statuses.get(position).getAsRight()); } @Override public void onReblog(final boolean reblog, final int position) { - final Status status = statuses.get(position); + final Status status = statuses.get(position).getAsRight(); super.reblogWithCallback(status, reblog, new Callback() { @Override - public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { status.reblogged = reblog; if (status.reblog != null) { status.reblog.reblogged = reblog; } + + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + StatusViewData newViewData = - new StatusViewData.Builder(statuses.getPairedItem(position)) + new StatusViewData.Builder(actual.first) .setReblogged(reblog) .createStatusViewData(); - statuses.setPairedItem(position, newViewData); - adapter.changeItem(position, newViewData, true); + statuses.setPairedItem(actual.second, newViewData); + adapter.changeItem(actual.second, newViewData, true); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(TAG, "Failed to reblog status " + status.id); - t.printStackTrace(); + Log.d(TAG, "Failed to reblog status " + status.id, t); } }); } @Override public void onFavourite(final boolean favourite, final int position) { - final Status status = statuses.get(position); + final Status status = statuses.get(position).getAsRight(); super.favouriteWithCallback(status, favourite, new Callback() { @Override - public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { status.favourited = favourite; if (status.reblog != null) { status.reblog.favourited = favourite; } + + Pair actual = + findStatusAndPosition(position, status); + if (actual == null) return; + StatusViewData newViewData = new StatusViewData - .Builder(statuses.getPairedItem(position)) + .Builder(actual.first) .setFavourited(favourite) .createStatusViewData(); - statuses.setPairedItem(position, newViewData); - adapter.changeItem(position, newViewData, true); + statuses.setPairedItem(actual.second, newViewData); + adapter.changeItem(actual.second, newViewData, true); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(TAG, "Failed to favourite status " + status.id); - t.printStackTrace(); + Log.d(TAG, "Failed to favourite status " + status.id, t); } }); } @Override public void onMore(View view, final int position) { - super.more(statuses.get(position), view, position); + super.more(statuses.get(position).getAsRight(), view, position); } @Override public void onOpenReblog(int position) { - super.openReblog(statuses.get(position)); + super.openReblog(statuses.get(position).getAsRight()); } @Override public void onExpandedChange(boolean expanded, int position) { - StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + StatusViewData newViewData = new StatusViewData.Builder( + ((StatusViewData.Concrete) statuses.getPairedItem(position))) .setIsExpanded(expanded).createStatusViewData(); statuses.setPairedItem(position, newViewData); adapter.changeItem(position, newViewData, false); @@ -338,12 +374,33 @@ public class TimelineFragment extends SFragment implements @Override public void onContentHiddenChange(boolean isShowing, int position) { - StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + StatusViewData newViewData = new StatusViewData.Builder( + ((StatusViewData.Concrete) statuses.getPairedItem(position))) .setIsShowingSensitiveContent(isShowing).createStatusViewData(); statuses.setPairedItem(position, newViewData); adapter.changeItem(position, newViewData, false); } + @Override + public void onLoadMore(int position) { + //check bounds before accessing list, + if (statuses.size() >= position && position > 0) { + Status fromStatus = statuses.get(position - 1).getAsRightOrNull(); + Status toStatus = statuses.get(position + 1).getAsRightOrNull(); + if (fromStatus == null || toStatus == null) { + Log.e(TAG, "Failed to load more at " + position + ", wrong placeholder position"); + return; + } + sendFetchTimelineRequest(fromStatus.id, toStatus.id, FetchEnd.MIDDLE, position); + + StatusViewData newViewData = new StatusViewData.Placeholder(true); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, false); + } else { + Log.e(TAG, "error loading more"); + } + } + @Override public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type, View view) { @@ -352,7 +409,7 @@ public class TimelineFragment extends SFragment implements @Override public void onViewThread(int position) { - super.viewThread(statuses.get(position)); + super.viewThread(statuses.get(position).getAsRight()); } @Override @@ -417,10 +474,10 @@ public class TimelineFragment extends SFragment implements @Override public void removeAllByAccountId(String accountId) { // using iterator to safely remove items while iterating - Iterator iterator = statuses.iterator(); + Iterator> iterator = statuses.iterator(); while (iterator.hasNext()) { - Status status = iterator.next(); - if (status.account.id.equals(accountId)) { + Status status = iterator.next().getAsRightOrNull(); + if (status != null && status.account.id.equals(accountId)) { iterator.remove(); } } @@ -428,12 +485,12 @@ public class TimelineFragment extends SFragment implements } private void onLoadMore() { - sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM); + sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM, -1); } private void fullyRefresh() { adapter.clear(); - sendFetchTimelineRequest(null, null, FetchEnd.TOP); + sendFetchTimelineRequest(null, null, FetchEnd.TOP, -1); } private boolean jumpToTopAllowed() { @@ -457,20 +514,20 @@ public class TimelineFragment extends SFragment implements case HOME: return api.homeTimeline(fromId, uptoId, null); case PUBLIC_FEDERATED: - return api.publicTimeline(null, fromId, uptoId, null); + return api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE); case PUBLIC_LOCAL: - return api.publicTimeline(true, fromId, uptoId, null); + return api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE); case TAG: - return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null); + return api.hashtagTimeline(tagOrId, null, fromId, uptoId, LOAD_AT_ONCE); case USER: - return api.accountStatuses(tagOrId, fromId, uptoId, null, null); + return api.accountStatuses(tagOrId, fromId, uptoId, LOAD_AT_ONCE); case FAVOURITES: - return api.favourites(fromId, uptoId, null); + return api.favourites(fromId, uptoId, LOAD_AT_ONCE); } } private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId, - final FetchEnd fetchEnd) { + final FetchEnd fetchEnd, final int pos) { /* 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) { @@ -499,15 +556,15 @@ public class TimelineFragment extends SFragment implements public void onResponse(@NonNull Call> call, @NonNull Response> response) { if (response.isSuccessful()) { String linkHeader = response.headers().get("Link"); - onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd); + onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd, pos); } else { - onFetchTimelineFailure(new Exception(response.message()), fetchEnd); + onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos); } } @Override public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - onFetchTimelineFailure((Exception) t, fetchEnd); + onFetchTimelineFailure((Exception) t, fetchEnd, pos); } }; @@ -516,8 +573,11 @@ public class TimelineFragment extends SFragment implements listCall.enqueue(callback); } - public void onFetchTimelineSuccess(List statuses, String linkHeader, - FetchEnd fetchEnd) { + private void onFetchTimelineSuccess(List statuses, String linkHeader, + FetchEnd fetchEnd, int pos) { + // We filled the hole (or reached the end) if the server returned less statuses than we + // we asked for. + boolean fullFetch = statuses.size() >= LOAD_AT_ONCE; filterStatuses(statuses); List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { @@ -527,7 +587,11 @@ public class TimelineFragment extends SFragment implements if (previous != null) { uptoId = previous.uri.getQueryParameter("since_id"); } - updateStatuses(statuses, null, uptoId); + updateStatuses(statuses, null, uptoId, fullFetch); + break; + } + case MIDDLE: { + replacePlaceholderWithStatuses(statuses, fullFetch, pos); break; } case BOTTOM: { @@ -547,7 +611,7 @@ public class TimelineFragment extends SFragment implements if (previous != null) { uptoId = previous.uri.getQueryParameter("since_id"); } - updateStatuses(statuses, fromId, uptoId); + updateStatuses(statuses, fromId, uptoId, fullFetch); } break; } @@ -561,8 +625,15 @@ public class TimelineFragment extends SFragment implements swipeRefreshLayout.setRefreshing(false); } - public void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd) { + private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) { swipeRefreshLayout.setRefreshing(false); + + if (fetchEnd == FetchEnd.MIDDLE && !statuses.get(position).isRight()) { + StatusViewData newViewData = new StatusViewData.Placeholder(false); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, true); + } + Log.e(TAG, "Fetch Failure: " + exception.getMessage()); fulfillAnyQueuedFetches(fetchEnd); } @@ -588,7 +659,7 @@ public class TimelineFragment extends SFragment implements } } - protected void filterStatuses(List statuses) { + private void filterStatuses(List statuses) { Iterator it = statuses.iterator(); while (it.hasNext()) { Status status = it.next(); @@ -600,7 +671,7 @@ public class TimelineFragment extends SFragment implements } private void updateStatuses(List newStatuses, @Nullable String fromId, - @Nullable String toId) { + @Nullable String toId, boolean fullFetch) { if (ListUtils.isEmpty(newStatuses)) { return; } @@ -610,20 +681,26 @@ public class TimelineFragment extends SFragment implements if (toId != null) { topId = toId; } + + List> liftedNew = listStatusList(newStatuses); + if (statuses.isEmpty()) { - // This construction removes duplicates while preserving order. - statuses.addAll(new LinkedHashSet<>(newStatuses)); + statuses.addAll(liftedNew); } else { - Status lastOfNew = newStatuses.get(newStatuses.size() - 1); + Either lastOfNew = liftedNew.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)); + int newIndex = liftedNew.indexOf(statuses.get(0)); if (newIndex == -1) { - statuses.addAll(0, newStatuses); + if (index == -1 && fullFetch) { + liftedNew.add(Either.left(Placeholder.getInstance())); + } + statuses.addAll(0, liftedNew); } else { - statuses.addAll(0, newStatuses.subList(0, newIndex)); + statuses.addAll(0, liftedNew.subList(0, newIndex)); } } adapter.update(statuses.getPairedCopy()); @@ -634,15 +711,17 @@ public class TimelineFragment extends SFragment implements return; } int end = statuses.size(); - Status last = statuses.get(end - 1); + Status last = statuses.get(end - 1).getAsRightOrNull(); + // I was about to replace findStatus with indexOf but it is incorrect to compare value + // types by ID anyway and we should change equals() for Status, I think, so this makes sense if (last != null && !findStatus(newStatuses, last.id)) { - statuses.addAll(newStatuses); + statuses.addAll(listStatusList(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 == %d newViewDatas.size == %d, statuses.size == %d", newStatuses.size(), newViewDatas.size(), statuses.size()); throw new AssertionError(error); } @@ -653,6 +732,28 @@ public class TimelineFragment extends SFragment implements } } + private void replacePlaceholderWithStatuses(List newStatuses, boolean fullFetch, int pos) { + Status status = statuses.get(pos).getAsRightOrNull(); + if (status == null) { + statuses.remove(pos); + } + + if (ListUtils.isEmpty(newStatuses)) { + adapter.update(statuses.getPairedCopy()); + return; + } + + List> liftedNew = listStatusList(newStatuses); + + if (fullFetch) { + liftedNew.add(Either.left(Placeholder.getInstance())); + } + + statuses.addAll(pos, liftedNew); + adapter.update(statuses.getPairedCopy()); + + } + private static boolean findStatus(List statuses, String id) { for (Status status : statuses) { if (status.id.equals(id)) { @@ -661,4 +762,39 @@ public class TimelineFragment extends SFragment implements } return false; } + + private final Function> statusLifter = + new Function>() { + @Override + public Either apply(Status input) { + return Either.right(input); + } + }; + + private @Nullable + Pair + findStatusAndPosition(int position, Status status) { + StatusViewData.Concrete statusToUpdate; + int positionToUpdate; + StatusViewData someOldViewData = statuses.getPairedItem(position); + + // Unlikely, but data could change between the request and response + if ((someOldViewData instanceof StatusViewData.Placeholder) || + !((StatusViewData.Concrete) someOldViewData).getId().equals(status.id)) { + // try to find the status we need to update + int foundPos = statuses.indexOf(Either.right(status)); + if (foundPos < 0) return null; // okay, it's hopeless, give up + statusToUpdate = ((StatusViewData.Concrete) + statuses.getPairedItem(foundPos)); + positionToUpdate = position; + } else { + statusToUpdate = (StatusViewData.Concrete) someOldViewData; + positionToUpdate = position; + } + return new Pair<>(statusToUpdate, positionToUpdate); + } + + private List> listStatusList(List list) { + return CollectionUtil.map(list, statusLifter); + } } 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 f186bb9a..aedf6dd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -69,7 +69,7 @@ public class ViewThreadFragment extends SFragment implements private int statusIndex = 0; - private final PairedList statuses = + private final PairedList statuses = new PairedList<>(ViewDataUtils.statusMapper()); public static ViewThreadFragment newInstance(String id) { @@ -83,7 +83,7 @@ public class ViewThreadFragment extends SFragment implements @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { + @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false); Context context = getContext(); @@ -227,22 +227,29 @@ public class ViewThreadFragment extends SFragment implements @Override public void onExpandedChange(boolean expanded, int position) { - StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) - .setIsExpanded(expanded) - .createStatusViewData(); + StatusViewData.Concrete 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(); + StatusViewData.Concrete newViewData = + new StatusViewData.Builder(statuses.getPairedItem(position)) + .setIsShowingSensitiveContent(isShowing) + .createStatusViewData(); statuses.setPairedItem(position, newViewData); adapter.setItem(position, newViewData, false); } + @Override + public void onLoadMore(int pos) { + + } + @Override public void onViewTag(String tag) { super.viewTag(tag); @@ -255,7 +262,7 @@ public class ViewThreadFragment extends SFragment implements @Override public void removeItem(int position) { - if(position == statusIndex) { + if (position == statusIndex) { //the status got removed, close the activity getActivity().finish(); } @@ -278,7 +285,7 @@ public class ViewThreadFragment extends SFragment implements } } statusIndex = statuses.indexOf(status); - if(statusIndex == -1) { + if (statusIndex == -1) { //the status got removed, close the activity getActivity().finish(); return; @@ -379,8 +386,8 @@ public class ViewThreadFragment extends SFragment implements int i = statusIndex; statuses.add(i, status); adapter.setDetailedStatusPosition(i); - StatusViewData viewData = statuses.getPairedItem(i); - if(viewData.getCard() == null && card != null) { + StatusViewData.Concrete viewData = statuses.getPairedItem(i); + if (viewData.getCard() == null && card != null) { viewData = new StatusViewData.Builder(viewData) .setCard(card) .createStatusViewData(); @@ -405,7 +412,7 @@ public class ViewThreadFragment extends SFragment implements statusIndex = ancestors.size(); adapter.setDetailedStatusPosition(statusIndex); statuses.addAll(0, ancestors); - List ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); + List ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) { String error = String.format(Locale.getDefault(), "Incorrectly got statusViewData sublist." + @@ -420,8 +427,8 @@ public class ViewThreadFragment extends SFragment implements // 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); - StatusViewData viewData = statuses.getPairedItem(statusIndex); - if(viewData.getCard() == null && card != null) { + StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex); + if (viewData.getCard() == null && card != null) { viewData = new StatusViewData.Builder(viewData) .setCard(card) .createStatusViewData(); @@ -431,9 +438,9 @@ public class ViewThreadFragment extends SFragment implements // Insert newly fetched descendants statuses.addAll(descendants); - List descendantsViewData; - descendantsViewData = statuses.getPairedCopy() - .subList(statuses.size() - descendants.size(), statuses.size()); + 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." + @@ -447,16 +454,14 @@ public class ViewThreadFragment extends SFragment implements private void showCard(Card card) { this.card = card; - if(statuses.size() != 0) { - StatusViewData oldViewData = statuses.getPairedItem(statusIndex); - if(oldViewData != null) { - StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(statusIndex)) - .setCard(card) - .createStatusViewData(); - - statuses.setPairedItem(statusIndex, newViewData); - adapter.setItem(statusIndex, newViewData, true); - } + if (statuses.size() != 0) { + StatusViewData.Concrete newViewData = + new StatusViewData.Builder(statuses.getPairedItem(statusIndex)) + .setCard(card) + .createStatusViewData(); + + statuses.setPairedItem(statusIndex, newViewData); + adapter.setItem(statusIndex, newViewData, true); } } 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 47b27d52..a8ff8db1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -29,4 +29,5 @@ public interface StatusActionListener extends LinkListener { void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); + void onLoadMore(int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CollectionUtil.java b/app/src/main/java/com/keylesspalace/tusky/util/CollectionUtil.java new file mode 100644 index 00000000..21d4bb08 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CollectionUtil.java @@ -0,0 +1,38 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.util; + +import android.arch.core.util.Function; + +import java.util.ArrayList; +import java.util.List; + +/** + * Created by charlag on 05/11/17. + */ + +public final class CollectionUtil { + private CollectionUtil() { + throw new AssertionError(); + } + + public static List map(List list, Function mapper) { + final List newList = new ArrayList<>(list.size()); + for (E el : list) { + newList.add(mapper.apply(el)); + } + return newList; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.java b/app/src/main/java/com/keylesspalace/tusky/util/Either.java new file mode 100644 index 00000000..3f134f75 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.java @@ -0,0 +1,125 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.util; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * Created by charlag on 05/11/17. + * + * Class to represent sum type/tagged union/variant/ADT e.t.c. + * It is either Left or Right. + */ +public final class Either { + + /** + * Constructs Left instance of either + * @param left Object to be considered Left + * @param Left type + * @param Right type + * @return new instance of Either which contains left. + */ + public static Either left(L left) { + return new Either<>(left, false); + } + + /** + * Constructs Right instance of either + * @param right Object to be considered Right + * @param Left type + * @param Right type + * @return new instance of Either which contains right. + */ + public static Either right(R right) { + return new Either<>(right, true); + } + + private final Object value; + // we need it because of the types erasure + private boolean isRight; + + private Either(Object value, boolean isRight) { + this.value = value; + this.isRight = isRight; + } + + public boolean isRight() { + return isRight; + } + + /** + * Try to get contained object as a Left or throw an exception. + * @throws AssertionError If contained value is Right + * @return contained value as Right + */ + public @NonNull L getAsLeft() { + if (isRight) { + throw new AssertionError("Tried to get the Either as Left while it is Right"); + } + //noinspection unchecked + return (L) value; + } + + /** + * Try to get contained object as a Right or throw an exception. + * @throws AssertionError If contained value is Left + * @return contained value as Right + */ + public @NonNull R getAsRight() { + if (!isRight) { + throw new AssertionError("Tried to get the Either as Right while it is Left"); + } + //noinspection unchecked + return (R) value; + } + + /** + * Same as {@link #getAsLeft()} but returns {@code null} is the value if Right instead of + * throwing an exception. + * @return contained value as Left or null + */ + public @Nullable L getAsLeftOrNull() { + if (isRight) { + return null; + } + //noinspection unchecked + return (L) value; + } + + /** + * Same as {@link #getAsRightOrNull()} but returns {@code null} is the value if Left instead of + * throwing an exception. + * @return contained value as Right or null + */ + public @Nullable R getAsRightOrNull() { + if (!isRight) { + return null; + } + //noinspection unchecked + return (R) value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (!(obj instanceof Either)) return false; + Either that = (Either) obj; + return this.isRight == that.isRight && + (this.value == that.value || + this.value != null && this.value.equals(that.value)); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index d344598b..502d558c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -1,3 +1,18 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + package com.keylesspalace.tusky.util; import android.arch.core.util.Function; @@ -17,7 +32,7 @@ import java.util.List; public final class ViewDataUtils { @Nullable - public static StatusViewData statusToViewData(@Nullable Status status) { + public static StatusViewData.Concrete statusToViewData(@Nullable Status status) { if (status == null) return null; Status visibleStatus = status.reblog == null ? status : status.reblog; return new StatusViewData.Builder().setId(status.id) @@ -55,12 +70,12 @@ public final class ViewDataUtils { return viewDatas; } - public static Function statusMapper() { + public static Function statusMapper() { return statusMapper; } - public static NotificationViewData notificationToViewData(Notification notification) { - return new NotificationViewData(notification.type, notification.id, notification.account, + public static NotificationViewData.Concrete notificationToViewData(Notification notification) { + return new NotificationViewData.Concrete(notification.type, notification.id, notification.account, statusToViewData(notification.status)); } @@ -73,10 +88,10 @@ public final class ViewDataUtils { return viewDatas; } - private static final Function statusMapper = - new Function() { + private static final Function statusMapper = + new Function() { @Override - public StatusViewData apply(Status input) { + public StatusViewData.Concrete apply(Status input) { return ViewDataUtils.statusToViewData(input); } }; diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.java b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.java index 4159c587..c90bf5f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.java +++ b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.java @@ -49,16 +49,16 @@ public class ConversationLineItemDecoration extends RecyclerView.ItemDecoration int position = parent.getChildAdapterPosition(child); ThreadAdapter adapter = (ThreadAdapter) parent.getAdapter(); - StatusViewData current = adapter.getItem(position); + StatusViewData.Concrete current = adapter.getItem(position); int dividerTop, dividerBottom; if (current != null) { - StatusViewData above = adapter.getItem(position - 1); + StatusViewData.Concrete above = adapter.getItem(position - 1); if (above != null && above.getId().equals(current.getInReplyToId())) { dividerTop = child.getTop(); } else { dividerTop = child.getTop() + avatarMargin; } - StatusViewData below = adapter.getItem(position + 1); + StatusViewData.Concrete below = adapter.getItem(position + 1); if (below != null && current.getId().equals(below.getInReplyToId())) { dividerBottom = child.getBottom(); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index beec0208..d0345017 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -1,3 +1,18 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + package com.keylesspalace.tusky.viewdata; import com.keylesspalace.tusky.entity.Account; @@ -5,35 +20,59 @@ import com.keylesspalace.tusky.entity.Notification; /** * Created by charlag on 12/07/2017. + * + * Class to represent data required to display either a notification or a placeholder. + * It is either a {@link Placeholder} or a {@link Concrete}. + * It is modelled this way because close relationship between placeholder and concrete notification + * is fine in this case. Placeholder case is not modelled as a type of notification because + * invariants would be violated and because it would model domain incorrectly. It is prefereable to + * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and + * more native. */ - -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 abstract class NotificationViewData { + private NotificationViewData() { } - public Notification.Type getType() { - return type; - } + public static final class Concrete extends NotificationViewData { + private final Notification.Type type; + private final String id; + private final Account account; + private final StatusViewData.Concrete statusViewData; - public String getId() { - return id; - } + public Concrete(Notification.Type type, String id, Account account, + StatusViewData.Concrete statusViewData) { + this.type = type; + this.id = id; + this.account = account; + this.statusViewData = statusViewData; + } + + public Notification.Type getType() { + return type; + } - public Account getAccount() { - return account; + public String getId() { + return id; + } + + public Account getAccount() { + return account; + } + + public StatusViewData.Concrete getStatusViewData() { + return statusViewData; + } } - public StatusViewData getStatusViewData() { - return statusViewData; + public static final class Placeholder extends NotificationViewData { + private final boolean isLoading; + + public Placeholder(boolean isLoading) { + this.isLoading = isLoading; + } + + public boolean isLoading() { + return isLoading; + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index a7b31159..2d91ac36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -1,3 +1,18 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + package com.keylesspalace.tusky.viewdata; import android.support.annotation.Nullable; @@ -12,179 +27,202 @@ import java.util.List; /** * Created by charlag on 11/07/2017. + * + * Class to represent data required to display either a notification or a placeholder. + * It is either a {@link StatusViewData.Concrete} or a {@link StatusViewData.Placeholder}. */ -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; - private final String reblogsCount; - private final String favouritesCount; - @Nullable - private final String inReplyToId; - // 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; - private final Status.Application application; - private final List emojis; - @Nullable - private final Card card; - - public StatusViewData(String id, Spanned content, 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, String reblogsCount, String favouritesCount, String inReplyToId, - Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, - Status.Application application, List emojis, Card card) { - this.id = id; - this.content = content; - 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.reblogsCount = reblogsCount; - this.favouritesCount = favouritesCount; - this.inReplyToId = inReplyToId; - this.mentions = mentions; - this.senderId = senderId; - this.rebloggingEnabled = rebloggingEnabled; - this.application = application; - this.emojis = emojis; - this.card = card; - } +public abstract class StatusViewData { - public String getId() { - return id; + private StatusViewData() { } - public Spanned getContent() { - return content; - } + public static final class Concrete extends 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; + private final String reblogsCount; + private final String favouritesCount; + @Nullable + private final String inReplyToId; + // 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; + private final Status.Application application; + private final List emojis; + @Nullable + private final Card card; + + public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, + @Nullable String spoilerText, Status.Visibility visibility, Status.MediaAttachment[] attachments, + @Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded, + boolean isShowingSensitiveWarning, String userFullName, String nickname, String avatar, + Date createdAt, String reblogsCount, String favouritesCount, @Nullable String inReplyToId, + @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, + Status.Application application, List emojis, @Nullable Card card) { + this.id = id; + this.content = content; + 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.reblogsCount = reblogsCount; + this.favouritesCount = favouritesCount; + this.inReplyToId = inReplyToId; + this.mentions = mentions; + this.senderId = senderId; + this.rebloggingEnabled = rebloggingEnabled; + this.application = application; + this.emojis = emojis; + this.card = card; + } - public boolean isReblogged() { - return reblogged; - } + public String getId() { + return id; + } - public boolean isFavourited() { - return favourited; - } + public Spanned getContent() { + return content; + } - @Nullable - public String getSpoilerText() { - return spoilerText; - } + public boolean isReblogged() { + return reblogged; + } - public Status.Visibility getVisibility() { - return visibility; - } + public boolean isFavourited() { + return favourited; + } - public Status.MediaAttachment[] getAttachments() { - return attachments; - } + @Nullable + public String getSpoilerText() { + return spoilerText; + } - @Nullable - public String getRebloggedByUsername() { - return rebloggedByUsername; - } + public Status.Visibility getVisibility() { + return visibility; + } - public boolean isSensitive() { - return isSensitive; - } + public Status.MediaAttachment[] getAttachments() { + return attachments; + } - public boolean isExpanded() { - return isExpanded; - } + @Nullable + public String getRebloggedByUsername() { + return rebloggedByUsername; + } - public boolean isShowingSensitiveContent() { - return isShowingSensitiveContent; - } + public boolean isSensitive() { + return isSensitive; + } - @Nullable - public String getRebloggedAvatar() { - return rebloggedAvatar; - } + public boolean isExpanded() { + return isExpanded; + } - public String getUserFullName() { - return userFullName; - } + public boolean isShowingSensitiveContent() { + return isShowingSensitiveContent; + } - public String getNickname() { - return nickname; - } + @Nullable + public String getRebloggedAvatar() { + return rebloggedAvatar; + } - public String getAvatar() { - return avatar; - } + public String getUserFullName() { + return userFullName; + } - public Date getCreatedAt() { - return createdAt; - } + public String getNickname() { + return nickname; + } - public String getReblogsCount() { - return reblogsCount; - } + public String getAvatar() { + return avatar; + } - public String getFavouritesCount() { - return favouritesCount; - } + public Date getCreatedAt() { + return createdAt; + } - @Nullable - public String getInReplyToId() { - return inReplyToId; - } + public String getReblogsCount() { + return reblogsCount; + } - public String getSenderId() { - return senderId; - } + public String getFavouritesCount() { + return favouritesCount; + } - public Boolean getRebloggingEnabled() { - return rebloggingEnabled; - } + @Nullable + public String getInReplyToId() { + return inReplyToId; + } - @Nullable - public Status.Mention[] getMentions() { - return mentions; - } + public String getSenderId() { + return senderId; + } - public Status.Application getApplication() { - return application; - } + public Boolean getRebloggingEnabled() { + return rebloggingEnabled; + } + + @Nullable + public Status.Mention[] getMentions() { + return mentions; + } + + public Status.Application getApplication() { + return application; + } + + public List getEmojis() { + return emojis; + } + + @Nullable + public Card getCard() { + return card; + } - public List getEmojis() { - return emojis; } - public Card getCard() { - return card; + public static final class Placeholder extends StatusViewData { + private final boolean isLoading; + + public Placeholder(boolean isLoading) { + this.isLoading = isLoading; + } + + public boolean isLoading() { + return isLoading; + } } public static class Builder { @@ -217,7 +255,7 @@ public final class StatusViewData { public Builder() { } - public Builder(final StatusViewData viewData) { + public Builder(final StatusViewData.Concrete viewData) { id = viewData.id; content = viewData.content; reblogged = viewData.reblogged; @@ -243,7 +281,6 @@ public final class StatusViewData { application = viewData.application; emojis = viewData.getEmojis(); card = viewData.getCard(); - } public Builder setId(String id) { @@ -371,12 +408,15 @@ public final class StatusViewData { return this; } - public StatusViewData createStatusViewData() { + public StatusViewData.Concrete createStatusViewData() { if (this.emojis == null) emojis = Collections.emptyList(); - return new StatusViewData(id, content, reblogged, favourited, spoilerText, visibility, + if (this.createdAt == null) createdAt = new Date(); + + return new StatusViewData.Concrete(id, content, reblogged, favourited, spoilerText, visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, reblogsCount, - favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, emojis, card); + favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, + emojis, card); } } } diff --git a/app/src/main/res/layout/item_status_placeholder.xml b/app/src/main/res/layout/item_status_placeholder.xml new file mode 100644 index 00000000..78ee11f1 --- /dev/null +++ b/app/src/main/res/layout/item_status_placeholder.xml @@ -0,0 +1,8 @@ + +