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..2fad7cb7 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); + } } } @@ -121,6 +127,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter { holder.setupButtons(notificationActionListener, notification.getAccount().id); break; } + case PLACEHOLDER: { + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(!notification.isPlaceholderLoading(), statusListener); + break; + } } } else { FooterViewHolder holder = (FooterViewHolder) viewHolder; @@ -151,6 +162,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case FOLLOW: { return VIEW_TYPE_FOLLOW; } + case PLACEHOLDER: { + return VIEW_TYPE_PLACEHOLDER; + } } } } 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..ef5e1287 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); } 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..2be75fa1 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,26 @@ 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.isPlaceholder()) { + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(!status.isPlaceholderLoading(), statusListener); + } else { + StatusViewHolder holder = (StatusViewHolder) viewHolder; + holder.setupWithStatus(status, statusListener, mediaPreviewEnabled); + } + } else { FooterViewHolder holder = (FooterViewHolder) viewHolder; holder.setState(footerState); @@ -84,7 +96,11 @@ public class TimelineAdapter extends RecyclerView.Adapter { if (position == statuses.size()) { return VIEW_TYPE_FOOTER; } else { - return VIEW_TYPE_STATUS; + if(statuses.get(position).isPlaceholder()) { + return VIEW_TYPE_PLACEHOLDER; + } else { + return VIEW_TYPE_STATUS; + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.java b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.java index 267c483a..6aff1520 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.java +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.java @@ -27,6 +27,7 @@ public class Notification { FAVOURITE, @SerializedName("follow") FOLLOW, + PLACEHOLDER } public Type type; 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..d5d941f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java @@ -27,6 +27,10 @@ import java.util.Date; import java.util.List; public class Status { + /*if placeholder == true, this is not a real status, but a placeholder "load more" + and the id represents the max_id for the request*/ + public boolean placeholder; + public String url; @SerializedName("reblogs_count") @@ -106,19 +110,21 @@ 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; + + if (placeholder != status.placeholder) return false; + 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() { + int result = (placeholder ? 1 : 0); + result = 31 * result + (id != null ? id.hashCode() : 0); + return result; } 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 5c3254c9..e3f11201 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -54,7 +54,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 +64,14 @@ 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 } private SwipeRefreshLayout swipeRefreshLayout; @@ -156,12 +158,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) { @@ -220,7 +220,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onRefresh() { - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP); + sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); } @Override @@ -252,8 +252,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); } }); } @@ -283,8 +282,7 @@ 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); } }); } @@ -321,7 +319,7 @@ public class NotificationsFragment extends SFragment implements .setIsExpanded(expanded) .createStatusViewData(); NotificationViewData notificationViewData = new NotificationViewData(old.getType(), - old.getId(), old.getAccount(), statusViewData); + old.getId(), old.getAccount(), statusViewData, false); notifications.setPairedItem(position, notificationViewData); adapter.updateItemWithNotify(position, notificationViewData, false); } @@ -334,11 +332,29 @@ public class NotificationsFragment extends SFragment implements .setIsShowingSensitiveContent(isShowing) .createStatusViewData(); NotificationViewData notificationViewData = new NotificationViewData(old.getType(), - old.getId(), old.getAccount(), statusViewData); + old.getId(), old.getAccount(), statusViewData, false); 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) { + String fromId = notifications.get(position - 1).id; + String toId = notifications.get(position + 1).id; + sendFetchNotificationsRequest(fromId, toId, FetchEnd.MIDDLE, position); + + NotificationViewData old = notifications.getPairedItem(position); + NotificationViewData notificationViewData = new NotificationViewData(old.getType(), + old.getId(), old.getAccount(), old.getStatusViewData(), 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); @@ -385,7 +401,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 +410,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 +434,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 +442,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 +469,10 @@ public class NotificationsFragment extends SFragment implements update(notifications, null, uptoId); break; } + case MIDDLE: { + insert(notifications, pos); + break; + } case BOTTOM: { HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next"); String fromId = null; @@ -489,8 +509,8 @@ public class NotificationsFragment extends SFragment implements swipeRefreshLayout.setRefreshing(false); } - public void update(@Nullable List newNotifications, @Nullable String fromId, - @Nullable String uptoId) { + private void update(@Nullable List newNotifications, @Nullable String fromId, + @Nullable String uptoId) { if (ListUtils.isEmpty(newNotifications)) { return; } @@ -501,8 +521,7 @@ public class NotificationsFragment extends SFragment implements topId = uptoId; } if (notifications.isEmpty()) { - // This construction removes duplicates while preserving order. - notifications.addAll(new LinkedHashSet<>(newNotifications)); + notifications.addAll(newNotifications); } else { int index = notifications.indexOf(newNotifications.get(newNotifications.size() - 1)); for (int i = 0; i < index; i++) { @@ -510,6 +529,11 @@ public class NotificationsFragment extends SFragment implements } int newIndex = newNotifications.indexOf(notifications.get(0)); if (newIndex == -1) { + if(index == -1 && newNotifications.size() >= LOAD_AT_ONCE) { + Notification placeholder = new Notification(); + placeholder.type = Notification.Type.PLACEHOLDER; + newNotifications.add(placeholder); + } notifications.addAll(0, newNotifications); } else { List sublist = newNotifications.subList(0, newIndex); @@ -519,7 +543,7 @@ public class NotificationsFragment extends SFragment implements 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; } @@ -532,7 +556,7 @@ public class NotificationsFragment extends SFragment implements notifications.addAll(newNotifications); List newViewDatas = notifications.getPairedCopy() .subList(notifications.size() - newNotifications.size(), - notifications.size() - 1); + notifications.size()); adapter.addItems(newViewDatas); } } @@ -546,8 +570,15 @@ public class NotificationsFragment extends SFragment implements return false; } - private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd) { + private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) { swipeRefreshLayout.setRefreshing(false); + if(fetchEnd == FetchEnd.MIDDLE && notifications.getPairedItem(position).getType() == Notification.Type.PLACEHOLDER) { + NotificationViewData old = notifications.getPairedItem(position); + NotificationViewData notificationViewData = new NotificationViewData(old.getType(), + old.getId(), old.getAccount(), old.getStatusViewData(), false); + notifications.setPairedItem(position, notificationViewData); + adapter.updateItemWithNotify(position, notificationViewData, true); + } Log.e(TAG, "Fetch failure: " + exception.getMessage()); fulfillAnyQueuedFetches(fetchEnd); } @@ -573,9 +604,29 @@ public class NotificationsFragment extends SFragment implements } } + private void insert(List newNotifications, int pos) { + + notifications.remove(pos); + + if (ListUtils.isEmpty(newNotifications)) { + adapter.update(notifications.getPairedCopy()); + return; + } + + if(newNotifications.size() >= LOAD_AT_ONCE) { + Notification placeholder = new Notification(); + placeholder.type = Notification.Type.PLACEHOLDER; + newNotifications.add(placeholder); + } + + notifications.addAll(pos, newNotifications); + adapter.update(notifications.getPairedCopy()); + + } + 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 d5d6da60..a2f4acce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -20,6 +20,7 @@ import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.TabLayout; @@ -51,7 +52,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; @@ -63,10 +63,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, @@ -79,6 +81,7 @@ public class TimelineFragment extends SFragment implements private enum FetchEnd { TOP, BOTTOM, + MIDDLE } private SwipeRefreshLayout swipeRefreshLayout; @@ -178,12 +181,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) { @@ -218,7 +219,7 @@ public class TimelineFragment extends SFragment implements } else if (!composeButton.isShown()) { composeButton.show(); } - } + } } @Override @@ -250,7 +251,7 @@ public class TimelineFragment extends SFragment implements @Override public void onRefresh() { - sendFetchTimelineRequest(null, topId, FetchEnd.TOP); + sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1); } @Override @@ -263,7 +264,7 @@ public class TimelineFragment extends SFragment implements final Status status = statuses.get(position); super.reblogWithCallback(status, reblog, new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful()) { status.reblogged = reblog; @@ -280,9 +281,8 @@ public class TimelineFragment extends SFragment implements } @Override - public void onFailure(Call call, Throwable t) { - Log.d(TAG, "Failed to reblog status " + status.id); - t.printStackTrace(); + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + Log.d(TAG, "Failed to reblog status " + status.id, t); } }); } @@ -293,7 +293,7 @@ public class TimelineFragment extends SFragment implements super.favouriteWithCallback(status, favourite, new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful()) { status.favourited = favourite; @@ -310,9 +310,8 @@ public class TimelineFragment extends SFragment implements } @Override - public void onFailure(Call call, Throwable t) { - Log.d(TAG, "Failed to favourite status " + status.id); - t.printStackTrace(); + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + Log.d(TAG, "Failed to favourite status " + status.id, t); } }); } @@ -343,6 +342,24 @@ public class TimelineFragment extends SFragment implements adapter.changeItem(position, newViewData, false); } + @Override + public void onLoadMore(int position) { + //check bounds before accessing list, + if (statuses.size() >= position && position > 0) { + String fromId = statuses.get(position - 1).id; + String toId = statuses.get(position + 1).id; + sendFetchTimelineRequest(fromId, toId, FetchEnd.MIDDLE, position); + + StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setPlaceholderLoading(true).createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, false); + + } else { + Log.d(TAG, "error loading more"); + } + } + @Override public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type, View view) { @@ -427,12 +444,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() { @@ -456,20 +473,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); + 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) { @@ -495,18 +512,18 @@ public class TimelineFragment extends SFragment implements Callback> callback = new Callback>() { @Override - public void onResponse(Call> call, Response> response) { + 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(Call> call, Throwable t) { - onFetchTimelineFailure((Exception) t, fetchEnd); + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + onFetchTimelineFailure((Exception) t, fetchEnd, pos); } }; @@ -515,8 +532,9 @@ 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) { + boolean fullFetch = statuses.size() >= LOAD_AT_ONCE; filterStatuses(statuses); List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { @@ -526,7 +544,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: { + insertStatuses(statuses,fullFetch, pos); break; } case BOTTOM: { @@ -546,7 +568,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; } @@ -560,8 +582,17 @@ 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.getPairedItem(position).isPlaceholder()) { + + StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) + .setPlaceholderLoading(false).createStatusViewData(); + statuses.setPairedItem(position, newViewData); + adapter.changeItem(position, newViewData, true); + } + Log.e(TAG, "Fetch Failure: " + exception.getMessage()); fulfillAnyQueuedFetches(fetchEnd); } @@ -587,7 +618,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(); @@ -599,7 +630,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,16 +641,21 @@ public class TimelineFragment extends SFragment implements topId = toId; } if (statuses.isEmpty()) { - // This construction removes duplicates while preserving order. - statuses.addAll(new LinkedHashSet<>(newStatuses)); + statuses.addAll(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) { + if(index == -1 && fullFetch) { + Status placeholder = new Status(); + placeholder.placeholder = true; + newStatuses.add(placeholder); + } statuses.addAll(0, newStatuses); } else { statuses.addAll(0, newStatuses.subList(0, newIndex)); @@ -641,7 +677,7 @@ public class TimelineFragment extends SFragment implements 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); } @@ -652,6 +688,28 @@ public class TimelineFragment extends SFragment implements } } + private void insertStatuses(List newStatuses, boolean fullFetch, int pos) { + + if(statuses.get(pos).placeholder) { + statuses.remove(pos); + } + + if (ListUtils.isEmpty(newStatuses)) { + adapter.update(statuses.getPairedCopy()); + return; + } + + if(fullFetch) { + Status placeholder = new Status(); + placeholder.placeholder = true; + newStatuses.add(placeholder); + } + + statuses.addAll(pos, newStatuses); + adapter.update(statuses.getPairedCopy()); + + } + private static boolean findStatus(List statuses, String id) { for (Status status : statuses) { if (status.id.equals(id)) { 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..ccef1d57 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -243,6 +243,11 @@ public class ViewThreadFragment extends SFragment implements adapter.setItem(position, newViewData, false); } + @Override + public void onLoadMore(int pos) { + + } + @Override public void onViewTag(String tag) { super.viewTag(tag); diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 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/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index d344598b..a25340d3 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; @@ -19,6 +34,11 @@ public final class ViewDataUtils { @Nullable public static StatusViewData statusToViewData(@Nullable Status status) { if (status == null) return null; + if(status.placeholder) { + return new StatusViewData.Builder().setId(status.id) + .setPlaceholder(true) + .createStatusViewData(); + } Status visibleStatus = status.reblog == null ? status : status.reblog; return new StatusViewData.Builder().setId(status.id) .setAttachments(visibleStatus.attachments) @@ -61,7 +81,7 @@ public final class ViewDataUtils { public static NotificationViewData notificationToViewData(Notification notification) { return new NotificationViewData(notification.type, notification.id, notification.account, - statusToViewData(notification.status)); + statusToViewData(notification.status), false); } public static List notificationListToViewDataList( 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..597d390d 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; @@ -12,13 +27,15 @@ public final class NotificationViewData { private final String id; private final Account account; private final StatusViewData statusViewData; + private final boolean placeholderLoading; public NotificationViewData(Notification.Type type, String id, Account account, - StatusViewData statusViewData) { + StatusViewData statusViewData, boolean placeholderLoading) { this.type = type; this.id = id; this.account = account; this.statusViewData = statusViewData; + this.placeholderLoading = placeholderLoading; } public Notification.Type getType() { @@ -36,4 +53,8 @@ public final class NotificationViewData { public StatusViewData getStatusViewData() { return statusViewData; } + + public boolean isPlaceholderLoading() { + return placeholderLoading; + } } 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..ed4a415c 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; @@ -48,13 +63,18 @@ public final class StatusViewData { @Nullable private final Card card; + private final boolean placeholder; + + private final boolean placeholderLoading; + 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, + @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, String inReplyToId, - Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, - Status.Application application, List emojis, Card card) { + 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, + boolean placeholder, boolean placeholderLoading) { this.id = id; this.content = content; this.reblogged = reblogged; @@ -80,6 +100,8 @@ public final class StatusViewData { this.application = application; this.emojis = emojis; this.card = card; + this.placeholder = placeholder; + this.placeholderLoading = placeholderLoading; } public String getId() { @@ -183,10 +205,19 @@ public final class StatusViewData { return emojis; } + @Nullable public Card getCard() { return card; } + public boolean isPlaceholder() { + return placeholder; + } + + public boolean isPlaceholderLoading() { + return placeholderLoading; + } + public static class Builder { private String id; private Spanned content; @@ -213,6 +244,8 @@ public final class StatusViewData { private Status.Application application; private List emojis; private Card card; + private boolean placeholder; + private boolean placeholderLoading; public Builder() { } @@ -243,6 +276,8 @@ public final class StatusViewData { application = viewData.application; emojis = viewData.getEmojis(); card = viewData.getCard(); + placeholder = viewData.isPlaceholder(); + placeholderLoading = viewData.isPlaceholderLoading(); } @@ -371,12 +406,25 @@ public final class StatusViewData { return this; } + public Builder setPlaceholder(boolean placeholder) { + this.placeholder = placeholder; + return this; + } + + public Builder setPlaceholderLoading(boolean placeholderLoading) { + this.placeholderLoading = placeholderLoading; + return this; + } + public StatusViewData createStatusViewData() { if (this.emojis == null) emojis = Collections.emptyList(); + if (this.createdAt == null) createdAt = new Date(); + return new StatusViewData(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, placeholder, placeholderLoading); } } } 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 @@ + +