Merge pull request #435 from Vavassor/timeline-improvement

"load more" Placeholder
main
Konrad Pozniak 7 years ago committed by GitHub
commit 55f7c98450
  1. 66
      app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
  2. 49
      app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java
  3. 5
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
  4. 2
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java
  5. 2
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
  6. 17
      app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java
  7. 24
      app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java
  8. 18
      app/src/main/java/com/keylesspalace/tusky/entity/Status.java
  9. 231
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  10. 13
      app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java
  11. 256
      app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java
  12. 61
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java
  13. 1
      app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java
  14. 38
      app/src/main/java/com/keylesspalace/tusky/util/CollectionUtil.java
  15. 125
      app/src/main/java/com/keylesspalace/tusky/util/Either.java
  16. 29
      app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java
  17. 6
      app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.java
  18. 85
      app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java
  19. 338
      app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java
  20. 8
      app/src/main/res/layout/item_status_placeholder.xml
  21. 1
      app/src/main/res/values/strings.xml

@ -47,6 +47,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private static final int VIEW_TYPE_FOOTER = 1; private static final int VIEW_TYPE_FOOTER = 1;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2; private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2;
private static final int VIEW_TYPE_FOLLOW = 3; private static final int VIEW_TYPE_FOLLOW = 3;
private static final int VIEW_TYPE_PLACEHOLDER = 4;
private List<NotificationViewData> notifications; private List<NotificationViewData> notifications;
private StatusActionListener statusListener; private StatusActionListener statusListener;
@ -88,6 +89,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
.inflate(R.layout.item_follow, parent, false); .inflate(R.layout.item_follow, parent, false);
return new FollowViewHolder(view); 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) { public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
if (position < notifications.size()) { if (position < notifications.size()) {
NotificationViewData notification = notifications.get(position); 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) { switch (type) {
case MENTION: { case MENTION: {
StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData status = notification.getStatusViewData(); StatusViewData.Concrete status = concreteNotificaton.getStatusViewData();
holder.setupWithStatus(status, holder.setupWithStatus(status,
statusListener, mediaPreviewEnabled); statusListener, mediaPreviewEnabled);
break; break;
@ -107,18 +121,18 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case FAVOURITE: case FAVOURITE:
case REBLOG: { case REBLOG: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
holder.setMessage(type, notification.getAccount().getDisplayName(), holder.setMessage(type, concreteNotificaton.getAccount().getDisplayName(),
notification.getStatusViewData()); concreteNotificaton.getStatusViewData());
holder.setupButtons(notificationActionListener, notification.getAccount().id); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().id);
holder.setAvatars(notification.getStatusViewData().getAvatar(), holder.setAvatars(concreteNotificaton.getStatusViewData().getAvatar(),
notification.getAccount().avatar); concreteNotificaton.getAccount().avatar);
break; break;
} }
case FOLLOW: { case FOLLOW: {
FollowViewHolder holder = (FollowViewHolder) viewHolder; FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(notification.getAccount().getDisplayName(), holder.setMessage(concreteNotificaton.getAccount().getDisplayName(),
notification.getAccount().username, notification.getAccount().avatar); concreteNotificaton.getAccount().username, concreteNotificaton.getAccount().avatar);
holder.setupButtons(notificationActionListener, notification.getAccount().id); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().id);
break; break;
} }
} }
@ -139,18 +153,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
return VIEW_TYPE_FOOTER; return VIEW_TYPE_FOOTER;
} else { } else {
NotificationViewData notification = notifications.get(position); NotificationViewData notification = notifications.get(position);
switch (notification.getType()) { if (notification instanceof NotificationViewData.Concrete) {
default: NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
case MENTION: { switch (concrete.getType()) {
return VIEW_TYPE_MENTION; default:
} case MENTION: {
case FAVOURITE: return VIEW_TYPE_MENTION;
case REBLOG: { }
return VIEW_TYPE_STATUS_NOTIFICATION; case FAVOURITE:
} case REBLOG: {
case FOLLOW: { return VIEW_TYPE_STATUS_NOTIFICATION;
return VIEW_TYPE_FOLLOW; }
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); 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(); Context context = message.getContext();
String format; String format;
switch (type) { switch (type) {

@ -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 <http://www.gnu.org/licenses>. */
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());
}
});
}
}
}

@ -271,6 +271,9 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder {
sensitiveMediaShow.setOnClickListener(new View.OnClickListener() { sensitiveMediaShow.setOnClickListener(new View.OnClickListener() {
@Override @Override
public void onClick(View v) { public void onClick(View v) {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
listener.onContentHiddenChange(false, getAdapterPosition());
}
v.setVisibility(View.GONE); v.setVisibility(View.GONE);
sensitiveMediaWarning.setVisibility(View.VISIBLE); sensitiveMediaWarning.setVisibility(View.VISIBLE);
} }
@ -470,7 +473,7 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder {
container.setOnClickListener(viewThreadListener); container.setOnClickListener(viewThreadListener);
} }
void setupWithStatus(StatusViewData status, final StatusActionListener listener, void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) { boolean mediaPreviewEnabled) {
setDisplayName(status.getUserFullName()); setDisplayName(status.getUserFullName());
setUsername(status.getNickname()); setUsername(status.getNickname());

@ -85,7 +85,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
void setupWithStatus(final StatusViewData status, final StatusActionListener listener, void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) { boolean mediaPreviewEnabled) {
super.setupWithStatus(status, listener, mediaPreviewEnabled); super.setupWithStatus(status, listener, mediaPreviewEnabled);
reblogs.setText(status.getReblogsCount()); reblogs.setText(status.getReblogsCount());

@ -67,7 +67,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
void setupWithStatus(StatusViewData status, final StatusActionListener listener, void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) { boolean mediaPreviewEnabled) {
super.setupWithStatus(status, listener, mediaPreviewEnabled); super.setupWithStatus(status, listener, mediaPreviewEnabled);

@ -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 = 0;
private static final int VIEW_TYPE_STATUS_DETAILED = 1; private static final int VIEW_TYPE_STATUS_DETAILED = 1;
private List<StatusViewData> statuses; private List<StatusViewData.Concrete> statuses;
private StatusActionListener statusActionListener; private StatusActionListener statusActionListener;
private boolean mediaPreviewEnabled; private boolean mediaPreviewEnabled;
private int detailedStatusPosition; private int detailedStatusPosition;
@ -66,13 +66,12 @@ public class ThreadAdapter extends RecyclerView.Adapter {
@Override @Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
StatusViewData.Concrete status = statuses.get(position);
if (position == detailedStatusPosition) { if (position == detailedStatusPosition) {
StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder; StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder;
StatusViewData status = statuses.get(position);
holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled); holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled);
} else { } else {
StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData status = statuses.get(position);
holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled); holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled);
} }
} }
@ -91,13 +90,13 @@ public class ThreadAdapter extends RecyclerView.Adapter {
return statuses.size(); return statuses.size();
} }
public void setStatuses(List<StatusViewData> statuses) { public void setStatuses(List<StatusViewData.Concrete> statuses) {
this.statuses.clear(); this.statuses.clear();
this.statuses.addAll(statuses); this.statuses.addAll(statuses);
notifyDataSetChanged(); notifyDataSetChanged();
} }
public void addItem(int position, StatusViewData statusViewData) { public void addItem(int position, StatusViewData.Concrete statusViewData) {
statuses.add(position, statusViewData); statuses.add(position, statusViewData);
notifyItemInserted(position); notifyItemInserted(position);
} }
@ -109,12 +108,12 @@ public class ThreadAdapter extends RecyclerView.Adapter {
notifyItemRangeRemoved(0, oldSize); notifyItemRangeRemoved(0, oldSize);
} }
public void addAll(int position, List<StatusViewData> statuses) { public void addAll(int position, List<StatusViewData.Concrete> statuses) {
this.statuses.addAll(position, statuses); this.statuses.addAll(position, statuses);
notifyItemRangeInserted(position, statuses.size()); notifyItemRangeInserted(position, statuses.size());
} }
public void addAll(List<StatusViewData> statuses) { public void addAll(List<StatusViewData.Concrete> statuses) {
int end = statuses.size(); int end = statuses.size();
this.statuses.addAll(statuses); this.statuses.addAll(statuses);
notifyItemRangeInserted(end, statuses.size()); notifyItemRangeInserted(end, statuses.size());
@ -126,7 +125,7 @@ public class ThreadAdapter extends RecyclerView.Adapter {
notifyDataSetChanged(); notifyDataSetChanged();
} }
public void setItem(int position, StatusViewData status, boolean notifyAdapter) { public void setItem(int position, StatusViewData.Concrete status, boolean notifyAdapter) {
statuses.set(position, status); statuses.set(position, status);
if (notifyAdapter) { if (notifyAdapter) {
notifyItemChanged(position); notifyItemChanged(position);
@ -134,7 +133,7 @@ public class ThreadAdapter extends RecyclerView.Adapter {
} }
@Nullable @Nullable
public StatusViewData getItem(int position) { public StatusViewData.Concrete getItem(int position) {
if (position != RecyclerView.NO_POSITION && position >= 0 && position < statuses.size()) { if (position != RecyclerView.NO_POSITION && position >= 0 && position < statuses.size()) {
return statuses.get(position); return statuses.get(position);
} else { } else {

@ -31,6 +31,7 @@ import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter { public class TimelineAdapter extends RecyclerView.Adapter {
private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_FOOTER = 1; private static final int VIEW_TYPE_FOOTER = 1;
private static final int VIEW_TYPE_PLACEHOLDER = 2;
private List<StatusViewData> statuses; private List<StatusViewData> statuses;
private StatusActionListener statusListener; private StatusActionListener statusListener;
@ -59,15 +60,28 @@ public class TimelineAdapter extends RecyclerView.Adapter {
.inflate(R.layout.item_footer, viewGroup, false); .inflate(R.layout.item_footer, viewGroup, false);
return new FooterViewHolder(view); 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 @Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
if (position < statuses.size()) { if (position < statuses.size()) {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData status = statuses.get(position); 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 { } else {
FooterViewHolder holder = (FooterViewHolder) viewHolder; FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState); holder.setState(footerState);
@ -84,7 +98,11 @@ public class TimelineAdapter extends RecyclerView.Adapter {
if (position == statuses.size()) { if (position == statuses.size()) {
return VIEW_TYPE_FOOTER; return VIEW_TYPE_FOOTER;
} else { } else {
return VIEW_TYPE_STATUS; if (statuses.get(position) instanceof StatusViewData.Placeholder) {
return VIEW_TYPE_PLACEHOLDER;
} else {
return VIEW_TYPE_STATUS;
}
} }
} }

@ -106,19 +106,17 @@ public class Status {
public static final int MAX_MEDIA_ATTACHMENTS = 4; public static final int MAX_MEDIA_ATTACHMENTS = 4;
@Override @Override
public int hashCode() { public boolean equals(Object o) {
return id.hashCode(); 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 @Override
public boolean equals(Object other) { public int hashCode() {
if (this.id == null) { return id != null ? id.hashCode() : 0;
return this == other;
} else if (!(other instanceof Status)) {
return false;
}
Status status = (Status) other;
return status.id.equals(this.id);
} }
public static class MediaAttachment { public static class MediaAttachment {

@ -44,6 +44,8 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.receiver.TimelineReceiver; 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.HttpHeaderLink;
import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.PairedList;
@ -54,7 +56,6 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import retrofit2.Call; import retrofit2.Call;
@ -65,11 +66,29 @@ public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, SwipeRefreshLayout.OnRefreshListener, StatusActionListener,
NotificationsAdapter.NotificationActionListener, NotificationsAdapter.NotificationActionListener,
SharedPreferences.OnSharedPreferenceChangeListener { 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 { private enum FetchEnd {
TOP, 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; private SwipeRefreshLayout swipeRefreshLayout;
@ -87,11 +106,17 @@ public class NotificationsFragment extends SFragment implements
private String bottomId; private String bottomId;
private String topId; private String topId;
private final PairedList<Notification, NotificationViewData> notifications // Each element is either a Notification for loading data or a Placeholder
= new PairedList<>(new Function<Notification, NotificationViewData>() { private final PairedList<Either<Placeholder, Notification>, NotificationViewData> notifications
= new PairedList<>(new Function<Either<Placeholder, Notification>, NotificationViewData>() {
@Override @Override
public NotificationViewData apply(Notification input) { public NotificationViewData apply(Either<Placeholder, Notification> input) {
return ViewDataUtils.notificationToViewData(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); TabLayout layout = activity.findViewById(R.id.tab_layout);
onTabSelectedListener = new TabLayout.OnTabSelectedListener() { onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@Override @Override
public void onTabSelected(TabLayout.Tab tab) { public void onTabSelected(TabLayout.Tab tab) {}
}
@Override @Override
public void onTabUnselected(TabLayout.Tab tab) { public void onTabUnselected(TabLayout.Tab tab) {}
}
@Override @Override
public void onTabReselected(TabLayout.Tab tab) { public void onTabReselected(TabLayout.Tab tab) {
@ -185,7 +208,7 @@ public class NotificationsFragment extends SFragment implements
ActionButtonActivity activity = (ActionButtonActivity) getActivity(); ActionButtonActivity activity = (ActionButtonActivity) getActivity();
FloatingActionButton composeButton = activity.getActionButton(); FloatingActionButton composeButton = activity.getActionButton();
if(composeButton != null) { if (composeButton != null) {
if (hideFab) { if (hideFab) {
if (dy > 0 && composeButton.isShown()) { if (dy > 0 && composeButton.isShown()) {
composeButton.hide(); // hides the button if we're scrolling down composeButton.hide(); // hides the button if we're scrolling down
@ -220,18 +243,17 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onRefresh() { public void onRefresh() {
sendFetchNotificationsRequest(null, topId, FetchEnd.TOP); sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1);
} }
@Override @Override
public void onReply(int position) { public void onReply(int position) {
Notification notification = notifications.get(position); super.reply(notifications.get(position).getAsRight().status);
super.reply(notification.status);
} }
@Override @Override
public void onReblog(final boolean reblog, final int position) { 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; final Status status = notification.status;
reblogWithCallback(status, reblog, new Callback<Status>() { reblogWithCallback(status, reblog, new Callback<Status>() {
@Override @Override
@ -242,7 +264,9 @@ public class NotificationsFragment extends SFragment implements
if (status.reblog != null) { if (status.reblog != null) {
status.reblog.reblogged = reblog; status.reblog.reblogged = reblog;
} }
notifications.set(position, notification); // Java's type inference *eyeroll*
notifications.set(position,
Either.<Placeholder, Notification>right(notification));
adapter.updateItemWithNotify(position, notifications.getPairedItem(position), true); adapter.updateItemWithNotify(position, notifications.getPairedItem(position), true);
@ -252,8 +276,7 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.id); Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.id, t);
t.printStackTrace();
} }
}); });
} }
@ -261,7 +284,7 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onFavourite(final boolean favourite, final int position) { 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; final Status status = notification.status;
favouriteWithCallback(status, favourite, new Callback<Status>() { favouriteWithCallback(status, favourite, new Callback<Status>() {
@Override @Override
@ -273,7 +296,8 @@ public class NotificationsFragment extends SFragment implements
status.reblog.favourited = favourite; status.reblog.favourited = favourite;
} }
notifications.set(position, notification); notifications.set(position,
Either.<Placeholder, Notification>right(notification));
adapter.updateItemWithNotify(position, notifications.getPairedItem(position), true); adapter.updateItemWithNotify(position, notifications.getPairedItem(position), true);
@ -283,15 +307,14 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.id); Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.id, t);
t.printStackTrace();
} }
}); });
} }
@Override @Override
public void onMore(View view, int position) { public void onMore(View view, int position) {
Notification notification = notifications.get(position); Notification notification = notifications.get(position).getAsRight();
super.more(notification.status, view, position); super.more(notification.status, view, position);
} }
@ -303,24 +326,25 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onViewThread(int position) { public void onViewThread(int position) {
Notification notification = notifications.get(position); Notification notification = notifications.get(position).getAsRight();
super.viewThread(notification.status); super.viewThread(notification.status);
} }
@Override @Override
public void onOpenReblog(int position) { public void onOpenReblog(int position) {
Notification notification = notifications.get(position); Notification notification = notifications.get(position).getAsRight();
if (notification != null) onViewAccount(notification.account.id); onViewAccount(notification.account.id);
} }
@Override @Override
public void onExpandedChange(boolean expanded, int position) { public void onExpandedChange(boolean expanded, int position) {
NotificationViewData old = notifications.getPairedItem(position); NotificationViewData.Concrete old =
StatusViewData statusViewData = (NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Concrete statusViewData =
new StatusViewData.Builder(old.getStatusViewData()) new StatusViewData.Builder(old.getStatusViewData())
.setIsExpanded(expanded) .setIsExpanded(expanded)
.createStatusViewData(); .createStatusViewData();
NotificationViewData notificationViewData = new NotificationViewData(old.getType(), NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(),
old.getId(), old.getAccount(), statusViewData); old.getId(), old.getAccount(), statusViewData);
notifications.setPairedItem(position, notificationViewData); notifications.setPairedItem(position, notificationViewData);
adapter.updateItemWithNotify(position, notificationViewData, false); adapter.updateItemWithNotify(position, notificationViewData, false);
@ -328,17 +352,38 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onContentHiddenChange(boolean isShowing, int position) { public void onContentHiddenChange(boolean isShowing, int position) {
NotificationViewData old = notifications.getPairedItem(position); NotificationViewData.Concrete old =
StatusViewData statusViewData = (NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Concrete statusViewData =
new StatusViewData.Builder(old.getStatusViewData()) new StatusViewData.Builder(old.getStatusViewData())
.setIsShowingSensitiveContent(isShowing) .setIsShowingSensitiveContent(isShowing)
.createStatusViewData(); .createStatusViewData();
NotificationViewData notificationViewData = new NotificationViewData(old.getType(), NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(),
old.getId(), old.getAccount(), statusViewData); old.getId(), old.getAccount(), statusViewData);
notifications.setPairedItem(position, notificationViewData); notifications.setPairedItem(position, notificationViewData);
adapter.updateItemWithNotify(position, notificationViewData, false); 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 @Override
public void onViewTag(String tag) { public void onViewTag(String tag) {
super.viewTag(tag); super.viewTag(tag);
@ -374,10 +419,11 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void removeAllByAccountId(String accountId) { public void removeAllByAccountId(String accountId) {
// using iterator to safely remove items while iterating // using iterator to safely remove items while iterating
Iterator<Notification> iterator = notifications.iterator(); Iterator<Either<Placeholder, Notification>> iterator = notifications.iterator();
while (iterator.hasNext()) { while (iterator.hasNext()) {
Notification notification = iterator.next(); Either<Placeholder, Notification> notification = iterator.next();
if (notification.account.id.equals(accountId)) { Notification maybeNotification = notification.getAsRightOrNull();
if (maybeNotification != null && maybeNotification.account.id.equals(accountId)) {
iterator.remove(); iterator.remove();
} }
} }
@ -385,7 +431,7 @@ public class NotificationsFragment extends SFragment implements
} }
private void onLoadMore() { private void onLoadMore() {
sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM); sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1);
} }
private void jumpToTop() { private void jumpToTop() {
@ -394,7 +440,7 @@ public class NotificationsFragment extends SFragment implements
} }
private void sendFetchNotificationsRequest(String fromId, String uptoId, 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 /* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */ * fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) { if (fetchEnd == FetchEnd.TOP && topLoading) {
@ -418,7 +464,7 @@ public class NotificationsFragment extends SFragment implements
}); });
} }
Call<List<Notification>> call = mastodonApi.notifications(fromId, uptoId, null); Call<List<Notification>> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE);
call.enqueue(new Callback<List<Notification>>() { call.enqueue(new Callback<List<Notification>>() {
@Override @Override
@ -426,22 +472,22 @@ public class NotificationsFragment extends SFragment implements
@NonNull Response<List<Notification>> response) { @NonNull Response<List<Notification>> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link"); String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd); onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
} else { } else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd); onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
} }
} }
@Override @Override
public void onFailure(@NonNull Call<List<Notification>> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<List<Notification>> call, @NonNull Throwable t) {
onFetchNotificationsFailure((Exception) t, fetchEnd); onFetchNotificationsFailure((Exception) t, fetchEnd, pos);
} }
}); });
callList.add(call); callList.add(call);
} }
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader, private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
FetchEnd fetchEnd) { FetchEnd fetchEnd, int pos) {
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader); List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) { switch (fetchEnd) {
case TOP: { case TOP: {
@ -453,6 +499,10 @@ public class NotificationsFragment extends SFragment implements
update(notifications, null, uptoId); update(notifications, null, uptoId);
break; break;
} }
case MIDDLE: {
replacePlaceholderWithNotifications(notifications, pos);
break;
}
case BOTTOM: { case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next"); HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null; String fromId = null;
@ -489,8 +539,20 @@ public class NotificationsFragment extends SFragment implements
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
} }
public void update(@Nullable List<Notification> newNotifications, @Nullable String fromId, private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) {
@Nullable String uptoId) { 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<Notification> newNotifications, @Nullable String fromId,
@Nullable String uptoId) {
if (ListUtils.isEmpty(newNotifications)) { if (ListUtils.isEmpty(newNotifications)) {
return; return;
} }
@ -500,26 +562,31 @@ public class NotificationsFragment extends SFragment implements
if (uptoId != null) { if (uptoId != null) {
topId = uptoId; topId = uptoId;
} }
List<Either<Placeholder, Notification>> liftedNew =
liftNotificationList(newNotifications);
if (notifications.isEmpty()) { if (notifications.isEmpty()) {
// This construction removes duplicates while preserving order. notifications.addAll(liftedNew);
notifications.addAll(new LinkedHashSet<>(newNotifications));
} else { } 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++) { for (int i = 0; i < index; i++) {
notifications.remove(0); notifications.remove(0);
} }
int newIndex = newNotifications.indexOf(notifications.get(0));
int newIndex = liftedNew.indexOf(notifications.get(0));
if (newIndex == -1) { if (newIndex == -1) {
notifications.addAll(0, newNotifications); if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) {
liftedNew.add(Either.<Placeholder, Notification>left(Placeholder.getInstance()));
}
notifications.addAll(0, liftedNew);
} else { } else {
List<Notification> sublist = newNotifications.subList(0, newIndex); notifications.addAll(0, liftedNew.subList(0, newIndex));
notifications.addAll(0, sublist);
} }
} }
adapter.update(notifications.getPairedCopy()); adapter.update(notifications.getPairedCopy());
} }
public void addItems(List<Notification> newNotifications, @Nullable String fromId) { private void addItems(List<Notification> newNotifications, @Nullable String fromId) {
if (ListUtils.isEmpty(newNotifications)) { if (ListUtils.isEmpty(newNotifications)) {
return; return;
} }
@ -527,9 +594,10 @@ public class NotificationsFragment extends SFragment implements
bottomId = fromId; bottomId = fromId;
} }
int end = notifications.size(); int end = notifications.size();
Notification last = notifications.get(end - 1); List<Either<Placeholder, Notification>> liftedNew = liftNotificationList(newNotifications);
if (last != null && !findNotification(newNotifications, last.id)) { Either<Placeholder, Notification> last = notifications.get(end - 1);
notifications.addAll(newNotifications); if (last != null && liftedNew.indexOf(last) == -1) {
notifications.addAll(liftedNew);
List<NotificationViewData> newViewDatas = notifications.getPairedCopy() List<NotificationViewData> newViewDatas = notifications.getPairedCopy()
.subList(notifications.size() - newNotifications.size(), .subList(notifications.size() - newNotifications.size(),
notifications.size()); notifications.size());
@ -537,21 +605,6 @@ public class NotificationsFragment extends SFragment implements
} }
} }
private static boolean findNotification(List<Notification> 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) { private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) { switch (fetchEnd) {
case BOTTOM: { case BOTTOM: {
@ -573,9 +626,43 @@ public class NotificationsFragment extends SFragment implements
} }
} }
private void replacePlaceholderWithNotifications(List<Notification> newNotifications, int pos) {
// Remove placeholder
notifications.remove(pos);
if (ListUtils.isEmpty(newNotifications)) {
adapter.update(notifications.getPairedCopy());
return;
}
List<Either<Placeholder, Notification>> 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.<Placeholder, Notification>left(Placeholder.getInstance()));
}
notifications.addAll(pos, liftedNew);
adapter.update(notifications.getPairedCopy());
}
private final Function<Notification, Either<Placeholder, Notification>> notificationLifter =
new Function<Notification, Either<Placeholder, Notification>>() {
@Override
public Either<Placeholder, Notification> apply(Notification input) {
return Either.right(input);
}
};
private List<Either<Placeholder, Notification>> liftNotificationList(List<Notification> list) {
return CollectionUtil.map(list, notificationLifter);
}
private void fullyRefresh() { private void fullyRefresh() {
adapter.clear(); adapter.clear();
notifications.clear(); notifications.clear();
sendFetchNotificationsRequest(null, null, FetchEnd.TOP); sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1);
} }
} }

@ -21,6 +21,7 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.app.ActivityOptionsCompat; import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.content.LocalBroadcastManager; import android.support.v4.content.LocalBroadcastManager;
@ -148,10 +149,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
Call<Relationship> call = mastodonApi.muteAccount(id); Call<Relationship> call = mastodonApi.muteAccount(id);
call.enqueue(new Callback<Relationship>() { call.enqueue(new Callback<Relationship>() {
@Override @Override
public void onResponse(Call<Relationship> call, Response<Relationship> response) {} public void onResponse(@NonNull Call<Relationship> call, @NonNull Response<Relationship> response) {}
@Override @Override
public void onFailure(Call<Relationship> call, Throwable t) {} public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {}
}); });
callList.add(call); callList.add(call);
Intent intent = new Intent(TimelineReceiver.Types.MUTE_ACCOUNT); Intent intent = new Intent(TimelineReceiver.Types.MUTE_ACCOUNT);
@ -164,10 +165,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
Call<Relationship> call = mastodonApi.blockAccount(id); Call<Relationship> call = mastodonApi.blockAccount(id);
call.enqueue(new Callback<Relationship>() { call.enqueue(new Callback<Relationship>() {
@Override @Override
public void onResponse(Call<Relationship> call, retrofit2.Response<Relationship> response) {} public void onResponse(@NonNull Call<Relationship> call, @NonNull retrofit2.Response<Relationship> response) {}
@Override @Override
public void onFailure(Call<Relationship> call, Throwable t) {} public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {}
}); });
callList.add(call); callList.add(call);
Intent intent = new Intent(TimelineReceiver.Types.BLOCK_ACCOUNT); Intent intent = new Intent(TimelineReceiver.Types.BLOCK_ACCOUNT);
@ -180,10 +181,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
Call<ResponseBody> call = mastodonApi.deleteStatus(id); Call<ResponseBody> call = mastodonApi.deleteStatus(id);
call.enqueue(new Callback<ResponseBody>() { call.enqueue(new Callback<ResponseBody>() {
@Override @Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {} public void onResponse(@NonNull Call<ResponseBody> call, @NonNull retrofit2.Response<ResponseBody> response) {}
@Override @Override
public void onFailure(Call<ResponseBody> call, Throwable t) {} public void onFailure(@NonNull Call<ResponseBody> call, @NonNull Throwable t) {}
}); });
callList.add(call); callList.add(call);
} }

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import android.arch.core.util.Function;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.graphics.drawable.Drawable; 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.FloatingActionButton;
import android.support.design.widget.TabLayout; import android.support.design.widget.TabLayout;
import android.support.v4.content.LocalBroadcastManager; import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.util.Pair;
import android.support.v4.widget.SwipeRefreshLayout; import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager; 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.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.receiver.TimelineReceiver; 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.HttpHeaderLink;
import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.PairedList;
@ -52,7 +56,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -64,10 +67,12 @@ public class TimelineFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
StatusActionListener, StatusActionListener,
SharedPreferences.OnSharedPreferenceChangeListener { 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 KIND_ARG = "kind";
private static final String HASHTAG_OR_ID_ARG = "hashtag_or_id"; private static final String HASHTAG_OR_ID_ARG = "hashtag_or_id";
private static final int LOAD_AT_ONCE = 30;
public enum Kind { public enum Kind {
HOME, HOME,
PUBLIC_LOCAL, PUBLIC_LOCAL,
@ -80,6 +85,7 @@ public class TimelineFragment extends SFragment implements
private enum FetchEnd { private enum FetchEnd {
TOP, TOP,
BOTTOM, BOTTOM,
MIDDLE
} }
private SwipeRefreshLayout swipeRefreshLayout; private SwipeRefreshLayout swipeRefreshLayout;
@ -102,8 +108,18 @@ public class TimelineFragment extends SFragment implements
private String bottomId; private String bottomId;
@Nullable @Nullable
private String topId; private String topId;
private PairedList<Status, StatusViewData> statuses = private PairedList<Either<Placeholder, Status>, StatusViewData> statuses =
new PairedList<>(ViewDataUtils.statusMapper()); new PairedList<>(new Function<Either<Placeholder, Status>, StatusViewData>() {
@Override
public StatusViewData apply(Either<Placeholder, Status> input) {
Status status = input.getAsRightOrNull();
if (status != null) {
return ViewDataUtils.statusToViewData(status);
} else {
return new StatusViewData.Placeholder(false);
}
}
});
public static TimelineFragment newInstance(Kind kind) { public static TimelineFragment newInstance(Kind kind) {
TimelineFragment fragment = new TimelineFragment(); TimelineFragment fragment = new TimelineFragment();
@ -122,6 +138,17 @@ public class TimelineFragment extends SFragment implements
return fragment; return fragment;
} }
private static final class Placeholder {
private final static Placeholder INSTANCE = new Placeholder();
public static Placeholder getInstance() {
return INSTANCE;
}
private Placeholder() {
}
}
@Override @Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) { Bundle savedInstanceState) {
@ -179,12 +206,10 @@ public class TimelineFragment extends SFragment implements
TabLayout layout = getActivity().findViewById(R.id.tab_layout); TabLayout layout = getActivity().findViewById(R.id.tab_layout);
onTabSelectedListener = new TabLayout.OnTabSelectedListener() { onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@Override @Override
public void onTabSelected(TabLayout.Tab tab) { public void onTabSelected(TabLayout.Tab tab) {}
}
@Override @Override
public void onTabUnselected(TabLayout.Tab tab) { public void onTabUnselected(TabLayout.Tab tab) {}
}
@Override @Override
public void onTabReselected(TabLayout.Tab tab) { public void onTabReselected(TabLayout.Tab tab) {
@ -219,7 +244,7 @@ public class TimelineFragment extends SFragment implements
} else if (!composeButton.isShown()) { } else if (!composeButton.isShown()) {
composeButton.show(); composeButton.show();
} }
} }
} }
@Override @Override
@ -251,86 +276,97 @@ public class TimelineFragment extends SFragment implements
@Override @Override
public void onRefresh() { public void onRefresh() {
sendFetchTimelineRequest(null, topId, FetchEnd.TOP); sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1);
} }
@Override @Override
public void onReply(int position) { public void onReply(int position) {
super.reply(statuses.get(position)); super.reply(statuses.get(position).getAsRight());
} }
@Override @Override
public void onReblog(final boolean reblog, final int position) { 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<Status>() { super.reblogWithCallback(status, reblog, new Callback<Status>() {
@Override @Override
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.reblogged = reblog; status.reblogged = reblog;
if (status.reblog != null) { if (status.reblog != null) {
status.reblog.reblogged = reblog; status.reblog.reblogged = reblog;
} }
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData = StatusViewData newViewData =
new StatusViewData.Builder(statuses.getPairedItem(position)) new StatusViewData.Builder(actual.first)
.setReblogged(reblog) .setReblogged(reblog)
.createStatusViewData(); .createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(actual.second, newViewData);
adapter.changeItem(position, newViewData, true); adapter.changeItem(actual.second, newViewData, true);
} }
} }
@Override @Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
Log.d(TAG, "Failed to reblog status " + status.id); Log.d(TAG, "Failed to reblog status " + status.id, t);
t.printStackTrace();
} }
}); });
} }
@Override @Override
public void onFavourite(final boolean favourite, final int position) { 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<Status>() { super.favouriteWithCallback(status, favourite, new Callback<Status>() {
@Override @Override
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.favourited = favourite; status.favourited = favourite;
if (status.reblog != null) { if (status.reblog != null) {
status.reblog.favourited = favourite; status.reblog.favourited = favourite;
} }
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData = new StatusViewData StatusViewData newViewData = new StatusViewData
.Builder(statuses.getPairedItem(position)) .Builder(actual.first)
.setFavourited(favourite) .setFavourited(favourite)
.createStatusViewData(); .createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(actual.second, newViewData);
adapter.changeItem(position, newViewData, true); adapter.changeItem(actual.second, newViewData, true);
} }
} }
@Override @Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
Log.d(TAG, "Failed to favourite status " + status.id); Log.d(TAG, "Failed to favourite status " + status.id, t);
t.printStackTrace();
} }
}); });
} }
@Override @Override
public void onMore(View view, final int position) { public void onMore(View view, final int position) {
super.more(statuses.get(position), view, position); super.more(statuses.get(position).getAsRight(), view, position);
} }
@Override @Override
public void onOpenReblog(int position) { public void onOpenReblog(int position) {
super.openReblog(statuses.get(position)); super.openReblog(statuses.get(position).getAsRight());
} }
@Override @Override
public void onExpandedChange(boolean expanded, int position) { 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(); .setIsExpanded(expanded).createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(position, newViewData);
adapter.changeItem(position, newViewData, false); adapter.changeItem(position, newViewData, false);
@ -338,12 +374,33 @@ public class TimelineFragment extends SFragment implements
@Override @Override
public void onContentHiddenChange(boolean isShowing, int position) { 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(); .setIsShowingSensitiveContent(isShowing).createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(position, newViewData);
adapter.changeItem(position, newViewData, false); 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 @Override
public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type, public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type,
View view) { View view) {
@ -352,7 +409,7 @@ public class TimelineFragment extends SFragment implements
@Override @Override
public void onViewThread(int position) { public void onViewThread(int position) {
super.viewThread(statuses.get(position)); super.viewThread(statuses.get(position).getAsRight());
} }
@Override @Override
@ -417,10 +474,10 @@ public class TimelineFragment extends SFragment implements
@Override @Override
public void removeAllByAccountId(String accountId) { public void removeAllByAccountId(String accountId) {
// using iterator to safely remove items while iterating // using iterator to safely remove items while iterating
Iterator<Status> iterator = statuses.iterator(); Iterator<Either<Placeholder, Status>> iterator = statuses.iterator();
while (iterator.hasNext()) { while (iterator.hasNext()) {
Status status = iterator.next(); Status status = iterator.next().getAsRightOrNull();
if (status.account.id.equals(accountId)) { if (status != null && status.account.id.equals(accountId)) {
iterator.remove(); iterator.remove();
} }
} }
@ -428,12 +485,12 @@ public class TimelineFragment extends SFragment implements
} }
private void onLoadMore() { private void onLoadMore() {
sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM); sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM, -1);
} }
private void fullyRefresh() { private void fullyRefresh() {
adapter.clear(); adapter.clear();
sendFetchTimelineRequest(null, null, FetchEnd.TOP); sendFetchTimelineRequest(null, null, FetchEnd.TOP, -1);
} }
private boolean jumpToTopAllowed() { private boolean jumpToTopAllowed() {
@ -457,20 +514,20 @@ public class TimelineFragment extends SFragment implements
case HOME: case HOME:
return api.homeTimeline(fromId, uptoId, null); return api.homeTimeline(fromId, uptoId, null);
case PUBLIC_FEDERATED: case PUBLIC_FEDERATED:
return api.publicTimeline(null, fromId, uptoId, null); return api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE);
case PUBLIC_LOCAL: case PUBLIC_LOCAL:
return api.publicTimeline(true, fromId, uptoId, null); return api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE);
case TAG: case TAG:
return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null); return api.hashtagTimeline(tagOrId, null, fromId, uptoId, LOAD_AT_ONCE);
case USER: case USER:
return api.accountStatuses(tagOrId, fromId, uptoId, null, null); return api.accountStatuses(tagOrId, fromId, uptoId, LOAD_AT_ONCE);
case FAVOURITES: 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, 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 /* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */ * fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) { if (fetchEnd == FetchEnd.TOP && topLoading) {
@ -499,15 +556,15 @@ public class TimelineFragment extends SFragment implements
public void onResponse(@NonNull Call<List<Status>> call, @NonNull Response<List<Status>> response) { public void onResponse(@NonNull Call<List<Status>> call, @NonNull Response<List<Status>> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link"); String linkHeader = response.headers().get("Link");
onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd); onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd, pos);
} else { } else {
onFetchTimelineFailure(new Exception(response.message()), fetchEnd); onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos);
} }
} }
@Override @Override
public void onFailure(@NonNull Call<List<Status>> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<List<Status>> 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); listCall.enqueue(callback);
} }
public void onFetchTimelineSuccess(List<Status> statuses, String linkHeader, private void onFetchTimelineSuccess(List<Status> statuses, String linkHeader,
FetchEnd fetchEnd) { 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); filterStatuses(statuses);
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader); List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) { switch (fetchEnd) {
@ -527,7 +587,11 @@ public class TimelineFragment extends SFragment implements
if (previous != null) { if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id"); uptoId = previous.uri.getQueryParameter("since_id");
} }
updateStatuses(statuses, null, uptoId); updateStatuses(statuses, null, uptoId, fullFetch);
break;
}
case MIDDLE: {
replacePlaceholderWithStatuses(statuses, fullFetch, pos);
break; break;
} }
case BOTTOM: { case BOTTOM: {
@ -547,7 +611,7 @@ public class TimelineFragment extends SFragment implements
if (previous != null) { if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id"); uptoId = previous.uri.getQueryParameter("since_id");
} }
updateStatuses(statuses, fromId, uptoId); updateStatuses(statuses, fromId, uptoId, fullFetch);
} }
break; break;
} }
@ -561,8 +625,15 @@ public class TimelineFragment extends SFragment implements
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
} }
public void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd) { private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) {
swipeRefreshLayout.setRefreshing(false); 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()); Log.e(TAG, "Fetch Failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd); fulfillAnyQueuedFetches(fetchEnd);
} }
@ -588,7 +659,7 @@ public class TimelineFragment extends SFragment implements
} }
} }
protected void filterStatuses(List<Status> statuses) { private void filterStatuses(List<Status> statuses) {
Iterator<Status> it = statuses.iterator(); Iterator<Status> it = statuses.iterator();
while (it.hasNext()) { while (it.hasNext()) {
Status status = it.next(); Status status = it.next();
@ -600,7 +671,7 @@ public class TimelineFragment extends SFragment implements
} }
private void updateStatuses(List<Status> newStatuses, @Nullable String fromId, private void updateStatuses(List<Status> newStatuses, @Nullable String fromId,
@Nullable String toId) { @Nullable String toId, boolean fullFetch) {
if (ListUtils.isEmpty(newStatuses)) { if (ListUtils.isEmpty(newStatuses)) {
return; return;
} }
@ -610,20 +681,26 @@ public class TimelineFragment extends SFragment implements
if (toId != null) { if (toId != null) {
topId = toId; topId = toId;
} }
List<Either<Placeholder, Status>> liftedNew = listStatusList(newStatuses);
if (statuses.isEmpty()) { if (statuses.isEmpty()) {
// This construction removes duplicates while preserving order. statuses.addAll(liftedNew);
statuses.addAll(new LinkedHashSet<>(newStatuses));
} else { } else {
Status lastOfNew = newStatuses.get(newStatuses.size() - 1); Either<Placeholder, Status> lastOfNew = liftedNew.get(newStatuses.size() - 1);
int index = statuses.indexOf(lastOfNew); int index = statuses.indexOf(lastOfNew);
for (int i = 0; i < index; i++) { for (int i = 0; i < index; i++) {
statuses.remove(0); statuses.remove(0);
} }
int newIndex = newStatuses.indexOf(statuses.get(0)); int newIndex = liftedNew.indexOf(statuses.get(0));
if (newIndex == -1) { if (newIndex == -1) {
statuses.addAll(0, newStatuses); if (index == -1 && fullFetch) {
liftedNew.add(Either.<Placeholder, Status>left(Placeholder.getInstance()));
}
statuses.addAll(0, liftedNew);
} else { } else {
statuses.addAll(0, newStatuses.subList(0, newIndex)); statuses.addAll(0, liftedNew.subList(0, newIndex));
} }
} }
adapter.update(statuses.getPairedCopy()); adapter.update(statuses.getPairedCopy());
@ -634,15 +711,17 @@ public class TimelineFragment extends SFragment implements
return; return;
} }
int end = statuses.size(); 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)) { if (last != null && !findStatus(newStatuses, last.id)) {
statuses.addAll(newStatuses); statuses.addAll(listStatusList(newStatuses));
List<StatusViewData> newViewDatas = statuses.getPairedCopy() List<StatusViewData> newViewDatas = statuses.getPairedCopy()
.subList(statuses.size() - newStatuses.size(), statuses.size()); .subList(statuses.size() - newStatuses.size(), statuses.size());
if (BuildConfig.DEBUG && newStatuses.size() != newViewDatas.size()) { if (BuildConfig.DEBUG && newStatuses.size() != newViewDatas.size()) {
String error = String.format(Locale.getDefault(), String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." + "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()); newStatuses.size(), newViewDatas.size(), statuses.size());
throw new AssertionError(error); throw new AssertionError(error);
} }
@ -653,6 +732,28 @@ public class TimelineFragment extends SFragment implements
} }
} }
private void replacePlaceholderWithStatuses(List<Status> 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<Either<Placeholder, Status>> liftedNew = listStatusList(newStatuses);
if (fullFetch) {
liftedNew.add(Either.<Placeholder, Status>left(Placeholder.getInstance()));
}
statuses.addAll(pos, liftedNew);
adapter.update(statuses.getPairedCopy());
}
private static boolean findStatus(List<Status> statuses, String id) { private static boolean findStatus(List<Status> statuses, String id) {
for (Status status : statuses) { for (Status status : statuses) {
if (status.id.equals(id)) { if (status.id.equals(id)) {
@ -661,4 +762,39 @@ public class TimelineFragment extends SFragment implements
} }
return false; return false;
} }
private final Function<Status, Either<Placeholder, Status>> statusLifter =
new Function<Status, Either<Placeholder, Status>>() {
@Override
public Either<Placeholder, Status> apply(Status input) {
return Either.right(input);
}
};
private @Nullable
Pair<StatusViewData.Concrete, Integer>
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.<Placeholder, Status>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<Either<Placeholder, Status>> listStatusList(List<Status> list) {
return CollectionUtil.map(list, statusLifter);
}
} }

@ -69,7 +69,7 @@ public class ViewThreadFragment extends SFragment implements
private int statusIndex = 0; private int statusIndex = 0;
private final PairedList<Status, StatusViewData> statuses = private final PairedList<Status, StatusViewData.Concrete> statuses =
new PairedList<>(ViewDataUtils.statusMapper()); new PairedList<>(ViewDataUtils.statusMapper());
public static ViewThreadFragment newInstance(String id) { public static ViewThreadFragment newInstance(String id) {
@ -83,7 +83,7 @@ public class ViewThreadFragment extends SFragment implements
@Nullable @Nullable
@Override @Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, 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); View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false);
Context context = getContext(); Context context = getContext();
@ -227,22 +227,29 @@ public class ViewThreadFragment extends SFragment implements
@Override @Override
public void onExpandedChange(boolean expanded, int position) { public void onExpandedChange(boolean expanded, int position) {
StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) StatusViewData.Concrete newViewData =
.setIsExpanded(expanded) new StatusViewData.Builder(statuses.getPairedItem(position))
.createStatusViewData(); .setIsExpanded(expanded)
.createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, false); adapter.setItem(position, newViewData, false);
} }
@Override @Override
public void onContentHiddenChange(boolean isShowing, int position) { public void onContentHiddenChange(boolean isShowing, int position) {
StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(position)) StatusViewData.Concrete newViewData =
.setIsShowingSensitiveContent(isShowing) new StatusViewData.Builder(statuses.getPairedItem(position))
.createStatusViewData(); .setIsShowingSensitiveContent(isShowing)
.createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, false); adapter.setItem(position, newViewData, false);
} }
@Override
public void onLoadMore(int pos) {
}
@Override @Override
public void onViewTag(String tag) { public void onViewTag(String tag) {
super.viewTag(tag); super.viewTag(tag);
@ -255,7 +262,7 @@ public class ViewThreadFragment extends SFragment implements
@Override @Override
public void removeItem(int position) { public void removeItem(int position) {
if(position == statusIndex) { if (position == statusIndex) {
//the status got removed, close the activity //the status got removed, close the activity
getActivity().finish(); getActivity().finish();
} }
@ -278,7 +285,7 @@ public class ViewThreadFragment extends SFragment implements
} }
} }
statusIndex = statuses.indexOf(status); statusIndex = statuses.indexOf(status);
if(statusIndex == -1) { if (statusIndex == -1) {
//the status got removed, close the activity //the status got removed, close the activity
getActivity().finish(); getActivity().finish();
return; return;
@ -379,8 +386,8 @@ public class ViewThreadFragment extends SFragment implements
int i = statusIndex; int i = statusIndex;
statuses.add(i, status); statuses.add(i, status);
adapter.setDetailedStatusPosition(i); adapter.setDetailedStatusPosition(i);
StatusViewData viewData = statuses.getPairedItem(i); StatusViewData.Concrete viewData = statuses.getPairedItem(i);
if(viewData.getCard() == null && card != null) { if (viewData.getCard() == null && card != null) {
viewData = new StatusViewData.Builder(viewData) viewData = new StatusViewData.Builder(viewData)
.setCard(card) .setCard(card)
.createStatusViewData(); .createStatusViewData();
@ -405,7 +412,7 @@ public class ViewThreadFragment extends SFragment implements
statusIndex = ancestors.size(); statusIndex = ancestors.size();
adapter.setDetailedStatusPosition(statusIndex); adapter.setDetailedStatusPosition(statusIndex);
statuses.addAll(0, ancestors); statuses.addAll(0, ancestors);
List<StatusViewData> ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); List<StatusViewData.Concrete> ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex);
if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) { if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) {
String error = String.format(Locale.getDefault(), String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." + "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 // In case we needed to delete everything (which is way easier than deleting
// everything except one), re-insert the remaining status here. // everything except one), re-insert the remaining status here.
statuses.add(statusIndex, mainStatus); statuses.add(statusIndex, mainStatus);
StatusViewData viewData = statuses.getPairedItem(statusIndex); StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex);
if(viewData.getCard() == null && card != null) { if (viewData.getCard() == null && card != null) {
viewData = new StatusViewData.Builder(viewData) viewData = new StatusViewData.Builder(viewData)
.setCard(card) .setCard(card)
.createStatusViewData(); .createStatusViewData();
@ -431,9 +438,9 @@ public class ViewThreadFragment extends SFragment implements
// Insert newly fetched descendants // Insert newly fetched descendants
statuses.addAll(descendants); statuses.addAll(descendants);
List<StatusViewData> descendantsViewData; List<StatusViewData.Concrete> descendantsViewData;
descendantsViewData = statuses.getPairedCopy() descendantsViewData = statuses.getPairedCopy()
.subList(statuses.size() - descendants.size(), statuses.size()); .subList(statuses.size() - descendants.size(), statuses.size());
if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) { if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) {
String error = String.format(Locale.getDefault(), String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." + "Incorrectly got statusViewData sublist." +
@ -447,16 +454,14 @@ public class ViewThreadFragment extends SFragment implements
private void showCard(Card card) { private void showCard(Card card) {
this.card = card; this.card = card;
if(statuses.size() != 0) { if (statuses.size() != 0) {
StatusViewData oldViewData = statuses.getPairedItem(statusIndex); StatusViewData.Concrete newViewData =
if(oldViewData != null) { new StatusViewData.Builder(statuses.getPairedItem(statusIndex))
StatusViewData newViewData = new StatusViewData.Builder(statuses.getPairedItem(statusIndex)) .setCard(card)
.setCard(card) .createStatusViewData();
.createStatusViewData();
statuses.setPairedItem(statusIndex, newViewData);
statuses.setPairedItem(statusIndex, newViewData); adapter.setItem(statusIndex, newViewData, true);
adapter.setItem(statusIndex, newViewData, true);
}
} }
} }

@ -29,4 +29,5 @@ public interface StatusActionListener extends LinkListener {
void onOpenReblog(int position); void onOpenReblog(int position);
void onExpandedChange(boolean expanded, int position); void onExpandedChange(boolean expanded, int position);
void onContentHiddenChange(boolean isShowing, int position); void onContentHiddenChange(boolean isShowing, int position);
void onLoadMore(int position);
} }

@ -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 <http://www.gnu.org/licenses>. */
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 <E, R> List<R> map(List<E> list, Function<E, R> mapper) {
final List<R> newList = new ArrayList<>(list.size());
for (E el : list) {
newList.add(mapper.apply(el));
}
return newList;
}
}

@ -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 <http://www.gnu.org/licenses>. */
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<L, R> {
/**
* Constructs Left instance of either
* @param left Object to be considered Left
* @param <L> Left type
* @param <R> Right type
* @return new instance of Either which contains left.
*/
public static <L, R> Either<L, R> left(L left) {
return new Either<>(left, false);
}
/**
* Constructs Right instance of either
* @param right Object to be considered Right
* @param <L> Left type
* @param <R> Right type
* @return new instance of Either which contains right.
*/
public static <L, R> Either<L, R> 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));
}
}

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util; package com.keylesspalace.tusky.util;
import android.arch.core.util.Function; import android.arch.core.util.Function;
@ -17,7 +32,7 @@ import java.util.List;
public final class ViewDataUtils { public final class ViewDataUtils {
@Nullable @Nullable
public static StatusViewData statusToViewData(@Nullable Status status) { public static StatusViewData.Concrete statusToViewData(@Nullable Status status) {
if (status == null) return null; if (status == null) return null;
Status visibleStatus = status.reblog == null ? status : status.reblog; Status visibleStatus = status.reblog == null ? status : status.reblog;
return new StatusViewData.Builder().setId(status.id) return new StatusViewData.Builder().setId(status.id)
@ -55,12 +70,12 @@ public final class ViewDataUtils {
return viewDatas; return viewDatas;
} }
public static Function<Status, StatusViewData> statusMapper() { public static Function<Status, StatusViewData.Concrete> statusMapper() {
return statusMapper; return statusMapper;
} }
public static NotificationViewData notificationToViewData(Notification notification) { public static NotificationViewData.Concrete notificationToViewData(Notification notification) {
return new NotificationViewData(notification.type, notification.id, notification.account, return new NotificationViewData.Concrete(notification.type, notification.id, notification.account,
statusToViewData(notification.status)); statusToViewData(notification.status));
} }
@ -73,10 +88,10 @@ public final class ViewDataUtils {
return viewDatas; return viewDatas;
} }
private static final Function<Status, StatusViewData> statusMapper = private static final Function<Status, StatusViewData.Concrete> statusMapper =
new Function<Status, StatusViewData>() { new Function<Status, StatusViewData.Concrete>() {
@Override @Override
public StatusViewData apply(Status input) { public StatusViewData.Concrete apply(Status input) {
return ViewDataUtils.statusToViewData(input); return ViewDataUtils.statusToViewData(input);
} }
}; };

@ -49,16 +49,16 @@ public class ConversationLineItemDecoration extends RecyclerView.ItemDecoration
int position = parent.getChildAdapterPosition(child); int position = parent.getChildAdapterPosition(child);
ThreadAdapter adapter = (ThreadAdapter) parent.getAdapter(); ThreadAdapter adapter = (ThreadAdapter) parent.getAdapter();
StatusViewData current = adapter.getItem(position); StatusViewData.Concrete current = adapter.getItem(position);
int dividerTop, dividerBottom; int dividerTop, dividerBottom;
if (current != null) { if (current != null) {
StatusViewData above = adapter.getItem(position - 1); StatusViewData.Concrete above = adapter.getItem(position - 1);
if (above != null && above.getId().equals(current.getInReplyToId())) { if (above != null && above.getId().equals(current.getInReplyToId())) {
dividerTop = child.getTop(); dividerTop = child.getTop();
} else { } else {
dividerTop = child.getTop() + avatarMargin; 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())) { if (below != null && current.getId().equals(below.getInReplyToId())) {
dividerBottom = child.getBottom(); dividerBottom = child.getBottom();
} else { } else {

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.viewdata; package com.keylesspalace.tusky.viewdata;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Account;
@ -5,35 +20,59 @@ import com.keylesspalace.tusky.entity.Notification;
/** /**
* Created by charlag on 12/07/2017. * 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 abstract class NotificationViewData {
public final class NotificationViewData { private NotificationViewData() {
private final Notification.Type type;
private final String id;
private final Account account;
private final StatusViewData statusViewData;
public NotificationViewData(Notification.Type type, String id, Account account,
StatusViewData statusViewData) {
this.type = type;
this.id = id;
this.account = account;
this.statusViewData = statusViewData;
} }
public Notification.Type getType() { public static final class Concrete extends NotificationViewData {
return type; private final Notification.Type type;
} private final String id;
private final Account account;
private final StatusViewData.Concrete statusViewData;
public String getId() { public Concrete(Notification.Type type, String id, Account account,
return id; StatusViewData.Concrete statusViewData) {
} this.type = type;
this.id = id;
this.account = account;
this.statusViewData = statusViewData;
}
public Notification.Type getType() {
return type;
}
public Account getAccount() { public String getId() {
return account; return id;
}
public Account getAccount() {
return account;
}
public StatusViewData.Concrete getStatusViewData() {
return statusViewData;
}
} }
public StatusViewData getStatusViewData() { public static final class Placeholder extends NotificationViewData {
return statusViewData; private final boolean isLoading;
public Placeholder(boolean isLoading) {
this.isLoading = isLoading;
}
public boolean isLoading() {
return isLoading;
}
} }
} }

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.viewdata; package com.keylesspalace.tusky.viewdata;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
@ -12,179 +27,202 @@ import java.util.List;
/** /**
* Created by charlag on 11/07/2017. * 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 { public abstract 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<Status.Emoji> 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<Status.Emoji> 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 String getId() { private StatusViewData() {
return id;
} }
public Spanned getContent() { public static final class Concrete extends StatusViewData {
return content; 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<Status.Emoji> 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<Status.Emoji> 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() { public String getId() {
return reblogged; return id;
} }
public boolean isFavourited() { public Spanned getContent() {
return favourited; return content;
} }
@Nullable public boolean isReblogged() {
public String getSpoilerText() { return reblogged;
return spoilerText; }
}
public Status.Visibility getVisibility() { public boolean isFavourited() {
return visibility; return favourited;
} }
public Status.MediaAttachment[] getAttachments() { @Nullable
return attachments; public String getSpoilerText() {
} return spoilerText;
}
@Nullable public Status.Visibility getVisibility() {
public String getRebloggedByUsername() { return visibility;
return rebloggedByUsername; }
}
public boolean isSensitive() { public Status.MediaAttachment[] getAttachments() {
return isSensitive; return attachments;
} }
public boolean isExpanded() { @Nullable
return isExpanded; public String getRebloggedByUsername() {
} return rebloggedByUsername;
}
public boolean isShowingSensitiveContent() { public boolean isSensitive() {
return isShowingSensitiveContent; return isSensitive;
} }
@Nullable public boolean isExpanded() {
public String getRebloggedAvatar() { return isExpanded;
return rebloggedAvatar; }
}
public String getUserFullName() { public boolean isShowingSensitiveContent() {
return userFullName; return isShowingSensitiveContent;
} }
public String getNickname() { @Nullable
return nickname; public String getRebloggedAvatar() {
} return rebloggedAvatar;
}
public String getAvatar() { public String getUserFullName() {
return avatar; return userFullName;
} }
public Date getCreatedAt() { public String getNickname() {
return createdAt; return nickname;
} }
public String getReblogsCount() { public String getAvatar() {
return reblogsCount; return avatar;
} }
public String getFavouritesCount() { public Date getCreatedAt() {
return favouritesCount; return createdAt;
} }
@Nullable public String getReblogsCount() {
public String getInReplyToId() { return reblogsCount;
return inReplyToId; }
}
public String getSenderId() { public String getFavouritesCount() {
return senderId; return favouritesCount;
} }
public Boolean getRebloggingEnabled() { @Nullable
return rebloggingEnabled; public String getInReplyToId() {
} return inReplyToId;
}
@Nullable public String getSenderId() {
public Status.Mention[] getMentions() { return senderId;
return mentions; }
}
public Status.Application getApplication() { public Boolean getRebloggingEnabled() {
return application; return rebloggingEnabled;
} }
@Nullable
public Status.Mention[] getMentions() {
return mentions;
}
public Status.Application getApplication() {
return application;
}
public List<Status.Emoji> getEmojis() {
return emojis;
}
@Nullable
public Card getCard() {
return card;
}
public List<Status.Emoji> getEmojis() {
return emojis;
} }
public Card getCard() { public static final class Placeholder extends StatusViewData {
return card; private final boolean isLoading;
public Placeholder(boolean isLoading) {
this.isLoading = isLoading;
}
public boolean isLoading() {
return isLoading;
}
} }
public static class Builder { public static class Builder {
@ -217,7 +255,7 @@ public final class StatusViewData {
public Builder() { public Builder() {
} }
public Builder(final StatusViewData viewData) { public Builder(final StatusViewData.Concrete viewData) {
id = viewData.id; id = viewData.id;
content = viewData.content; content = viewData.content;
reblogged = viewData.reblogged; reblogged = viewData.reblogged;
@ -243,7 +281,6 @@ public final class StatusViewData {
application = viewData.application; application = viewData.application;
emojis = viewData.getEmojis(); emojis = viewData.getEmojis();
card = viewData.getCard(); card = viewData.getCard();
} }
public Builder setId(String id) { public Builder setId(String id) {
@ -371,12 +408,15 @@ public final class StatusViewData {
return this; return this;
} }
public StatusViewData createStatusViewData() { public StatusViewData.Concrete createStatusViewData() {
if (this.emojis == null) emojis = Collections.emptyList(); 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, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded,
isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, reblogsCount, isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, reblogsCount,
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, emojis, card); favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application,
emojis, card);
} }
} }
} }

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<Button xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/button_load_more"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="match_parent"
android:layout_height="72dp"
android:text="@string/load_more_placeholder_text"
android:textColor="?attr/colorAccent" />

@ -237,5 +237,6 @@
<string name="pref_title_alway_show_sensitive_media">Always show all nsfw content</string> <string name="pref_title_alway_show_sensitive_media">Always show all nsfw content</string>
<string name="title_media">Media</string> <string name="title_media">Media</string>
<string name="replying_to">Replying to @%s</string> <string name="replying_to">Replying to @%s</string>
<string name="load_more_placeholder_text">load more</string>
</resources> </resources>

Loading…
Cancel
Save