/* Copyright 2017 Andrew Dawson * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.fragment; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.design.widget.Snackbar; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.keylesspalace.tusky.AccountActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.AccountAdapter; import com.keylesspalace.tusky.adapter.BlocksAdapter; import com.keylesspalace.tusky.adapter.FollowAdapter; import com.keylesspalace.tusky.adapter.FollowRequestsAdapter; import com.keylesspalace.tusky.adapter.FooterViewHolder; import com.keylesspalace.tusky.adapter.MutesAdapter; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Relationship; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.view.EndlessOnScrollListener; import java.util.List; import javax.inject.Inject; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class AccountListFragment extends BaseFragment implements AccountActionListener, Injectable { private static final String TAG = "AccountList"; // logging tag public AccountListFragment() { } public enum Type { FOLLOWS, FOLLOWERS, BLOCKS, MUTES, FOLLOW_REQUESTS, } @Inject public MastodonApi api; private Type type; private String accountId; private LinearLayoutManager layoutManager; private RecyclerView recyclerView; private EndlessOnScrollListener scrollListener; private AccountAdapter adapter; private boolean bottomLoading; private int bottomFetches; private boolean topLoading; private int topFetches; public static AccountListFragment newInstance(Type type) { Bundle arguments = new Bundle(); AccountListFragment fragment = new AccountListFragment(); arguments.putSerializable("type", type); fragment.setArguments(arguments); return fragment; } public static AccountListFragment newInstance(Type type, String accountId) { Bundle arguments = new Bundle(); AccountListFragment fragment = new AccountListFragment(); arguments.putSerializable("type", type); arguments.putString("accountId", accountId); fragment.setArguments(arguments); return fragment; } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bundle arguments = getArguments(); type = (Type) arguments.getSerializable("type"); accountId = arguments.getString("accountId"); api = null; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_account_list, container, false); Context context = getContext(); recyclerView = rootView.findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); layoutManager = new LinearLayoutManager(context); recyclerView.setLayoutManager(layoutManager); DividerItemDecoration divider = new DividerItemDecoration( context, layoutManager.getOrientation()); Drawable drawable = ThemeUtils.getDrawable(context, R.attr.status_divider_drawable, R.drawable.status_divider_dark); divider.setDrawable(drawable); recyclerView.addItemDecoration(divider); scrollListener = null; if (type == Type.BLOCKS) { adapter = new BlocksAdapter(this); } else if (type == Type.MUTES) { adapter = new MutesAdapter(this); } else if (type == Type.FOLLOW_REQUESTS) { adapter = new FollowRequestsAdapter(this); } else { adapter = new FollowAdapter(this); } recyclerView.setAdapter(adapter); bottomLoading = false; bottomFetches = 0; topLoading = false; topFetches = 0; return rootView; } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); // Just use the basic scroll listener to load more accounts. scrollListener = new EndlessOnScrollListener(layoutManager) { @Override public void onLoadMore(int totalItemsCount, RecyclerView view) { AccountListFragment.this.onLoadMore(view); } }; recyclerView.addOnScrollListener(scrollListener); fetchAccounts(null, null, FetchEnd.BOTTOM); } @Override public void onViewAccount(String id) { Intent intent = new Intent(getContext(), AccountActivity.class); intent.putExtra("id", id); startActivity(intent); } @Override public void onMute(final boolean mute, final String id, final int position) { if (api == null) { /* If somehow an unmute button is clicked after onCreateView but before * onActivityCreated, then this would get called with a null api object, so this eats * that input. */ Log.d(TAG, "MastodonApi isn't initialised so this mute can't occur."); return; } Callback callback = new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful()) { onMuteSuccess(mute, id, position); } else { onMuteFailure(mute, id); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { onMuteFailure(mute, id); } }; Call call; if (!mute) { call = api.unmuteAccount(id); } else { call = api.muteAccount(id); } callList.add(call); call.enqueue(callback); } private void onMuteSuccess(boolean muted, final String id, final int position) { if (muted) { return; } final MutesAdapter mutesAdapter = (MutesAdapter) adapter; final Account unmutedUser = mutesAdapter.removeItem(position); View.OnClickListener listener = v -> { mutesAdapter.addItem(unmutedUser, position); onMute(true, id, position); }; Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) .setAction(R.string.action_undo, listener) .show(); } private void onMuteFailure(boolean mute, String id) { String verb; if (mute) { verb = "mute"; } else { verb = "unmute"; } Log.e(TAG, String.format("Failed to %s account id %s", verb, id)); } @Override public void onBlock(final boolean block, final String id, final int position) { if (api == null) { /* If somehow an unblock button is clicked after onCreateView but before * onActivityCreated, then this would get called with a null api object, so this eats * that input. */ Log.d(TAG, "MastodonApi isn't initialised so this block can't occur."); return; } Callback cb = new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful()) { onBlockSuccess(block, id, position); } else { onBlockFailure(block, id); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { onBlockFailure(block, id); } }; Call call; if (!block) { call = api.unblockAccount(id); } else { call = api.blockAccount(id); } callList.add(call); call.enqueue(cb); } private void onBlockSuccess(boolean blocked, final String id, final int position) { if (blocked) { return; } final BlocksAdapter blocksAdapter = (BlocksAdapter) adapter; final Account unblockedUser = blocksAdapter.removeItem(position); View.OnClickListener listener = v -> { blocksAdapter.addItem(unblockedUser, position); onBlock(true, id, position); }; Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG) .setAction(R.string.action_undo, listener) .show(); } private void onBlockFailure(boolean block, String id) { String verb; if (block) { verb = "block"; } else { verb = "unblock"; } Log.e(TAG, String.format("Failed to %s account id %s", verb, id)); } @Override public void onRespondToFollowRequest(final boolean accept, final String accountId, final int position) { if (api == null) { /* If somehow an response button is clicked after onCreateView but before * onActivityCreated, then this would get called with a null api object, so this eats * that input. */ Log.d(TAG, "MastodonApi isn't initialised, so follow requests can't be responded to."); return; } Callback callback = new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful()) { onRespondToFollowRequestSuccess(position); } else { onRespondToFollowRequestFailure(accept, accountId); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { onRespondToFollowRequestFailure(accept, accountId); } }; Call call; if (accept) { call = api.authorizeFollowRequest(accountId); } else { call = api.rejectFollowRequest(accountId); } callList.add(call); call.enqueue(callback); } private void onRespondToFollowRequestSuccess(int position) { FollowRequestsAdapter followRequestsAdapter = (FollowRequestsAdapter) adapter; followRequestsAdapter.removeItem(position); } private void onRespondToFollowRequestFailure(boolean accept, String accountId) { String verb; if (accept) { verb = "accept"; } else { verb = "reject"; } String message = String.format("Failed to %s account id %s.", verb, accountId); Log.e(TAG, message); } private enum FetchEnd { TOP, BOTTOM } private Call> 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; } if (fromId != null || adapter.getItemCount() <= 1) { /* When this is called by the EndlessScrollListener it cannot refresh the footer state * using adapter.notifyItemChanged. So its necessary to postpone doing so until a * convenient time for the UI thread using a Runnable. */ recyclerView.post(() -> adapter.setFooterState(FooterViewHolder.State.LOADING)); } Callback> cb = new Callback>() { @Override public void onResponse(@NonNull Call> call, @NonNull Response> 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(@NonNull Call> call, @NonNull Throwable t) { onFetchAccountsFailure((Exception) t, fetchEnd); } }; Call> listCall = getFetchCallByListType(type, fromId, uptoId); callList.add(listCall); listCall.enqueue(cb); } private void onFetchAccountsSuccess(List accounts, String linkHeader, FetchEnd fetchEnd) { List 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); if (accounts.size() == 0 && adapter.getItemCount() == 1) { adapter.setFooterState(FooterViewHolder.State.EMPTY); } else { adapter.setFooterState(FooterViewHolder.State.END); } } 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(); //if we do not have a bottom id, we know we do not need to load more if (adapter.getBottomId() == null) return; fetchAccounts(adapter.getBottomId(), null, FetchEnd.BOTTOM); } 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; } } } }