Moves loading of accounts, notifications, and statuses to use link headers. Also remedies an issue where duplicate calls for the same chunk of items in a list can occur.

This commit is contained in:
Vavassor 2017-06-30 02:31:58 -04:00
parent 74b64f61a7
commit 2a98693ed4
10 changed files with 755 additions and 374 deletions

View File

@ -21,12 +21,16 @@ import android.support.v7.widget.RecyclerView;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
public abstract class AccountAdapter extends RecyclerView.Adapter {
List<Account> accountList;
AccountActionListener accountActionListener;
private String topId;
private String bottomId;
AccountAdapter(AccountActionListener accountActionListener) {
super();
@ -39,12 +43,20 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
return accountList.size() + 1;
}
public void update(List<Account> newAccounts) {
public void update(@Nullable List<Account> newAccounts, @Nullable String fromId,
@Nullable String uptoId) {
if (newAccounts == null || newAccounts.isEmpty()) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (uptoId != null) {
topId = uptoId;
}
if (accountList.isEmpty()) {
accountList = newAccounts;
// This construction removes duplicates.
accountList = new ArrayList<>(new HashSet<>(newAccounts));
} else {
int index = accountList.indexOf(newAccounts.get(newAccounts.size() - 1));
for (int i = 0; i < index; i++) {
@ -60,10 +72,25 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
notifyDataSetChanged();
}
public void addItems(List<Account> newAccounts) {
public void addItems(List<Account> newAccounts, @Nullable String fromId) {
if (fromId != null) {
bottomId = fromId;
}
int end = accountList.size();
accountList.addAll(newAccounts);
notifyItemRangeInserted(end, newAccounts.size());
Account last = accountList.get(end - 1);
if (last != null && !findAccount(accountList, last.id)) {
accountList.addAll(newAccounts);
notifyItemRangeInserted(end, newAccounts.size());
}
}
private static boolean findAccount(List<Account> accounts, String id) {
for (Account account : accounts) {
if (account.id.equals(id)) {
return true;
}
}
return false;
}
@Nullable
@ -84,10 +111,21 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
notifyItemInserted(position);
}
@Nullable
public Account getItem(int position) {
if (position >= 0 && position < accountList.size()) {
return accountList.get(position);
}
return null;
}
@Nullable
public String getBottomId() {
return bottomId;
}
@Nullable
public String getTopId() {
return topId;
}
}

View File

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.squareup.picasso.Picasso;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
public class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
@ -56,6 +57,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
private NotificationActionListener notificationActionListener;
private FooterState footerState = FooterState.END;
private boolean mediaPreviewEnabled;
private String bottomId;
private String topId;
public NotificationsAdapter(StatusActionListener statusListener,
NotificationActionListener notificationActionListener) {
@ -186,19 +189,28 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
}
}
public @Nullable Notification getItem(int position) {
@Nullable
public Notification getItem(int position) {
if (position >= 0 && position < notifications.size()) {
return notifications.get(position);
}
return null;
}
public void update(List<Notification> newNotifications) {
public void update(@Nullable List<Notification> newNotifications, @Nullable String fromId,
@Nullable String uptoId) {
if (newNotifications == null || newNotifications.isEmpty()) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (uptoId != null) {
topId = uptoId;
}
if (notifications.isEmpty()) {
notifications = newNotifications;
// This construction removes duplicates.
notifications = new ArrayList<>(new HashSet<>(newNotifications));
} else {
int index = notifications.indexOf(newNotifications.get(newNotifications.size() - 1));
for (int i = 0; i < index; i++) {
@ -214,10 +226,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
notifyDataSetChanged();
}
public void addItems(List<Notification> new_notifications) {
public void addItems(List<Notification> newNotifications, @Nullable String fromId) {
if (fromId != null) {
bottomId = fromId;
}
int end = notifications.size();
notifications.addAll(new_notifications);
notifyItemRangeInserted(end, new_notifications.size());
Notification last = notifications.get(end - 1);
if (last != null && !findNotification(notifications, last.id)) {
notifications.addAll(newNotifications);
notifyItemRangeInserted(end, newNotifications.size());
}
}
private static boolean findNotification(List<Notification> notifications, String id) {
for (Notification notification : notifications) {
if (notification.id.equals(id)) {
return true;
}
}
return false;
}
public void clear() {
@ -233,6 +260,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
}
}
@Nullable
public String getBottomId() {
return bottomId;
}
@Nullable
public String getTopId() {
return topId;
}
public void setMediaPreviewEnabled(boolean enabled) {
mediaPreviewEnabled = enabled;
}

View File

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
@ -43,6 +44,8 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
private StatusActionListener statusListener;
private FooterState footerState = FooterState.END;
private boolean mediaPreviewEnabled;
private String topId;
private String bottomId;
public TimelineAdapter(StatusActionListener statusListener) {
super();
@ -126,12 +129,20 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
}
}
public void update(List<Status> newStatuses) {
public void update(@Nullable List<Status> newStatuses, @Nullable String fromId,
@Nullable String uptoId) {
if (newStatuses == null || newStatuses.isEmpty()) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (uptoId != null) {
topId = uptoId;
}
if (statuses.isEmpty()) {
statuses = newStatuses;
// This construction removes duplicates.
statuses = new ArrayList<>(new HashSet<>(newStatuses));
} else {
int index = statuses.indexOf(newStatuses.get(newStatuses.size() - 1));
for (int i = 0; i < index; i++) {
@ -147,10 +158,25 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
notifyDataSetChanged();
}
public void addItems(List<Status> newStatuses) {
public void addItems(List<Status> newStatuses, @Nullable String fromId) {
if (fromId != null) {
bottomId = fromId;
}
int end = statuses.size();
statuses.addAll(newStatuses);
notifyItemRangeInserted(end, newStatuses.size());
Status last = statuses.get(end - 1);
if (last != null && !findStatus(statuses, last.id)) {
statuses.addAll(newStatuses);
notifyItemRangeInserted(end, newStatuses.size());
}
}
private static boolean findStatus(List<Status> statuses, String id) {
for (Status status : statuses) {
if (status.id.equals(id)) {
return true;
}
}
return false;
}
public void clear() {
@ -177,4 +203,14 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
public void setMediaPreviewEnabled(boolean enabled) {
mediaPreviewEnabled = enabled;
}
@Nullable
public String getBottomId() {
return bottomId;
}
@Nullable
public String getTopId() {
return topId;
}
}

View File

@ -43,6 +43,7 @@ import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -71,6 +72,10 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
private AccountAdapter adapter;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
private MastodonApi api;
private boolean bottomLoading;
private int bottomFetches;
private boolean topLoading;
private int topFetches;
public static AccountListFragment newInstance(Type type) {
Bundle arguments = new Bundle();
@ -160,13 +165,7 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
AccountAdapter adapter = (AccountAdapter) view.getAdapter();
Account account = adapter.getItem(adapter.getItemCount() - 2);
if (account != null) {
fetchAccounts(account.id, null);
} else {
fetchAccounts();
}
AccountListFragment.this.onLoadMore(view);
}
};
recyclerView.addOnScrollListener(scrollListener);
@ -181,78 +180,6 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
super.onDestroyView();
}
private void fetchAccounts(final String fromId, String uptoId) {
Callback<List<Account>> cb = new Callback<List<Account>>() {
@Override
public void onResponse(Call<List<Account>> call, Response<List<Account>> response) {
if (response.isSuccessful()) {
onFetchAccountsSuccess(response.body(), fromId);
} else {
onFetchAccountsFailure(new Exception(response.message()));
}
}
@Override
public void onFailure(Call<List<Account>> call, Throwable t) {
onFetchAccountsFailure((Exception) t);
}
};
Call<List<Account>> listCall;
switch (type) {
default:
case FOLLOWS: {
listCall = api.accountFollowing(accountId, fromId, uptoId, null);
break;
}
case FOLLOWERS: {
listCall = api.accountFollowers(accountId, fromId, uptoId, null);
break;
}
case BLOCKS: {
listCall = api.blocks(fromId, uptoId, null);
break;
}
case MUTES: {
listCall = api.mutes(fromId, uptoId, null);
break;
}
case FOLLOW_REQUESTS: {
listCall = api.followRequests(fromId, uptoId, null);
break;
}
}
callList.add(listCall);
listCall.enqueue(cb);
}
private void fetchAccounts() {
fetchAccounts(null, null);
}
private static boolean findAccount(List<Account> accounts, String id) {
for (Account account : accounts) {
if (account.id.equals(id)) {
return true;
}
}
return false;
}
private void onFetchAccountsSuccess(List<Account> accounts, String fromId) {
if (fromId != null) {
if (accounts.size() > 0 && !findAccount(accounts, fromId)) {
adapter.addItems(accounts);
}
} else {
adapter.update(accounts);
}
}
private void onFetchAccountsFailure(Exception exception) {
Log.e(TAG, "Fetch failure: " + exception.getMessage());
}
@Override
public void onViewAccount(String id) {
Intent intent = new Intent(getContext(), AccountActivity.class);
@ -444,4 +371,126 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
layoutManager.scrollToPositionWithOffset(0, 0);
scrollListener.reset();
}
private enum FetchEnd {
TOP,
BOTTOM
}
private Call<List<Account>> getFetchCallByListType(Type type, String fromId, String uptoId) {
switch (type) {
default:
case FOLLOWS: return api.accountFollowing(accountId, fromId, uptoId, null);
case FOLLOWERS: return api.accountFollowers(accountId, fromId, uptoId, null);
case BLOCKS: return api.blocks(fromId, uptoId, null);
case MUTES: return api.mutes(fromId, uptoId, null);
case FOLLOW_REQUESTS: return api.followRequests(fromId, uptoId, null);
}
}
private void fetchAccounts(String fromId, String uptoId, final FetchEnd fetchEnd) {
/* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) {
topFetches++;
return;
}
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
bottomFetches++;
return;
}
Callback<List<Account>> cb = new Callback<List<Account>>() {
@Override
public void onResponse(Call<List<Account>> call, Response<List<Account>> response) {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchAccountsSuccess(response.body(), linkHeader, fetchEnd);
} else {
onFetchAccountsFailure(new Exception(response.message()), fetchEnd);
}
}
@Override
public void onFailure(Call<List<Account>> call, Throwable t) {
onFetchAccountsFailure((Exception) t, fetchEnd);
}
};
Call<List<Account>> listCall = getFetchCallByListType(type, fromId, uptoId);
callList.add(listCall);
listCall.enqueue(cb);
}
private void onFetchAccountsSuccess(List<Account> accounts, String linkHeader,
FetchEnd fetchEnd) {
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) {
case TOP: {
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(accounts, null, uptoId);
break;
}
case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(accounts, fromId);
} else {
/* If this is the first fetch, also save the id from the "previous" link and
* treat this operation as a refresh so the scroll position doesn't get pushed
* down to the end. */
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(accounts, fromId, uptoId);
}
break;
}
}
fulfillAnyQueuedFetches(fetchEnd);
}
private void onFetchAccountsFailure(Exception exception, FetchEnd fetchEnd) {
Log.e(TAG, "Fetch failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd);
}
private void onRefresh() {
fetchAccounts(null, adapter.getTopId(), FetchEnd.TOP);
}
private void onLoadMore(RecyclerView recyclerView) {
AccountAdapter adapter = (AccountAdapter) recyclerView.getAdapter();
fetchAccounts(adapter.getBottomId(), null, FetchEnd.BOTTOM);
}
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) {
case BOTTOM: {
bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
Log.d(TAG, "extra fetchos " + bottomFetches);
onLoadMore(recyclerView);
}
break;
}
case TOP: {
topLoading = false;
if (topFetches > 0) {
topFetches--;
onRefresh();
}
break;
}
}
}
}

View File

@ -40,6 +40,7 @@ import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -55,15 +56,23 @@ public class NotificationsFragment extends SFragment implements
SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "Notifications"; // logging tag
private enum FetchEnd {
TOP,
BOTTOM,
}
private SwipeRefreshLayout swipeRefreshLayout;
private LinearLayoutManager layoutManager;
private RecyclerView recyclerView;
private EndlessOnScrollListener scrollListener;
private NotificationsAdapter adapter;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
private Call<List<Notification>> listCall;
private boolean hideFab;
private TimelineReceiver timelineReceiver;
private boolean topLoading;
private int topFetches;
private boolean bottomLoading;
private int bottomFetches;
public static NotificationsFragment newInstance() {
NotificationsFragment fragment = new NotificationsFragment();
@ -157,27 +166,13 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
Notification notification = adapter.getItem(adapter.getItemCount() - 2);
if (notification != null) {
sendFetchNotificationsRequest(notification.id, null);
} else {
sendFetchNotificationsRequest();
}
NotificationsFragment.this.onLoadMore(view);
}
};
recyclerView.addOnScrollListener(scrollListener);
}
@Override
public void onDestroy() {
super.onDestroy();
if (listCall != null) {
listCall.cancel();
}
}
@Override
public void onDestroyView() {
TabLayout tabLayout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
@ -189,88 +184,9 @@ public class NotificationsFragment extends SFragment implements
super.onDestroyView();
}
private void jumpToTop() {
layoutManager.scrollToPosition(0);
scrollListener.reset();
}
private void sendFetchNotificationsRequest(final String fromId, String uptoId) {
if (fromId != null || adapter.getItemCount() <= 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.LOADING);
}
listCall = mastodonAPI.notifications(fromId, uptoId, null);
listCall.enqueue(new Callback<List<Notification>>() {
@Override
public void onResponse(Call<List<Notification>> call,
Response<List<Notification>> response) {
if (response.isSuccessful()) {
onFetchNotificationsSuccess(response.body(), fromId);
} else {
onFetchNotificationsFailure(new Exception(response.message()));
}
}
@Override
public void onFailure(Call<List<Notification>> call, Throwable t) {
onFetchNotificationsFailure((Exception) t);
}
});
callList.add(listCall);
}
private void sendFetchNotificationsRequest() {
sendFetchNotificationsRequest(null, null);
}
private static boolean findNotification(List<Notification> notifications, String id) {
for (Notification notification : notifications) {
if (notification.id.equals(id)) {
return true;
}
}
return false;
}
private void onFetchNotificationsSuccess(List<Notification> notifications, String fromId) {
if (fromId != null) {
if (notifications.size() > 0 && !findNotification(notifications, fromId)) {
adapter.addItems(notifications);
// Set last update id for pull notifications so that we don't get notified
// about things we already loaded here
SharedPreferences preferences = getActivity()
.getSharedPreferences(getString(R.string.preferences_file_key),
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putString("lastUpdateId", notifications.get(0).id);
editor.apply();
}
} else {
adapter.update(notifications);
}
if (notifications.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY);
} else if (fromId != null) {
adapter.setFooterState(NotificationsAdapter.FooterState.END);
}
swipeRefreshLayout.setRefreshing(false);
}
private void onFetchNotificationsFailure(Exception exception) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch failure: " + exception.getMessage());
}
@Override
public void onRefresh() {
Notification notification = adapter.getItem(0);
if (notification != null) {
sendFetchNotificationsRequest(null, notification.id);
} else {
sendFetchNotificationsRequest();
}
sendFetchNotificationsRequest(null, adapter.getTopId(), FetchEnd.TOP);
}
@Override
@ -334,8 +250,133 @@ public class NotificationsFragment extends SFragment implements
}
}
private void onLoadMore(RecyclerView view) {
NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
sendFetchNotificationsRequest(adapter.getBottomId(), null, FetchEnd.BOTTOM);
}
private void jumpToTop() {
layoutManager.scrollToPosition(0);
scrollListener.reset();
}
private void sendFetchNotificationsRequest(String fromId, String uptoId,
final FetchEnd fetchEnd) {
/* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) {
topFetches++;
return;
}
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
bottomFetches++;
return;
}
if (fromId != null || adapter.getItemCount() <= 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.LOADING);
}
Call<List<Notification>> call = mastodonApi.notifications(fromId, uptoId, null);
call.enqueue(new Callback<List<Notification>>() {
@Override
public void onResponse(Call<List<Notification>> call,
Response<List<Notification>> response) {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd);
} else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd);
}
}
@Override
public void onFailure(Call<List<Notification>> call, Throwable t) {
onFetchNotificationsFailure((Exception) t, fetchEnd);
}
});
callList.add(call);
}
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
FetchEnd fetchEnd) {
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) {
case TOP: {
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(notifications, null, uptoId);
break;
}
case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(notifications, fromId);
} else {
/* If this is the first fetch, also save the id from the "previous" link and
* treat this operation as a refresh so the scroll position doesn't get pushed
* down to the end. */
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(notifications, fromId, uptoId);
}
/* Set last update id for pull notifications so that we don't get notified
* about things we already loaded here */
getPrivatePreferences().edit()
.putString("lastUpdateId", fromId)
.apply();
break;
}
}
fulfillAnyQueuedFetches(fetchEnd);
if (notifications.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY);
} else {
adapter.setFooterState(NotificationsAdapter.FooterState.END);
}
swipeRefreshLayout.setRefreshing(false);
}
private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd);
}
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) {
case BOTTOM: {
bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
onLoadMore(recyclerView);
}
break;
}
case TOP: {
topLoading = false;
if (topFetches > 0) {
topFetches--;
onRefresh();
}
break;
}
}
}
private void fullyRefresh() {
adapter.clear();
sendFetchNotificationsRequest(null, null);
sendFetchNotificationsRequest(null, null, FetchEnd.TOP);
}
}

View File

@ -57,10 +57,11 @@ import retrofit2.Response;
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */
public abstract class SFragment extends BaseFragment {
protected static final int COMPOSE_RESULT = 1;
protected String loggedInAccountId;
protected String loggedInUsername;
protected MastodonApi mastodonAPI;
protected static int COMPOSE_RESULT = 1;
protected MastodonApi mastodonApi;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@ -75,7 +76,13 @@ public abstract class SFragment extends BaseFragment {
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
BaseActivity activity = (BaseActivity) getActivity();
mastodonAPI = activity.mastodonApi;
mastodonApi = activity.mastodonApi;
}
@Override
public void startActivity(Intent intent) {
super.startActivity(intent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
protected void reply(Status status) {
@ -122,9 +129,9 @@ public abstract class SFragment extends BaseFragment {
Call<Status> call;
if (reblog) {
call = mastodonAPI.reblogStatus(id);
call = mastodonApi.reblogStatus(id);
} else {
call = mastodonAPI.unreblogStatus(id);
call = mastodonApi.unreblogStatus(id);
}
call.enqueue(cb);
callList.add(call);
@ -154,16 +161,16 @@ public abstract class SFragment extends BaseFragment {
Call<Status> call;
if (favourite) {
call = mastodonAPI.favouriteStatus(id);
call = mastodonApi.favouriteStatus(id);
} else {
call = mastodonAPI.unfavouriteStatus(id);
call = mastodonApi.unfavouriteStatus(id);
}
call.enqueue(cb);
callList.add(call);
}
private void mute(String id) {
Call<Relationship> call = mastodonAPI.muteAccount(id);
Call<Relationship> call = mastodonApi.muteAccount(id);
call.enqueue(new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, Response<Relationship> response) {}
@ -179,7 +186,7 @@ public abstract class SFragment extends BaseFragment {
}
private void block(String id) {
Call<Relationship> call = mastodonAPI.blockAccount(id);
Call<Relationship> call = mastodonApi.blockAccount(id);
call.enqueue(new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, retrofit2.Response<Relationship> response) {}
@ -195,7 +202,7 @@ public abstract class SFragment extends BaseFragment {
}
private void delete(String id) {
Call<ResponseBody> call = mastodonAPI.deleteStatus(id);
Call<ResponseBody> call = mastodonApi.deleteStatus(id);
call.enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {}
@ -313,14 +320,8 @@ public abstract class SFragment extends BaseFragment {
startActivity(intent);
}
@Override
public void startActivity(Intent intent) {
super.startActivity(intent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
protected void openReportPage(String accountId, String accountUsername, String statusId,
Spanned statusContent) {
Spanned statusContent) {
Intent intent = new Intent(getContext(), ReportActivity.class);
intent.putExtra("account_id", accountId);
intent.putExtra("account_username", accountUsername);

View File

@ -38,7 +38,9 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.TimelineAdapter;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -64,6 +66,11 @@ public class TimelineFragment extends SFragment implements
FAVOURITES
}
private enum FetchEnd {
TOP,
BOTTOM,
}
private SwipeRefreshLayout swipeRefreshLayout;
private TimelineAdapter adapter;
private Kind kind;
@ -72,11 +79,14 @@ public class TimelineFragment extends SFragment implements
private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
private SharedPreferences preferences;
private boolean filterRemoveReplies;
private boolean filterRemoveReblogs;
private boolean hideFab;
private TimelineReceiver timelineReceiver;
private boolean topLoading;
private int topFetches;
private boolean bottomLoading;
private int bottomFetches;
public static TimelineFragment newInstance(Kind kind) {
TimelineFragment fragment = new TimelineFragment();
@ -198,8 +208,6 @@ public class TimelineFragment extends SFragment implements
};
}
recyclerView.addOnScrollListener(scrollListener);
preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
}
@Override
@ -212,20 +220,9 @@ public class TimelineFragment extends SFragment implements
super.onDestroyView();
}
@Override
public void onResume() {
super.onResume();
setFiltersFromSettings();
}
@Override
public void onRefresh() {
Status status = adapter.getItem(0);
if (status != null) {
sendFetchTimelineRequest(null, status.id);
} else {
sendFetchTimelineRequest(null, null);
}
sendFetchTimelineRequest(null, adapter.getTopId(), FetchEnd.TOP);
}
@Override
@ -290,22 +287,35 @@ public class TimelineFragment extends SFragment implements
fullyRefresh();
break;
}
case "tabFilterHomeReplies": {
boolean filter = sharedPreferences.getBoolean("tabFilterHomeReplies", true);
boolean oldRemoveReplies = filterRemoveReplies;
filterRemoveReplies = kind == Kind.HOME && !filter;
if (adapter.getItemCount() > 1 && oldRemoveReplies != filterRemoveReplies) {
fullyRefresh();
}
break;
}
case "tabFilterHomeBoosts": {
boolean filter = sharedPreferences.getBoolean("tabFilterHomeBoosts", true);
boolean oldRemoveReblogs = filterRemoveReblogs;
filterRemoveReblogs = kind == Kind.HOME && !filter;
if (adapter.getItemCount() > 1 && oldRemoveReblogs != filterRemoveReblogs) {
fullyRefresh();
}
break;
}
}
}
private void onLoadMore(RecyclerView view) {
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
sendFetchTimelineRequest(status.id, null);
} else {
sendFetchTimelineRequest(null, null);
}
sendFetchTimelineRequest(adapter.getBottomId(), null, FetchEnd.BOTTOM);
}
private void fullyRefresh() {
adapter.clear();
sendFetchTimelineRequest(null, null);
sendFetchTimelineRequest(null, null, FetchEnd.TOP);
}
private boolean jumpToTopAllowed() {
@ -321,7 +331,33 @@ public class TimelineFragment extends SFragment implements
scrollListener.reset();
}
private void sendFetchTimelineRequest(@Nullable final String fromId, @Nullable String uptoId) {
private Call<List<Status>> getFetchCallByTimelineType(Kind kind, String tagOrId, String fromId,
String uptoId) {
MastodonApi api = mastodonApi;
switch (kind) {
default:
case HOME: return api.homeTimeline(fromId, uptoId, null);
case PUBLIC_FEDERATED: return api.publicTimeline(null, fromId, uptoId, null);
case PUBLIC_LOCAL: return api.publicTimeline(true, fromId, uptoId, null);
case TAG: return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null);
case USER: return api.accountStatuses(tagOrId, fromId, uptoId, null);
case FAVOURITES: return api.favourites(fromId, uptoId, null);
}
}
private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId,
final FetchEnd fetchEnd) {
/* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) {
topFetches++;
return;
}
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
bottomFetches++;
return;
}
if (fromId != null || adapter.getItemCount() <= 1) {
adapter.setFooterState(TimelineAdapter.FooterState.LOADING);
}
@ -330,99 +366,104 @@ public class TimelineFragment extends SFragment implements
@Override
public void onResponse(Call<List<Status>> call, Response<List<Status>> response) {
if (response.isSuccessful()) {
onFetchTimelineSuccess(response.body(), fromId);
String linkHeader = response.headers().get("Link");
onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd);
} else {
onFetchTimelineFailure(new Exception(response.message()));
onFetchTimelineFailure(new Exception(response.message()), fetchEnd);
}
}
@Override
public void onFailure(Call<List<Status>> call, Throwable t) {
onFetchTimelineFailure((Exception) t);
onFetchTimelineFailure((Exception) t, fetchEnd);
}
};
Call<List<Status>> listCall;
switch (kind) {
default:
case HOME: {
listCall = mastodonAPI.homeTimeline(fromId, uptoId, null);
break;
}
case PUBLIC_FEDERATED: {
listCall = mastodonAPI.publicTimeline(null, fromId, uptoId, null);
break;
}
case PUBLIC_LOCAL: {
listCall = mastodonAPI.publicTimeline(true, fromId, uptoId, null);
break;
}
case TAG: {
listCall = mastodonAPI.hashtagTimeline(hashtagOrId, null, fromId, uptoId, null);
break;
}
case USER: {
listCall = mastodonAPI.accountStatuses(hashtagOrId, fromId, uptoId, null);
break;
}
case FAVOURITES: {
listCall = mastodonAPI.favourites(fromId, uptoId, null);
break;
}
}
Call<List<Status>> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId);
callList.add(listCall);
listCall.enqueue(callback);
}
private static boolean findStatus(List<Status> statuses, String id) {
for (Status status : statuses) {
if (status.id.equals(id)) {
return true;
public void onFetchTimelineSuccess(List<Status> statuses, String linkHeader,
FetchEnd fetchEnd) {
filterStatuses(statuses);
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) {
case TOP: {
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(statuses, null, uptoId);
break;
}
case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(statuses, fromId);
} else {
/* If this is the first fetch, also save the id from the "previous" link and
* treat this operation as a refresh so the scroll position doesn't get pushed
* down to the end. */
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(statuses, fromId, uptoId);
}
break;
}
}
fulfillAnyQueuedFetches(fetchEnd);
if (statuses.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(TimelineAdapter.FooterState.EMPTY);
} else {
adapter.setFooterState(TimelineAdapter.FooterState.END);
}
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch Failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd);
}
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) {
case BOTTOM: {
bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
onLoadMore(recyclerView);
}
break;
}
case TOP: {
topLoading = false;
if (topFetches > 0) {
topFetches--;
onRefresh();
}
break;
}
}
return false;
}
protected void filterStatuses(List<Status> statuses) {
Iterator<Status> it = statuses.iterator();
while (it.hasNext()) {
Status status = it.next();
if ((status.inReplyToId != null && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs)) {
if ((status.inReplyToId != null && filterRemoveReplies)
|| (status.reblog != null && filterRemoveReblogs)) {
it.remove();
}
}
}
protected void setFiltersFromSettings() {
boolean oldRemoveReplies = filterRemoveReplies;
boolean oldRemoveReblogs = filterRemoveReblogs;
filterRemoveReplies = (kind == Kind.HOME && !preferences.getBoolean("tabFilterHomeReplies", true));
filterRemoveReblogs = (kind == Kind.HOME && !preferences.getBoolean("tabFilterHomeBoosts", true));
if (adapter.getItemCount() > 1 && (oldRemoveReblogs != filterRemoveReblogs || oldRemoveReplies != filterRemoveReplies)) {
fullyRefresh();
}
}
public void onFetchTimelineSuccess(List<Status> statuses, String fromId) {
filterStatuses(statuses);
if (fromId != null) {
if (statuses.size() > 0 && !findStatus(statuses, fromId)) {
adapter.addItems(statuses);
}
} else {
adapter.update(statuses);
}
if (statuses.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(TimelineAdapter.FooterState.EMPTY);
} else if(fromId != null) {
adapter.setFooterState(TimelineAdapter.FooterState.END);
}
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch Failure: " + exception.getMessage());
}
}

View File

@ -35,10 +35,8 @@ import android.view.ViewGroup;
import com.keylesspalace.tusky.adapter.ThreadAdapter;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
@ -56,7 +54,6 @@ public class ViewThreadFragment extends SFragment implements
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ThreadAdapter adapter;
private MastodonApi mastodonApi;
private String thisThreadsStatusId;
private TimelineReceiver timelineReceiver;
@ -97,7 +94,6 @@ public class ViewThreadFragment extends SFragment implements
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
recyclerView.setAdapter(adapter);
mastodonApi = null;
thisThreadsStatusId = null;
timelineReceiver = new TimelineReceiver(adapter, this);
@ -117,77 +113,10 @@ public class ViewThreadFragment extends SFragment implements
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
/* BaseActivity's MastodonApi object isn't guaranteed to be valid until after its onCreate
* is run, so all calls that need it can't be done until here. */
mastodonApi = ((BaseActivity) getActivity()).mastodonApi;
thisThreadsStatusId = getArguments().getString("id");
onRefresh();
}
private void sendStatusRequest(final String id) {
Call<Status> call = mastodonApi.status(id);
call.enqueue(new Callback<Status>() {
@Override
public void onResponse(Call<Status> call, Response<Status> response) {
if (response.isSuccessful()) {
int position = adapter.setStatus(response.body());
recyclerView.scrollToPosition(position);
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(Call<Status> call, Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
}
private void sendThreadRequest(final String id) {
Call<StatusContext> call = mastodonApi.statusContext(id);
call.enqueue(new Callback<StatusContext>() {
@Override
public void onResponse(Call<StatusContext> call, Response<StatusContext> response) {
if (response.isSuccessful()) {
swipeRefreshLayout.setRefreshing(false);
StatusContext context = response.body();
adapter.setContext(context.ancestors, context.descendants);
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(Call<StatusContext> call, Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
}
private void onThreadRequestFailure(final String id) {
View view = getView();
swipeRefreshLayout.setRefreshing(false);
if (view != null) {
Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, new View.OnClickListener() {
@Override
public void onClick(View v) {
sendThreadRequest(id);
sendStatusRequest(id);
}
})
.show();
} else {
Log.e(TAG, "Couldn't display thread fetch error message");
}
}
@Override
public void onRefresh() {
sendStatusRequest(thisThreadsStatusId);
@ -238,4 +167,65 @@ public class ViewThreadFragment extends SFragment implements
public void onViewAccount(String id) {
super.viewAccount(id);
}
private void sendStatusRequest(final String id) {
Call<Status> call = mastodonApi.status(id);
call.enqueue(new Callback<Status>() {
@Override
public void onResponse(Call<Status> call, Response<Status> response) {
if (response.isSuccessful()) {
int position = adapter.setStatus(response.body());
recyclerView.scrollToPosition(position);
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(Call<Status> call, Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
}
private void sendThreadRequest(final String id) {
Call<StatusContext> call = mastodonApi.statusContext(id);
call.enqueue(new Callback<StatusContext>() {
@Override
public void onResponse(Call<StatusContext> call, Response<StatusContext> response) {
if (response.isSuccessful()) {
swipeRefreshLayout.setRefreshing(false);
StatusContext context = response.body();
adapter.setContext(context.ancestors, context.descendants);
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(Call<StatusContext> call, Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
}
private void onThreadRequestFailure(final String id) {
View view = getView();
swipeRefreshLayout.setRefreshing(false);
if (view != null) {
Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, new View.OnClickListener() {
@Override
public void onClick(View v) {
sendThreadRequest(id);
sendStatusRequest(id);
}
})
.show();
} else {
Log.e(TAG, "Couldn't display thread fetch error message");
}
}
}

View File

@ -0,0 +1,148 @@
/* Written in 2017 by Andrew Dawson
*
* To the extent possible under law, the author(s) have dedicated all copyright and related and
* neighboring rights to this software to the public domain worldwide. This software is distributed
* without any warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication along with this software.
* If not, see <http://creativecommons.org/publicdomain/zero/1.0/>. */
package com.keylesspalace.tusky.util;
import android.net.Uri;
import android.support.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
public class HttpHeaderLink {
private static class Parameter {
public String name;
public String value;
}
private List<Parameter> parameters;
public Uri uri;
private HttpHeaderLink(String uri) {
this.uri = Uri.parse(uri);
this.parameters = new ArrayList<>();
}
private static int findAny(String s, int fromIndex, char[] set) {
for (int i = fromIndex; i < s.length(); i++) {
char c = s.charAt(i);
for (char member : set) {
if (c == member) {
return i;
}
}
}
return -1;
}
private static int findEndOfQuotedString(String line, int start) {
for (int i = start; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '\\') {
i += 1;
} else if (c == '"') {
return i;
}
}
return -1;
}
private static class ValueResult {
String value;
int end;
ValueResult() {
end = -1;
}
void setValue(String value) {
value = value.trim();
if (!value.isEmpty()) {
this.value = value;
}
}
}
private static ValueResult parseValue(String line, int start) {
ValueResult result = new ValueResult();
int foundIndex = findAny(line, start, new char[] {';', ',', '"'});
if (foundIndex == -1) {
result.setValue(line.substring(start));
return result;
}
char c = line.charAt(foundIndex);
if (c == ';' || c == ',') {
result.end = foundIndex;
result.setValue(line.substring(start, foundIndex));
return result;
} else {
int quoteEnd = findEndOfQuotedString(line, foundIndex + 1);
if (quoteEnd == -1) {
quoteEnd = line.length();
}
result.end = quoteEnd;
result.setValue(line.substring(foundIndex + 1, quoteEnd));
return result;
}
}
private static int parseParameters(String line, int start, HttpHeaderLink link) {
for (int i = start; i < line.length(); i++) {
int foundIndex = findAny(line, i, new char[] {'=', ','});
if (foundIndex == -1) {
return -1;
} else if (line.charAt(foundIndex) == ',') {
return foundIndex;
}
Parameter parameter = new Parameter();
parameter.name = line.substring(line.indexOf(';', i) + 1, foundIndex).trim();
link.parameters.add(parameter);
ValueResult result = parseValue(line, foundIndex);
parameter.value = result.value;
if (result.end == -1) {
return -1;
} else {
i = result.end;
}
}
return -1;
}
public static List<HttpHeaderLink> parse(@Nullable String line) {
List<HttpHeaderLink> linkList = new ArrayList<>();
if (line != null) {
for (int i = 0; i < line.length(); i++) {
int uriEnd = line.indexOf('>', i);
String uri = line.substring(line.indexOf('<', i) + 1, uriEnd);
HttpHeaderLink link = new HttpHeaderLink(uri);
linkList.add(link);
int parseEnd = parseParameters(line, uriEnd, link);
if (parseEnd == -1) {
break;
} else {
i = parseEnd;
}
}
}
return linkList;
}
@Nullable
public static HttpHeaderLink findByRelationType(List<HttpHeaderLink> links,
String relationType) {
for (HttpHeaderLink link : links) {
for (Parameter parameter : link.parameters) {
if (parameter.name.equals("rel") && parameter.value.equals(relationType)) {
return link;
}
}
}
return null;
}
}

View File

@ -115,7 +115,7 @@
<string name="hint_content_warning">Content warning</string>
<string name="hint_display_name">Display name</string>
<string name="hint_note">Bio</string>
<string name="hint_search">Search accounts and tags</string>
<string name="hint_search">Search…</string>
<string name="search_no_results">No results</string>