From bf6d7a6b975b8ad90f723555461c906e05f2ba56 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 22 Apr 2021 18:48:16 +0200 Subject: [PATCH] Convert TimelineFragment to Kotlin & ViewBinding (#2131) * convert TimelineFragment to Kotlin * cleanup some code * migrate to viewbinding * cleanup even more code * address review feedback * improve findStatusOrReblogPositionById --- .../tusky/fragment/SFragment.java | 4 +- .../tusky/fragment/TimelineFragment.java | 1526 ----------------- .../tusky/fragment/TimelineFragment.kt | 1265 ++++++++++++++ .../util/ListStatusAccessibilityDelegate.kt | 2 +- 4 files changed, 1268 insertions(+), 1529 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 9d4b45b3e..ef1074a39 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -511,7 +511,7 @@ public abstract class SFragment extends Fragment implements Injectable { }); } - @VisibleForTesting + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) public void reloadFilters(boolean forceRefresh) { if (filters != null && !forceRefresh) { applyFilters(forceRefresh); @@ -547,7 +547,7 @@ public abstract class SFragment extends Fragment implements Injectable { // Override to refresh your fragment } - @VisibleForTesting + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) public boolean shouldFilterStatus(Status status) { if (filterRemoveRegex && status.getPoll() != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java deleted file mode 100644 index 6f42dd159..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ /dev/null @@ -1,1526 +0,0 @@ -/* 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.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityManager; -import android.widget.ProgressBar; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.arch.core.util.Function; -import androidx.core.content.ContextCompat; -import androidx.core.util.Pair; -import androidx.core.widget.ContentLoadingProgressBar; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.AccountListActivity; -import com.keylesspalace.tusky.BaseActivity; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.adapter.TimelineAdapter; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.BookmarkEvent; -import com.keylesspalace.tusky.appstore.DomainMuteEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.FavoriteEvent; -import com.keylesspalace.tusky.appstore.MuteConversationEvent; -import com.keylesspalace.tusky.appstore.MuteEvent; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.appstore.StatusComposedEvent; -import com.keylesspalace.tusky.appstore.StatusDeletedEvent; -import com.keylesspalace.tusky.appstore.UnfollowEvent; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Filter; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.interfaces.RefreshableFragment; -import com.keylesspalace.tusky.interfaces.ReselectableFragment; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.repository.Placeholder; -import com.keylesspalace.tusky.repository.TimelineRepository; -import com.keylesspalace.tusky.repository.TimelineRequestMode; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.BackgroundMessageView; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.ListIterator; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.Observable; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import kotlin.Unit; -import kotlin.collections.CollectionsKt; -import kotlin.jvm.functions.Function1; -import retrofit2.Response; - -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; - -public class TimelineFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - Injectable, ReselectableFragment, RefreshableFragment { - private static final String TAG = "TimelineF"; // logging tag - private static final String KIND_ARG = "kind"; - private static final String ID_ARG = "id"; - private static final String HASHTAGS_ARG = "hastags"; - private static final String ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh"; - - private static final int LOAD_AT_ONCE = 30; - private boolean isSwipeToRefreshEnabled = true; - private boolean isNeedRefresh; - - public enum Kind { - HOME, - PUBLIC_LOCAL, - PUBLIC_FEDERATED, - TAG, - USER, - USER_PINNED, - USER_WITH_REPLIES, - FAVOURITES, - LIST, - BOOKMARKS - } - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - @Inject - public EventHub eventHub; - @Inject - TimelineRepository timelineRepo; - - @Inject - public AccountManager accountManager; - - private boolean eventRegistered = false; - - private SwipeRefreshLayout swipeRefreshLayout; - private RecyclerView recyclerView; - private ProgressBar progressBar; - private ContentLoadingProgressBar topProgressBar; - private BackgroundMessageView statusView; - - private TimelineAdapter adapter; - private Kind kind; - private String id; - private List tags; - /** - * For some timeline kinds we must use LINK headers and not just status ids. - */ - private String nextId; - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private boolean filterRemoveReplies; - private boolean filterRemoveReblogs; - private boolean hideFab; - private boolean bottomLoading; - - private boolean didLoadEverythingBottom; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean initialUpdateFailed = false; - - private PairedList, StatusViewData> statuses = - new PairedList<>(new Function, StatusViewData>() { - @Override - public StatusViewData apply(Either input) { - Status status = input.asRightOrNull(); - if (status != null) { - return ViewDataUtils.statusToViewData( - status, - alwaysShowSensitiveMedia, - alwaysOpenSpoiler - ); - } else { - Placeholder placeholder = input.asLeft(); - return new StatusViewData.Placeholder(placeholder.getId(), false); - } - } - }); - - public static TimelineFragment newInstance(Kind kind) { - return newInstance(kind, null); - } - - public static TimelineFragment newInstance(Kind kind, @Nullable String hashtagOrId) { - return newInstance(kind, hashtagOrId, true); - } - - public static TimelineFragment newInstance(Kind kind, @Nullable String hashtagOrId, boolean enableSwipeToRefresh) { - TimelineFragment fragment = new TimelineFragment(); - Bundle arguments = new Bundle(3); - arguments.putString(KIND_ARG, kind.name()); - arguments.putString(ID_ARG, hashtagOrId); - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh); - fragment.setArguments(arguments); - return fragment; - } - - public static TimelineFragment newHashtagInstance(@NonNull List hashtags) { - TimelineFragment fragment = new TimelineFragment(); - Bundle arguments = new Bundle(3); - arguments.putString(KIND_ARG, Kind.TAG.name()); - arguments.putStringArrayList(HASHTAGS_ARG, new ArrayList<>(hashtags)); - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true); - fragment.setArguments(arguments); - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Bundle arguments = requireArguments(); - kind = Kind.valueOf(arguments.getString(KIND_ARG)); - if (kind == Kind.USER - || kind == Kind.USER_PINNED - || kind == Kind.USER_WITH_REPLIES - || kind == Kind.LIST) { - id = arguments.getString(ID_ARG); - } - if (kind == Kind.TAG) { - tags = arguments.getStringArrayList(HASHTAGS_ARG); - } - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - preferences.getBoolean("showCardsInTimelines", false) ? - CardViewMode.INDENTED : - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ); - adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this); - - isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true); - - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); - - recyclerView = rootView.findViewById(R.id.recyclerView); - swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); - progressBar = rootView.findViewById(R.id.progressBar); - statusView = rootView.findViewById(R.id.statusView); - topProgressBar = rootView.findViewById(R.id.topProgressBar); - - setupSwipeRefreshLayout(); - setupRecyclerView(); - updateAdapter(); - setupTimelinePreferences(); - - if (statuses.isEmpty()) { - progressBar.setVisibility(View.VISIBLE); - bottomLoading = true; - this.sendInitialRequest(); - } else { - progressBar.setVisibility(View.GONE); - if (isNeedRefresh) - onRefresh(); - } - - return rootView; - } - - private void sendInitialRequest() { - if (this.kind == Kind.HOME) { - this.tryCache(); - } else { - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1); - } - } - - private void tryCache() { - // Request timeline from disk to make it quick, then replace it with timeline from - // the server to update it - this.timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, - TimelineRequestMode.DISK) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(statuses -> { - filterStatuses(statuses); - - if (statuses.size() > 1) { - this.clearPlaceholdersForResponse(statuses); - this.statuses.clear(); - this.statuses.addAll(statuses); - this.updateAdapter(); - this.progressBar.setVisibility(View.GONE); - // Request statuses including current top to refresh all of them - } - - this.updateCurrent(); - this.loadAbove(); - }); - } - - private void updateCurrent() { - if (this.statuses.isEmpty()) { - return; - } - - String topId = CollectionsKt.first(this.statuses, Either::isRight).asRight().getId(); - - this.timelineRepo.getStatuses(topId, null, null, LOAD_AT_ONCE, - TimelineRequestMode.NETWORK) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (statuses) -> { - this.initialUpdateFailed = false; - // When cached timeline is too old, we would replace it with nothing - if (!statuses.isEmpty()) { - filterStatuses(statuses); - - if (!this.statuses.isEmpty()) { - // clear old cached statuses - Iterator> iterator = this.statuses.iterator(); - while (iterator.hasNext()) { - Either item = iterator.next(); - if (item.isRight()) { - Status status = item.asRight(); - if (status.getId().length() < topId.length() || status.getId().compareTo(topId) < 0) { - - iterator.remove(); - } - } else { - Placeholder placeholder = item.asLeft(); - if (placeholder.getId().length() < topId.length() || placeholder.getId().compareTo(topId) < 0) { - - iterator.remove(); - } - } - - } - } - - this.statuses.addAll(statuses); - this.updateAdapter(); - } - this.bottomLoading = false; - - }, - (e) -> { - this.initialUpdateFailed = true; - // Indicate that we are not loading anymore - this.progressBar.setVisibility(View.GONE); - this.swipeRefreshLayout.setRefreshing(false); - }); - } - - private void setupTimelinePreferences() { - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); - boolean filter = preferences.getBoolean("tabFilterHomeReplies", true); - filterRemoveReplies = kind == Kind.HOME && !filter; - - filter = preferences.getBoolean("tabFilterHomeBoosts", true); - filterRemoveReblogs = kind == Kind.HOME && !filter; - reloadFilters(false); - } - - private static boolean filterContextMatchesKind(Kind kind, List filterContext) { - // home, notifications, public, thread - switch (kind) { - case HOME: - case LIST: - return filterContext.contains(Filter.HOME); - case PUBLIC_FEDERATED: - case PUBLIC_LOCAL: - case TAG: - return filterContext.contains(Filter.PUBLIC); - case FAVOURITES: - return (filterContext.contains(Filter.PUBLIC) || filterContext.contains(Filter.NOTIFICATIONS)); - case USER: - case USER_WITH_REPLIES: - case USER_PINNED: - return filterContext.contains(Filter.ACCOUNT); - default: - return false; - } - } - - @Override - protected boolean filterIsRelevant(@NonNull Filter filter) { - return filterContextMatchesKind(kind, filter.getContext()); - } - - @Override - protected void refreshAfterApplyingFilters() { - fullyRefresh(); - } - - private void setupSwipeRefreshLayout() { - swipeRefreshLayout.setEnabled(isSwipeToRefreshEnabled); - if (isSwipeToRefreshEnabled) { - swipeRefreshLayout.setOnRefreshListener(this); - swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - } - } - - private void setupRecyclerView() { - recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull)); - Context context = recyclerView.getContext(); - recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - recyclerView.setLayoutManager(layoutManager); - DividerItemDecoration divider = new DividerItemDecoration( - context, layoutManager.getOrientation()); - recyclerView.addItemDecoration(divider); - - // CWs are expanded without animation, buttons animate itself, we don't need it basically - ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - recyclerView.setAdapter(adapter); - } - - private void deleteStatusById(String id) { - for (int i = 0; i < statuses.size(); i++) { - Either either = statuses.get(i); - if (either.isRight() - && id.equals(either.asRight().getId())) { - statuses.remove(either); - updateAdapter(); - break; - } - } - if (statuses.size() == 0) { - showNothing(); - } - } - - private void showNothing() { - statusView.setVisibility(View.VISIBLE); - statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't - * guaranteed to be set until then. */ - if (actionButtonPresent()) { - /* Use a modified scroll listener that both loads more statuses as it goes, and hides - * the follow button on down-scroll. */ - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, RecyclerView view) { - TimelineFragment.this.onLoadMore(); - } - }; - } else { - // Just use the basic scroll listener to load more statuses. - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onLoadMore(int totalItemsCount, RecyclerView view) { - TimelineFragment.this.onLoadMore(); - } - }; - } - recyclerView.addOnScrollListener(scrollListener); - - if (!eventRegistered) { - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - FavoriteEvent favEvent = ((FavoriteEvent) event); - handleFavEvent(favEvent); - } else if (event instanceof ReblogEvent) { - ReblogEvent reblogEvent = (ReblogEvent) event; - handleReblogEvent(reblogEvent); - } else if (event instanceof BookmarkEvent) { - BookmarkEvent bookmarkEvent = (BookmarkEvent) event; - handleBookmarkEvent(bookmarkEvent); - } else if (event instanceof MuteConversationEvent) { - MuteConversationEvent muteEvent = (MuteConversationEvent) event; - handleMuteConversationEvent(muteEvent); - } else if (event instanceof UnfollowEvent) { - if (kind == Kind.HOME) { - String id = ((UnfollowEvent) event).getAccountId(); - removeAllByAccountId(id); - } - } else if (event instanceof BlockEvent) { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - String id = ((BlockEvent) event).getAccountId(); - removeAllByAccountId(id); - } - } else if (event instanceof MuteEvent) { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - String id = ((MuteEvent) event).getAccountId(); - removeAllByAccountId(id); - } - } else if (event instanceof DomainMuteEvent) { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - String instance = ((DomainMuteEvent) event).getInstance(); - removeAllByInstance(instance); - } - } else if (event instanceof StatusDeletedEvent) { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - String id = ((StatusDeletedEvent) event).getStatusId(); - deleteStatusById(id); - } - } else if (event instanceof StatusComposedEvent) { - Status status = ((StatusComposedEvent) event).getStatus(); - handleStatusComposeEvent(status); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - }); - eventRegistered = true; - } - } - - @Override - public void onRefresh() { - if (isSwipeToRefreshEnabled) - swipeRefreshLayout.setEnabled(true); - this.statusView.setVisibility(View.GONE); - isNeedRefresh = false; - if (this.initialUpdateFailed) { - updateCurrent(); - } - - this.loadAbove(); - - } - - private void loadAbove() { - String firstOrNull = null; - String secondOrNull = null; - for (int i = 0; i < this.statuses.size(); i++) { - Either status = this.statuses.get(i); - if (status.isRight()) { - firstOrNull = status.asRight().getId(); - if (i + 1 < statuses.size() && statuses.get(i + 1).isRight()) { - secondOrNull = statuses.get(i + 1).asRight().getId(); - } - break; - } - } - if (firstOrNull != null) { - this.sendFetchTimelineRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1); - } else { - this.sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1); - } - } - - @Override - public void onReply(int position) { - super.reply(statuses.get(position).asRight()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Status status = statuses.get(position).asRight(); - timelineCases.reblog(status, reblog) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (newStatus) -> setRebloggedForStatus(position, status, reblog), - (err) -> Log.d(TAG, "Failed to reblog status " + status.getId(), err) - ); - } - - private void setRebloggedForStatus(int position, Status status, boolean reblog) { - status.setReblogged(reblog); - - if (status.getReblog() != null) { - status.getReblog().setReblogged(reblog); - } - - Pair actual = - findStatusAndPosition(position, status); - if (actual == null) return; - - StatusViewData newViewData = - new StatusViewData.Builder(actual.first) - .setReblogged(reblog) - .createStatusViewData(); - statuses.setPairedItem(actual.second, newViewData); - updateAdapter(); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Status status = statuses.get(position).asRight(); - - timelineCases.favourite(status, favourite) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (newStatus) -> setFavouriteForStatus(position, newStatus, favourite), - (err) -> Log.d(TAG, "Failed to favourite status " + status.getId(), err) - ); - } - - private void setFavouriteForStatus(int position, Status status, boolean favourite) { - status.setFavourited(favourite); - - if (status.getReblog() != null) { - status.getReblog().setFavourited(favourite); - } - - Pair actual = - findStatusAndPosition(position, status); - if (actual == null) return; - - StatusViewData newViewData = new StatusViewData - .Builder(actual.first) - .setFavourited(favourite) - .createStatusViewData(); - statuses.setPairedItem(actual.second, newViewData); - updateAdapter(); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Status status = statuses.get(position).asRight(); - - timelineCases.bookmark(status, bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (newStatus) -> setBookmarkForStatus(position, newStatus, bookmark), - (err) -> Log.d(TAG, "Failed to favourite status " + status.getId(), err) - ); - } - - private void setBookmarkForStatus(int position, Status status, boolean bookmark) { - status.setBookmarked(bookmark); - - if (status.getReblog() != null) { - status.getReblog().setBookmarked(bookmark); - } - - Pair actual = - findStatusAndPosition(position, status); - if (actual == null) return; - - StatusViewData newViewData = new StatusViewData - .Builder(actual.first) - .setBookmarked(bookmark) - .createStatusViewData(); - statuses.setPairedItem(actual.second, newViewData); - updateAdapter(); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - - final Status status = statuses.get(position).asRight(); - - Poll votedPoll = status.getActionableStatus().getPoll().votedCopy(choices); - - setVoteForPoll(position, status, votedPoll); - - timelineCases.voteInPoll(status, choices) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) - .subscribe( - (newPoll) -> setVoteForPoll(position, status, newPoll), - (t) -> Log.d(TAG, - "Failed to vote in poll: " + status.getId(), t) - ); - } - - private void setVoteForPoll(int position, Status status, Poll newPoll) { - Pair actual = - findStatusAndPosition(position, status); - if (actual == null) return; - - StatusViewData newViewData = new StatusViewData - .Builder(actual.first) - .setPoll(newPoll) - .createStatusViewData(); - statuses.setPairedItem(actual.second, newViewData); - updateAdapter(); - } - - @Override - public void onMore(@NonNull View view, final int position) { - super.more(statuses.get(position).asRight(), view, position); - } - - @Override - public void onOpenReblog(int position) { - super.openReblog(statuses.get(position).asRight()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - StatusViewData newViewData = new StatusViewData.Builder( - ((StatusViewData.Concrete) statuses.getPairedItem(position))) - .setIsExpanded(expanded).createStatusViewData(); - statuses.setPairedItem(position, newViewData); - updateAdapter(); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - StatusViewData newViewData = new StatusViewData.Builder( - ((StatusViewData.Concrete) statuses.getPairedItem(position))) - .setIsShowingSensitiveContent(isShowing).createStatusViewData(); - statuses.setPairedItem(position, newViewData); - updateAdapter(); - } - - - @Override - public void onShowReblogs(int position) { - String statusId = statuses.get(position).asRight().getId(); - Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId); - ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); - } - - @Override - public void onShowFavs(int position) { - String statusId = statuses.get(position).asRight().getId(); - Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId); - ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); - } - - @Override - public void onLoadMore(int position) { - //check bounds before accessing list, - if (statuses.size() >= position && position > 0) { - Status fromStatus = statuses.get(position - 1).asRightOrNull(); - Status toStatus = statuses.get(position + 1).asRightOrNull(); - String maxMinusOne = - statuses.size() > position + 1 && statuses.get(position + 2).isRight() - ? statuses.get(position + 1).asRight().getId() - : null; - if (fromStatus == null || toStatus == null) { - Log.e(TAG, "Failed to load more at " + position + ", wrong placeholder position"); - return; - } - sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), maxMinusOne, - FetchEnd.MIDDLE, position); - - Placeholder placeholder = statuses.get(position).asLeft(); - StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.getId(), true); - statuses.setPairedItem(position, newViewData); - updateAdapter(); - } else { - Log.e(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - if (position < 0 || position >= statuses.size()) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); - return; - } - - StatusViewData status = statuses.getPairedItem(position); - if (!(status instanceof StatusViewData.Concrete)) { - // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't - // check for null values when adding values to it although this doesn't seem to be an issue. - Log.e(TAG, String.format( - "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", - status == null ? "" : status.getClass().getSimpleName(), - position, - statuses.size() - 1 - )); - return; - } - - StatusViewData updatedStatus = new StatusViewData.Builder((StatusViewData.Concrete) status) - .setCollapsed(isCollapsed) - .createStatusViewData(); - statuses.setPairedItem(position, updatedStatus); - updateAdapter(); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Status status = statuses.get(position).asRightOrNull(); - if (status == null) return; - super.viewMedia(attachmentIndex, status, view); - } - - @Override - public void onViewThread(int position) { - super.viewThread(statuses.get(position).asRight()); - } - - @Override - public void onViewTag(String tag) { - if (kind == Kind.TAG && tags.size() == 1 && tags.contains(tag)) { - // If already viewing a tag page, then ignore any request to view that tag again. - return; - } - super.viewTag(tag); - } - - @Override - public void onViewAccount(String id) { - if ((kind == Kind.USER || kind == Kind.USER_WITH_REPLIES) && this.id.equals(id)) { - /* If already viewing an account page, then any requests to view that account page - * should be ignored. */ - return; - } - super.viewAccount(id); - } - - private void onPreferenceChanged(String key) { - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); - switch (key) { - case "fabHide": { - hideFab = sharedPreferences.getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - boolean oldMediaPreviewEnabled = adapter.getMediaPreviewEnabled(); - if (enabled != oldMediaPreviewEnabled) { - adapter.setMediaPreviewEnabled(enabled); - 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; - } - case Filter.HOME: - case Filter.NOTIFICATIONS: - case Filter.THREAD: - case Filter.PUBLIC: - case Filter.ACCOUNT: { - if (filterContextMatchesKind(kind, Collections.singletonList(key))) { - reloadFilters(true); - } - break; - } - case "alwaysShowSensitiveMedia": { - //it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - break; - } - } - } - - @Override - public void removeItem(int position) { - statuses.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // using iterator to safely remove items while iterating - Iterator> iterator = statuses.iterator(); - while (iterator.hasNext()) { - Status status = iterator.next().asRightOrNull(); - if (status != null && - (status.getAccount().getId().equals(accountId) || status.getActionableStatus().getAccount().getId().equals(accountId))) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void removeAllByInstance(String instance) { - // using iterator to safely remove items while iterating - Iterator> iterator = statuses.iterator(); - while (iterator.hasNext()) { - Status status = iterator.next().asRightOrNull(); - if (status != null && LinkHelper.getDomain(status.getAccount().getUrl()).equals(instance)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (didLoadEverythingBottom || bottomLoading) { - return; - } - - if (statuses.size() == 0) { - sendInitialRequest(); - return; - } - - bottomLoading = true; - - Either last = statuses.get(statuses.size() - 1); - Placeholder placeholder; - if (last.isRight()) { - final String placeholderId = StringUtils.dec(last.asRight().getId()); - placeholder = new Placeholder(placeholderId); - statuses.add(new Either.Left<>(placeholder)); - } else { - placeholder = last.asLeft(); - } - statuses.setPairedItem(statuses.size() - 1, - new StatusViewData.Placeholder(placeholder.getId(), true)); - - updateAdapter(); - - String bottomId = null; - if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { - bottomId = this.nextId; - } else { - final ListIterator> iterator = - this.statuses.listIterator(this.statuses.size()); - while (iterator.hasPrevious()) { - Either previous = iterator.previous(); - if (previous.isRight()) { - bottomId = previous.asRight().getId(); - break; - } - } - } - sendFetchTimelineRequest(bottomId, null, null, FetchEnd.BOTTOM, -1); - } - - private void fullyRefresh() { - statuses.clear(); - updateAdapter(); - bottomLoading = true; - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1); - } - - private boolean actionButtonPresent() { - return kind != Kind.TAG && kind != Kind.FAVOURITES && kind != Kind.BOOKMARKS && - getActivity() instanceof ActionButtonActivity; - } - - private void jumpToTop() { - if (isAdded()) { - layoutManager.scrollToPosition(0); - recyclerView.stopScroll(); - scrollListener.reset(); - } - } - - private Single>> getFetchCallByTimelineType(String fromId, String uptoId) { - MastodonApi api = mastodonApi; - switch (kind) { - default: - case HOME: - return api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE); - case PUBLIC_FEDERATED: - return api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE); - case PUBLIC_LOCAL: - return api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE); - case TAG: - String firstHashtag = tags.get(0); - List additionalHashtags = tags.subList(1, tags.size()); - return api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE); - case USER: - return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, true, null, null); - case USER_PINNED: - return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, true); - case USER_WITH_REPLIES: - return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, null); - case FAVOURITES: - return api.favourites(fromId, uptoId, LOAD_AT_ONCE); - case BOOKMARKS: - return api.bookmarks(fromId, uptoId, LOAD_AT_ONCE); - case LIST: - return api.listTimeline(id, fromId, uptoId, LOAD_AT_ONCE); - } - } - - private void sendFetchTimelineRequest(@Nullable String maxId, @Nullable String sinceId, - @Nullable String sinceIdMinusOne, - final FetchEnd fetchEnd, final int pos) { - if (isAdded() && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && progressBar.getVisibility() != View.VISIBLE) && !isSwipeToRefreshEnabled) - topProgressBar.show(); - - if (kind == Kind.HOME) { - TimelineRequestMode mode; - // allow getting old statuses/fallbacks for network only for for bottom loading - if (fetchEnd == FetchEnd.BOTTOM) { - mode = TimelineRequestMode.ANY; - } else { - mode = TimelineRequestMode.NETWORK; - } - timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - result -> onFetchTimelineSuccess(result, fetchEnd, pos), - err -> onFetchTimelineFailure(err, fetchEnd, pos) - ); - } else { - getFetchCallByTimelineType(maxId, sinceId) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - if (response.isSuccessful()) { - @Nullable - String newNextId = extractNextId(response); - if (newNextId != null) { - // when we reach the bottom of the list, we won't have a new link. If - // we blindly write `null` here we will start loading from the top - // again. - nextId = newNextId; - } - onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos); - } else { - onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - err -> onFetchTimelineFailure(err, fetchEnd, pos) - ); - } - } - - @Nullable - private String extractNextId(Response response) { - String linkHeader = response.headers().get("Link"); - if (linkHeader == null) { - return null; - } - List links = HttpHeaderLink.parse(linkHeader); - HttpHeaderLink nextHeader = HttpHeaderLink.findByRelationType(links, "next"); - if (nextHeader == null) { - return null; - } - Uri nextLink = nextHeader.uri; - if (nextLink == null) { - return null; - } - return nextLink.getQueryParameter("max_id"); - } - - private void onFetchTimelineSuccess(List> statuses, - 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); - switch (fetchEnd) { - case TOP: { - updateStatuses(statuses, fullFetch); - break; - } - case MIDDLE: { - replacePlaceholderWithStatuses(statuses, fullFetch, pos); - break; - } - case BOTTOM: { - if (!this.statuses.isEmpty() - && !this.statuses.get(this.statuses.size() - 1).isRight()) { - this.statuses.remove(this.statuses.size() - 1); - updateAdapter(); - } - - if (!statuses.isEmpty() && !statuses.get(statuses.size() - 1).isRight()) { - // Removing placeholder if it's the last one from the cache - statuses.remove(statuses.size() - 1); - } - int oldSize = this.statuses.size(); - if (this.statuses.size() > 1) { - addItems(statuses); - } else { - updateStatuses(statuses, fullFetch); - } - if (this.statuses.size() == oldSize) { - // This may be a brittle check but seems like it works - // Can we check it using headers somehow? Do all server support them? - didLoadEverythingBottom = true; - } - break; - } - } - if (isAdded()) { - topProgressBar.hide(); - updateBottomLoadingState(fetchEnd); - progressBar.setVisibility(View.GONE); - swipeRefreshLayout.setRefreshing(false); - swipeRefreshLayout.setEnabled(true); - if (this.statuses.size() == 0) { - this.showNothing(); - } else { - this.statusView.setVisibility(View.GONE); - } - } - } - - private void onFetchTimelineFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - if (isAdded()) { - swipeRefreshLayout.setRefreshing(false); - topProgressBar.hide(); - - if (fetchEnd == FetchEnd.MIDDLE && !statuses.get(position).isRight()) { - Placeholder placeholder = statuses.get(position).asLeftOrNull(); - StatusViewData newViewData; - if (placeholder == null) { - Status above = statuses.get(position - 1).asRight(); - String newId = StringUtils.dec(above.getId()); - placeholder = new Placeholder(newId); - } - newViewData = new StatusViewData.Placeholder(placeholder.getId(), false); - statuses.setPairedItem(position, newViewData); - updateAdapter(); - } else if (this.statuses.isEmpty()) { - swipeRefreshLayout.setEnabled(false); - this.statusView.setVisibility(View.VISIBLE); - if (throwable instanceof IOException) { - this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { - this.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - this.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { - this.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - } - - Log.e(TAG, "Fetch Failure: " + throwable.getMessage()); - updateBottomLoadingState(fetchEnd); - progressBar.setVisibility(View.GONE); - } - } - - private void updateBottomLoadingState(FetchEnd fetchEnd) { - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - } - - private void filterStatuses(List> statuses) { - Iterator> it = statuses.iterator(); - while (it.hasNext()) { - Status status = it.next().asRightOrNull(); - if (status != null - && ((status.getInReplyToId() != null && filterRemoveReplies) - || (status.getReblog() != null && filterRemoveReblogs) - || shouldFilterStatus(status.getActionableStatus()))) { - it.remove(); - } - } - } - - private void updateStatuses(List> newStatuses, boolean fullFetch) { - if (ListUtils.isEmpty(newStatuses)) { - updateAdapter(); - return; - } - - if (statuses.isEmpty()) { - statuses.addAll(newStatuses); - } else { - Either lastOfNew = newStatuses.get(newStatuses.size() - 1); - int index = statuses.indexOf(lastOfNew); - - if (index >= 0) { - statuses.subList(0, index).clear(); - } - - int newIndex = newStatuses.indexOf(statuses.get(0)); - if (newIndex == -1) { - if (index == -1 && fullFetch) { - String placeholderId = StringUtils.inc( - CollectionsKt.last(newStatuses, Either::isRight).asRight().getId()); - newStatuses.add(new Either.Left<>(new Placeholder(placeholderId))); - } - statuses.addAll(0, newStatuses); - } else { - statuses.addAll(0, newStatuses.subList(0, newIndex)); - } - } - // Remove all consecutive placeholders - removeConsecutivePlaceholders(); - updateAdapter(); - } - - private void removeConsecutivePlaceholders() { - for (int i = 0; i < statuses.size() - 1; i++) { - if (statuses.get(i).isLeft() && statuses.get(i + 1).isLeft()) { - statuses.remove(i); - } - } - } - - private void addItems(List> newStatuses) { - if (ListUtils.isEmpty(newStatuses)) { - return; - } - Either last = null; - for (int i = statuses.size() - 1; i >= 0; i--) { - if (statuses.get(i).isRight()) { - last = statuses.get(i); - break; - } - } - // 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 && !newStatuses.contains(last)) { - statuses.addAll(newStatuses); - removeConsecutivePlaceholders(); - updateAdapter(); - } - } - - /** - * For certain requests we don't want to see placeholders, they will be removed some other way - */ - private void clearPlaceholdersForResponse(List> statuses) { - CollectionsKt.removeAll(statuses, Either::isLeft); - } - - private void replacePlaceholderWithStatuses(List> newStatuses, - boolean fullFetch, int pos) { - Either placeholder = statuses.get(pos); - if (placeholder.isLeft()) { - statuses.remove(pos); - } - - if (ListUtils.isEmpty(newStatuses)) { - updateAdapter(); - return; - } - - if (fullFetch) { - newStatuses.add(placeholder); - } - - statuses.addAll(pos, newStatuses); - removeConsecutivePlaceholders(); - - updateAdapter(); - - } - - private int findStatusOrReblogPositionById(@NonNull String statusId) { - for (int i = 0; i < statuses.size(); i++) { - Status status = statuses.get(i).asRightOrNull(); - if (status != null - && (statusId.equals(status.getId()) - || (status.getReblog() != null - && statusId.equals(status.getReblog().getId())))) { - return i; - } - } - return -1; - } - - private final Function1> statusLifter = - Either.Right::new; - - @Nullable - private Pair - findStatusAndPosition(int position, Status status) { - StatusViewData.Concrete statusToUpdate; - int positionToUpdate; - StatusViewData someOldViewData = statuses.getPairedItem(position); - - // Unlikely, but data could change between the request and response - if ((someOldViewData instanceof StatusViewData.Placeholder) || - !((StatusViewData.Concrete) someOldViewData).getId().equals(status.getId())) { - // try to find the status we need to update - int foundPos = statuses.indexOf(new Either.Right<>(status)); - if (foundPos < 0) return null; // okay, it's hopeless, give up - statusToUpdate = ((StatusViewData.Concrete) - statuses.getPairedItem(foundPos)); - positionToUpdate = position; - } else { - statusToUpdate = (StatusViewData.Concrete) someOldViewData; - positionToUpdate = position; - } - return new Pair<>(statusToUpdate, positionToUpdate); - } - - private void handleReblogEvent(@NonNull ReblogEvent reblogEvent) { - int pos = findStatusOrReblogPositionById(reblogEvent.getStatusId()); - if (pos < 0) return; - Status status = statuses.get(pos).asRight(); - setRebloggedForStatus(pos, status, reblogEvent.getReblog()); - } - - private void handleFavEvent(@NonNull FavoriteEvent favEvent) { - int pos = findStatusOrReblogPositionById(favEvent.getStatusId()); - if (pos < 0) return; - Status status = statuses.get(pos).asRight(); - setFavouriteForStatus(pos, status, favEvent.getFavourite()); - } - - private void handleBookmarkEvent(@NonNull BookmarkEvent bookmarkEvent) { - int pos = findStatusOrReblogPositionById(bookmarkEvent.getStatusId()); - if (pos < 0) return; - Status status = statuses.get(pos).asRight(); - setBookmarkForStatus(pos, status, bookmarkEvent.getBookmark()); - } - - private void handleMuteConversationEvent(@NonNull MuteConversationEvent event) { - fullyRefresh(); - } - - private void handleStatusComposeEvent(@NonNull Status status) { - switch (kind) { - case HOME: - case PUBLIC_FEDERATED: - case PUBLIC_LOCAL: - break; - case USER: - case USER_WITH_REPLIES: - if (status.getAccount().getId().equals(id)) { - break; - } else { - return; - } - case TAG: - case FAVOURITES: - case LIST: - return; - } - onRefresh(); - } - - private List> liftStatusList(List list) { - return CollectionsKt.map(list, statusLifter); - } - - private void updateAdapter() { - differ.submitList(statuses.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being in the first position - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - if (isSwipeToRefreshEnabled) - recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - else - recyclerView.scrollToPosition(0); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final TimelineAdapter.AdapterDataSource dataSource = - new TimelineAdapter.AdapterDataSource() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public StatusViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback() { - - @Override - public boolean areItemsTheSame(StatusViewData oldItem, StatusViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(StatusViewData oldItem, @NonNull StatusViewData newItem) { - return false; //Items are different always. It allows to refresh timestamp on every view holder update - } - - @Nullable - @Override - public Object getChangePayload(@NonNull StatusViewData oldItem, @NonNull StatusViewData newItem) { - if (oldItem.deepEquals(newItem)) { - //If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - AccessibilityManager a11yManager; - boolean talkBackWasEnabled; - - @Override - public void onResume() { - super.onResume(); - a11yManager = Objects.requireNonNull( - ContextCompat.getSystemService(requireContext(), AccessibilityManager.class) - ); - boolean wasEnabled = this.talkBackWasEnabled; - talkBackWasEnabled = a11yManager.isEnabled(); - Log.d(TAG, "talkback was enabled: " + wasEnabled + ", now " + talkBackWasEnabled); - if (talkBackWasEnabled && !wasEnabled) { - this.adapter.notifyDataSetChanged(); - } - startUpdateTimestamp(); - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private void startUpdateTimestamp() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - if (!useAbsoluteTime) { - Observable.interval(1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) - .subscribe( - interval -> updateAdapter() - ); - } - - } - - @Override - public void onReselect() { - jumpToTop(); - } - - @Override - public void refreshContent() { - if (isAdded()) - onRefresh(); - else - isNeedRefresh = true; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt new file mode 100644 index 000000000..bb42e46fc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt @@ -0,0 +1,1265 @@ +/* Copyright 2021 Tusky Contributors + * + * 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.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityManager +import androidx.core.content.ContextCompat +import androidx.core.util.Pair +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.adapter.TimelineAdapter +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.BookmarkEvent +import com.keylesspalace.tusky.appstore.DomainMuteEvent +import com.keylesspalace.tusky.appstore.Event +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusComposedEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent +import com.keylesspalace.tusky.databinding.FragmentTimelineBinding +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.repository.Placeholder +import com.keylesspalace.tusky.repository.TimelineRepository +import com.keylesspalace.tusky.repository.TimelineRequestMode +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.Either.Left +import com.keylesspalace.tusky.util.Either.Right +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.PairedList +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.ViewDataUtils +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDispose +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import retrofit2.Response +import java.io.IOException +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable, ReselectableFragment, RefreshableFragment { + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var timelineRepo: TimelineRepository + + @Inject + lateinit var accountManager: AccountManager + + private val binding by viewBinding(FragmentTimelineBinding::bind) + + private var kind: Kind? = null + private var id: String? = null + private var tags: List = emptyList() + + private lateinit var adapter: TimelineAdapter + + private var isSwipeToRefreshEnabled = true + private var isNeedRefresh = false + + private var eventRegistered = false + + /** + * For some timeline kinds we must use LINK headers and not just status ids. + */ + private var nextId: String? = null + private var layoutManager: LinearLayoutManager? = null + private var scrollListener: EndlessOnScrollListener? = null + private var filterRemoveReplies = false + private var filterRemoveReblogs = false + private var hideFab = false + private var bottomLoading = false + private var didLoadEverythingBottom = false + private var alwaysShowSensitiveMedia = false + private var alwaysOpenSpoiler = false + private var initialUpdateFailed = false + + private val statuses = PairedList, StatusViewData> { input -> + val status = input.asRightOrNull() + if (status != null) { + ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia, + alwaysOpenSpoiler + ) + } else { + val (id1) = input.asLeft() + StatusViewData.Placeholder(id1, false) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val arguments = requireArguments() + kind = Kind.valueOf(arguments.getString(KIND_ARG)!!) + if (kind == Kind.USER || kind == Kind.USER_PINNED || kind == Kind.USER_WITH_REPLIES || kind == Kind.LIST) { + id = arguments.getString(ID_ARG)!! + } + if (kind == Kind.TAG) { + tags = arguments.getStringArrayList(HASHTAGS_ARG)!! + } + + isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) CardViewMode.INDENTED else CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) + adapter = TimelineAdapter(dataSource, statusDisplayOptions, this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupSwipeRefreshLayout() + setupRecyclerView() + updateAdapter() + setupTimelinePreferences() + if (statuses.isEmpty()) { + binding.progressBar.show() + bottomLoading = true + sendInitialRequest() + } else { + binding.progressBar.hide() + if (isNeedRefresh) { + onRefresh() + } + } + } + + private fun sendInitialRequest() { + if (kind == Kind.HOME) { + tryCache() + } else { + sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) + } + } + + private fun tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe { statuses: List> -> + val mutableStatusResponse = statuses.toMutableList() + filterStatuses(mutableStatusResponse) + if (statuses.size > 1) { + clearPlaceholdersForResponse(mutableStatusResponse) + this.statuses.clear() + this.statuses.addAll(statuses) + updateAdapter() + binding.progressBar.hide() + // Request statuses including current top to refresh all of them + } + updateCurrent() + loadAbove() + } + } + + private fun updateCurrent() { + if (statuses.isEmpty()) { + return + } + val topId = statuses.first { status -> status.isRight() }!!.asRight().id + timelineRepo.getStatuses(topId, null, null, LOAD_AT_ONCE, + TimelineRequestMode.NETWORK) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { statuses: List> -> + + initialUpdateFailed = false + // When cached timeline is too old, we would replace it with nothing + if (statuses.isNotEmpty()) { + val mutableStatuses = statuses.toMutableList() + filterStatuses(mutableStatuses) + if (!this.statuses.isEmpty()) { + // clear old cached statuses + val iterator = this.statuses.iterator() + while (iterator.hasNext()) { + val item = iterator.next() + if (item.isRight()) { + val (id1) = item.asRight() + if (id1.length < topId.length || id1 < topId) { + iterator.remove() + } + } else { + val (id1) = item.asLeft() + if (id1.length < topId.length || id1 < topId) { + iterator.remove() + } + } + } + } + this.statuses.addAll(mutableStatuses) + updateAdapter() + } + bottomLoading = false + }, + { t: Throwable? -> + Log.d(TAG, "Failed updating timeline", t) + initialUpdateFailed = true + // Indicate that we are not loading anymore + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + }) + } + + private fun setupTimelinePreferences() { + alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler + if (kind == Kind.HOME) { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + filterRemoveReplies = !preferences.getBoolean("tabFilterHomeReplies", true) + filterRemoveReblogs = !preferences.getBoolean("tabFilterHomeBoosts", true) + } + reloadFilters(false) + } + + override fun filterIsRelevant(filter: Filter): Boolean { + return filterContextMatchesKind(kind, filter.context) + } + + override fun refreshAfterApplyingFilters() { + fullyRefresh() + } + + private fun setupSwipeRefreshLayout() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun setupRecyclerView() { + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate(binding.recyclerView, this) + { pos -> statuses.getPairedItemOrNull(pos) } + ) + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + + // CWs are expanded without animation, buttons animate itself, we don't need it basically + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + binding.recyclerView.adapter = adapter + } + + private fun deleteStatusById(id: String) { + for (i in statuses.indices) { + val either = statuses[i] + if (either.isRight() && id == either.asRight().id) { + statuses.remove(either) + updateAdapter() + break + } + } + if (statuses.isEmpty()) { + showEmptyView() + } + } + + private fun showEmptyView() { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't + * guaranteed to be set until then. */ + scrollListener = if (actionButtonPresent()) { + /* Use a modified scroll listener that both loads more statuses as it goes, and hides + * the follow button on down-scroll. */ + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + hideFab = preferences.getBoolean("fabHide", false) + object : EndlessOnScrollListener(layoutManager) { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(view, dx, dy) + val composeButton = (activity as ActionButtonActivity).actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + this@TimelineFragment.onLoadMore() + } + } + } else { + // Just use the basic scroll listener to load more statuses. + object : EndlessOnScrollListener(layoutManager) { + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + this@TimelineFragment.onLoadMore() + } + } + }.also { + binding.recyclerView.addOnScrollListener(it) + } + + if (!eventRegistered) { + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe { event: Event? -> + when (event) { + is FavoriteEvent -> handleFavEvent(event) + is ReblogEvent -> handleReblogEvent(event) + is BookmarkEvent -> handleBookmarkEvent(event) + is MuteConversationEvent -> fullyRefresh() + is UnfollowEvent -> { + if (kind == Kind.HOME) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is BlockEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is MuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is DomainMuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val instance = event.instance + removeAllByInstance(instance) + } + } + is StatusDeletedEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.statusId + deleteStatusById(id) + } + } + is StatusComposedEvent -> { + val status = event.status + handleStatusComposeEvent(status) + } + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + eventRegistered = true + } + } + + override fun onRefresh() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.statusView.hide() + isNeedRefresh = false + if (initialUpdateFailed) { + updateCurrent() + } + loadAbove() + } + + private fun loadAbove() { + var firstOrNull: String? = null + var secondOrNull: String? = null + for (i in statuses.indices) { + val status = statuses[i] + if (status.isRight()) { + firstOrNull = status.asRight().id + if (i + 1 < statuses.size && statuses[i + 1].isRight()) { + secondOrNull = statuses[i + 1].asRight().id + } + break + } + } + if (firstOrNull != null) { + sendFetchTimelineRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) + } else { + sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) + } + } + + override fun onReply(position: Int) { + super.reply(statuses[position].asRight()) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val status = statuses[position].asRight() + timelineCases.reblog(status, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { newStatus: Status -> setRebloggedForStatus(position, newStatus, reblog) } + ) { t: Throwable? -> Log.d(TAG, "Failed to reblog status " + status.id, t) } + } + + private fun setRebloggedForStatus(position: Int, status: Status, reblog: Boolean) { + status.reblogged = reblog + if (status.reblog != null) { + status.reblog.reblogged = reblog + } + val actual = findStatusAndPosition(position, status) ?: return + val newViewData: StatusViewData = StatusViewData.Builder(actual.first) + .setReblogged(reblog) + .createStatusViewData() + statuses.setPairedItem(actual.second!!, newViewData) + updateAdapter() + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val status = statuses[position].asRight() + timelineCases.favourite(status, favourite) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { newStatus: Status -> setFavouriteForStatus(position, newStatus, favourite) }, + { t: Throwable? -> Log.d(TAG, "Failed to favourite status " + status.id, t) } + ) + } + + private fun setFavouriteForStatus(position: Int, status: Status, favourite: Boolean) { + status.favourited = favourite + if (status.reblog != null) { + status.reblog.favourited = favourite + } + val actual = findStatusAndPosition(position, status) ?: return + val newViewData: StatusViewData = StatusViewData.Builder(actual.first) + .setFavourited(favourite) + .createStatusViewData() + statuses.setPairedItem(actual.second!!, newViewData) + updateAdapter() + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val status = statuses[position].asRight() + timelineCases.bookmark(status, bookmark) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { newStatus: Status -> setBookmarkForStatus(position, newStatus, bookmark) }, + { t: Throwable? -> Log.d(TAG, "Failed to favourite status " + status.id, t) } + ) + } + + private fun setBookmarkForStatus(position: Int, status: Status, bookmark: Boolean) { + status.bookmarked = bookmark + if (status.reblog != null) { + status.reblog.bookmarked = bookmark + } + val actual = findStatusAndPosition(position, status) ?: return + val newViewData: StatusViewData = StatusViewData.Builder(actual.first) + .setBookmarked(bookmark) + .createStatusViewData() + statuses.setPairedItem(actual.second!!, newViewData) + updateAdapter() + } + + override fun onVoteInPoll(position: Int, choices: List) { + val status = statuses[position].asRight() + val votedPoll = status.actionableStatus.poll!!.votedCopy(choices) + setVoteForPoll(position, status, votedPoll) + timelineCases.voteInPoll(status, choices) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { newPoll: Poll -> setVoteForPoll(position, status, newPoll) }, + { t: Throwable? -> Log.d(TAG, "Failed to vote in poll: " + status.id, t) } + ) + } + + private fun setVoteForPoll(position: Int, status: Status, newPoll: Poll) { + val actual = findStatusAndPosition(position, status) ?: return + val newViewData: StatusViewData = StatusViewData.Builder(actual.first) + .setPoll(newPoll) + .createStatusViewData() + statuses.setPairedItem(actual.second!!, newViewData) + updateAdapter() + } + + override fun onMore(view: View, position: Int) { + super.more(statuses[position].asRight(), view, position) + } + + override fun onOpenReblog(position: Int) { + super.openReblog(statuses[position].asRight()) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val newViewData: StatusViewData = StatusViewData.Builder( + statuses.getPairedItem(position) as StatusViewData.Concrete) + .setIsExpanded(expanded).createStatusViewData() + statuses.setPairedItem(position, newViewData) + updateAdapter() + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val newViewData: StatusViewData = StatusViewData.Builder( + statuses.getPairedItem(position) as StatusViewData.Concrete) + .setIsShowingSensitiveContent(isShowing).createStatusViewData() + statuses.setPairedItem(position, newViewData) + updateAdapter() + } + + override fun onShowReblogs(position: Int) { + val statusId = statuses[position].asRight().id + val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onShowFavs(position: Int) { + val statusId = statuses[position].asRight().id + val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onLoadMore(position: Int) { + //check bounds before accessing list, + if (statuses.size >= position && position > 0) { + val fromStatus = statuses[position - 1].asRightOrNull() + val toStatus = statuses[position + 1].asRightOrNull() + val maxMinusOne = if (statuses.size > position + 1 && statuses[position + 2].isRight()) statuses[position + 1].asRight().id else null + if (fromStatus == null || toStatus == null) { + Log.e(TAG, "Failed to load more at $position, wrong placeholder position") + return + } + sendFetchTimelineRequest(fromStatus.id, toStatus.id, maxMinusOne, + FetchEnd.MIDDLE, position) + val (id1) = statuses[position].asLeft() + val newViewData: StatusViewData = StatusViewData.Placeholder(id1, true) + statuses.setPairedItem(position, newViewData) + updateAdapter() + } else { + Log.e(TAG, "error loading more") + } + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + if (position < 0 || position >= statuses.size) { + Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size - 1)) + return + } + val status = statuses.getPairedItem(position) + if (status !is StatusViewData.Concrete) { + // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't + // check for null values when adding values to it although this doesn't seem to be an issue. + Log.e(TAG, String.format( + "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", + status?.javaClass?.simpleName ?: "", + position, + statuses.size - 1 + )) + return + } + val updatedStatus: StatusViewData = StatusViewData.Builder(status) + .setCollapsed(isCollapsed) + .createStatusViewData() + statuses.setPairedItem(position, updatedStatus) + updateAdapter() + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = statuses.getOrNull(position)?.asRightOrNull() ?: return + super.viewMedia(attachmentIndex, status, view) + } + + override fun onViewThread(position: Int) { + super.viewThread(statuses[position].asRight()) + } + + override fun onViewTag(tag: String) { + if (kind == Kind.TAG && tags.size == 1 && tags.contains(tag)) { + // If already viewing a tag page, then ignore any request to view that tag again. + return + } + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + if ((kind == Kind.USER || kind == Kind.USER_WITH_REPLIES) && this.id == id) { + /* If already viewing an account page, then any requests to view that account page + * should be ignored. */ + return + } + super.viewAccount(id) + } + + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + fullyRefresh() + } + } + PrefKeys.TAB_FILTER_HOME_REPLIES -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + val oldRemoveReplies = filterRemoveReplies + filterRemoveReplies = kind == Kind.HOME && !filter + if (adapter.itemCount > 1 && oldRemoveReplies != filterRemoveReplies) { + fullyRefresh() + } + } + PrefKeys.TAB_FILTER_HOME_BOOSTS -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + val oldRemoveReblogs = filterRemoveReblogs + filterRemoveReblogs = kind == Kind.HOME && !filter + if (adapter.itemCount > 1 && oldRemoveReblogs != filterRemoveReblogs) { + fullyRefresh() + } + } + Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { + if (filterContextMatchesKind(kind, listOf(key))) { + reloadFilters(true) + } + } + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { + //it is ok if only newly loaded statuses are affected, no need to fully refresh + alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + } + } + } + + public override fun removeItem(position: Int) { + statuses.removeAt(position) + updateAdapter() + } + + private fun removeAllByAccountId(accountId: String) { + // using iterator to safely remove items while iterating + val iterator = statuses.iterator() + while (iterator.hasNext()) { + val status = iterator.next().asRightOrNull() + if (status != null && + (status.account.id == accountId || status.actionableStatus.account.id == accountId)) { + iterator.remove() + } + } + updateAdapter() + } + + private fun removeAllByInstance(instance: String) { + // using iterator to safely remove items while iterating + val iterator = statuses.iterator() + while (iterator.hasNext()) { + val status = iterator.next().asRightOrNull() + if (status != null && LinkHelper.getDomain(status.account.url) == instance) { + iterator.remove() + } + } + updateAdapter() + } + + private fun onLoadMore() { + if (didLoadEverythingBottom || bottomLoading) { + return + } + if (statuses.isEmpty()) { + sendInitialRequest() + return + } + bottomLoading = true + val last = statuses[statuses.size - 1] + val placeholder: Placeholder + if (last!!.isRight()) { + val placeholderId = last.asRight().id.dec() + placeholder = Placeholder(placeholderId) + statuses.add(Left(placeholder)) + } else { + placeholder = last.asLeft() + } + statuses.setPairedItem(statuses.size - 1, + StatusViewData.Placeholder(placeholder.id, true)) + updateAdapter() + + val bottomId: String? = if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { + nextId + } else { + statuses.lastOrNull { it.isRight() }?.asRight()?.id + } + + sendFetchTimelineRequest(bottomId, null, null, FetchEnd.BOTTOM, -1) + } + + private fun fullyRefresh() { + statuses.clear() + updateAdapter() + bottomLoading = true + sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) + } + + private fun actionButtonPresent(): Boolean { + return kind != Kind.TAG && kind != Kind.FAVOURITES && kind != Kind.BOOKMARKS && + activity is ActionButtonActivity + } + + private fun getFetchCallByTimelineType(fromId: String?, uptoId: String?): Single>> { + val api = mastodonApi + return when (kind) { + Kind.HOME -> api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE) + Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE) + Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE) + Kind.TAG -> { + val firstHashtag = tags[0] + val additionalHashtags = tags.subList(1, tags.size) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE) + } + Kind.USER -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, true, null, null) + Kind.USER_PINNED -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, null, null, true) + Kind.USER_WITH_REPLIES -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, null, null, null) + Kind.FAVOURITES -> api.favourites(fromId, uptoId, LOAD_AT_ONCE) + Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, LOAD_AT_ONCE) + Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, LOAD_AT_ONCE) + else -> api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE) + } + } + + private fun sendFetchTimelineRequest(maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, + fetchEnd: FetchEnd, pos: Int) { + if (isAdded && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && binding.progressBar.visibility != View.VISIBLE) && !isSwipeToRefreshEnabled) { + binding.topProgressBar.show() + } + if (kind == Kind.HOME) { + // allow getting old statuses/fallbacks for network only for for bottom loading + val mode = if (fetchEnd == FetchEnd.BOTTOM) { + TimelineRequestMode.ANY + } else { + TimelineRequestMode.NETWORK + } + timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { result: List> -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) }, + { t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) } + ) + } else { + getFetchCallByTimelineType(maxId, sinceId) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { response: Response> -> + if (response.isSuccessful) { + val newNextId = extractNextId(response) + if (newNextId != null) { + // when we reach the bottom of the list, we won't have a new link. If + // we blindly write `null` here we will start loading from the top + // again. + nextId = newNextId + } + onFetchTimelineSuccess(liftStatusList(response.body()!!).toMutableList(), fetchEnd, pos) + } else { + onFetchTimelineFailure(Exception(response.message()), fetchEnd, pos) + } + } + ) { t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) } + } + } + + private fun extractNextId(response: Response<*>): String? { + val linkHeader = response.headers()["Link"] ?: return null + val links = HttpHeaderLink.parse(linkHeader) + val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null + val nextLink = nextHeader.uri ?: return null + return nextLink.getQueryParameter("max_id") + } + + private fun onFetchTimelineSuccess(statuses: MutableList>, + fetchEnd: FetchEnd, pos: Int) { + + // We filled the hole (or reached the end) if the server returned less statuses than we + // we asked for. + val fullFetch = statuses.size >= LOAD_AT_ONCE + filterStatuses(statuses) + when (fetchEnd) { + FetchEnd.TOP -> { + updateStatuses(statuses, fullFetch) + } + FetchEnd.MIDDLE -> { + replacePlaceholderWithStatuses(statuses, fullFetch, pos) + } + FetchEnd.BOTTOM -> { + if (!this.statuses.isEmpty() + && !this.statuses[this.statuses.size - 1].isRight()) { + this.statuses.removeAt(this.statuses.size - 1) + updateAdapter() + } + if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { + // Removing placeholder if it's the last one from the cache + statuses.removeAt(statuses.size - 1) + } + val oldSize = this.statuses.size + if (this.statuses.size > 1) { + addItems(statuses) + } else { + updateStatuses(statuses, fullFetch) + } + if (this.statuses.size == oldSize) { + // This may be a brittle check but seems like it works + // Can we check it using headers somehow? Do all server support them? + didLoadEverythingBottom = true + } + } + } + if (isAdded) { + binding.topProgressBar.hide() + updateBottomLoadingState(fetchEnd) + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + binding.swipeRefreshLayout.isEnabled = true + if (this.statuses.size == 0) { + showEmptyView() + } else { + binding.statusView.hide() + } + } + } + + private fun onFetchTimelineFailure(throwable: Throwable, fetchEnd: FetchEnd, position: Int) { + if (isAdded) { + binding.swipeRefreshLayout.isRefreshing = false + binding.topProgressBar.hide() + if (fetchEnd == FetchEnd.MIDDLE && !statuses[position].isRight()) { + var placeholder = statuses[position].asLeftOrNull() + val newViewData: StatusViewData + if (placeholder == null) { + val (id1) = statuses[position - 1].asRight() + val newId = id1.dec() + placeholder = Placeholder(newId) + } + newViewData = StatusViewData.Placeholder(placeholder.id, false) + statuses.setPairedItem(position, newViewData) + updateAdapter() + } else if (statuses.isEmpty()) { + binding.swipeRefreshLayout.isEnabled = false + binding.statusView.visibility = View.VISIBLE + if (throwable is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.progressBar.visibility = View.VISIBLE + onRefresh() + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.progressBar.visibility = View.VISIBLE + onRefresh() + } + } + } + Log.e(TAG, "Fetch Failure: " + throwable.message) + updateBottomLoadingState(fetchEnd) + binding.progressBar.hide() + } + } + + private fun updateBottomLoadingState(fetchEnd: FetchEnd) { + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false + } + } + + private fun filterStatuses(statuses: MutableList>) { + val it = statuses.iterator() + while (it.hasNext()) { + val status = it.next().asRightOrNull() + if (status != null + && (status.inReplyToId != null && filterRemoveReplies + || status.reblog != null && filterRemoveReblogs + || shouldFilterStatus(status.actionableStatus))) { + it.remove() + } + } + } + + private fun updateStatuses(newStatuses: MutableList>, fullFetch: Boolean) { + if (newStatuses.isEmpty()) { + updateAdapter() + return + } + if (statuses.isEmpty()) { + statuses.addAll(newStatuses) + } else { + val lastOfNew = newStatuses[newStatuses.size - 1] + val index = statuses.indexOf(lastOfNew) + if (index >= 0) { + statuses.subList(0, index).clear() + } + val newIndex = newStatuses.indexOf(statuses[0]) + if (newIndex == -1) { + if (index == -1 && fullFetch) { + val placeholderId = newStatuses.last { status -> status.isRight() }.asRight().id.inc() + newStatuses.add(Left(Placeholder(placeholderId))) + } + statuses.addAll(0, newStatuses) + } else { + statuses.addAll(0, newStatuses.subList(0, newIndex)) + } + } + // Remove all consecutive placeholders + removeConsecutivePlaceholders() + updateAdapter() + } + + private fun removeConsecutivePlaceholders() { + for (i in 0 until statuses.size - 1) { + if (statuses[i].isLeft() && statuses[i + 1].isLeft()) { + statuses.removeAt(i) + } + } + } + + private fun addItems(newStatuses: List?>) { + if (newStatuses.isEmpty()) { + return + } + val last = statuses.last { status -> + status.isRight() + } + + // 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 && !newStatuses.contains(last)) { + statuses.addAll(newStatuses) + removeConsecutivePlaceholders() + updateAdapter() + } + } + + /** + * For certain requests we don't want to see placeholders, they will be removed some other way + */ + private fun clearPlaceholdersForResponse(statuses: MutableList>) { + statuses.removeAll{ status -> status.isLeft() } + } + + private fun replacePlaceholderWithStatuses(newStatuses: MutableList>, + fullFetch: Boolean, pos: Int) { + val placeholder = statuses[pos] + if (placeholder.isLeft()) { + statuses.removeAt(pos) + } + if (newStatuses.isEmpty()) { + updateAdapter() + return + } + if (fullFetch) { + newStatuses.add(placeholder) + } + statuses.addAll(pos, newStatuses) + removeConsecutivePlaceholders() + updateAdapter() + } + + private fun findStatusOrReblogPositionById(statusId: String): Int { + return statuses.indexOfFirst { either -> + val status = either.asRightOrNull() + status != null && + (statusId == status.id || + (status.reblog != null && statusId == status.reblog.id)) + } + } + + private val statusLifter: Function1> = { value -> Right(value) } + + private fun findStatusAndPosition(position: Int, status: Status): Pair? { + val statusToUpdate: StatusViewData.Concrete + val positionToUpdate: Int + val someOldViewData = statuses.getPairedItem(position) + + // Unlikely, but data could change between the request and response + if (someOldViewData is StatusViewData.Placeholder || + (someOldViewData as StatusViewData.Concrete).id != status.id) { + // try to find the status we need to update + val foundPos = statuses.indexOf(Right(status)) + if (foundPos < 0) return null // okay, it's hopeless, give up + statusToUpdate = statuses.getPairedItem(foundPos) as StatusViewData.Concrete + positionToUpdate = position + } else { + statusToUpdate = someOldViewData + positionToUpdate = position + } + return Pair(statusToUpdate, positionToUpdate) + } + + private fun handleReblogEvent(reblogEvent: ReblogEvent) { + val pos = findStatusOrReblogPositionById(reblogEvent.statusId) + if (pos < 0) return + val status = statuses[pos].asRight() + setRebloggedForStatus(pos, status, reblogEvent.reblog) + } + + private fun handleFavEvent(favEvent: FavoriteEvent) { + val pos = findStatusOrReblogPositionById(favEvent.statusId) + if (pos < 0) return + val status = statuses[pos].asRight() + setFavouriteForStatus(pos, status, favEvent.favourite) + } + + private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { + val pos = findStatusOrReblogPositionById(bookmarkEvent.statusId) + if (pos < 0) return + val status = statuses[pos].asRight() + setBookmarkForStatus(pos, status, bookmarkEvent.bookmark) + } + + private fun handleStatusComposeEvent(status: Status) { + when (kind) { + Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> onRefresh() + Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) { + onRefresh() + } else { + return + } + Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return + } + } + + private fun liftStatusList(list: List): List> { + return list.map(statusLifter) + } + + private fun updateAdapter() { + differ.submitList(statuses.pairedCopy) + } + + private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + if (isAdded) { + adapter.notifyItemRangeInserted(position, count) + val context = context + // scroll up when new items at the top are loaded while being in the first position + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.itemCount != count) { + if (isSwipeToRefreshEnabled) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) + } else binding.recyclerView.scrollToPosition(0) + } + } + } + + override fun onRemoved(position: Int, count: Int) { + adapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + adapter.notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + adapter.notifyItemRangeChanged(position, count, payload) + } + } + private val differ = AsyncListDiffer(listUpdateCallback, + AsyncDifferConfig.Builder(diffCallback).build()) + + private val dataSource: TimelineAdapter.AdapterDataSource = object : TimelineAdapter.AdapterDataSource { + override fun getItemCount(): Int { + return differ.currentList.size + } + + override fun getItemAt(pos: Int): StatusViewData { + return differ.currentList[pos] + } + } + + private var talkBackWasEnabled = false + + override fun onResume() { + super.onResume() + val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") + if (talkBackWasEnabled && !wasEnabled) { + adapter.notifyDataSetChanged() + } + startUpdateTimestamp() + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private fun startUpdateTimestamp() { + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_PAUSE)) + .subscribe { updateAdapter() } + } + } + + override fun onReselect() { + if (isAdded) { + layoutManager!!.scrollToPosition(0) + binding.recyclerView.stopScroll() + scrollListener!!.reset() + } + } + + override fun refreshContent() { + if (isAdded) { + onRefresh() + } else { + isNeedRefresh = true + } + } + + enum class Kind { + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS + } + + private enum class FetchEnd { + TOP, BOTTOM, MIDDLE + } + + companion object { + private const val TAG = "TimelineF" // logging tag + private const val KIND_ARG = "kind" + private const val ID_ARG = "id" + private const val HASHTAGS_ARG = "hashtags" + private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" + private const val LOAD_AT_ONCE = 30 + + fun newInstance(kind: Kind, hashtagOrId: String? = null, enableSwipeToRefresh: Boolean = true): TimelineFragment { + val fragment = TimelineFragment() + val arguments = Bundle(3) + arguments.putString(KIND_ARG, kind.name) + arguments.putString(ID_ARG, hashtagOrId) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) + fragment.arguments = arguments + return fragment + } + + @JvmStatic + fun newHashtagInstance(hashtags: List): TimelineFragment { + val fragment = TimelineFragment() + val arguments = Bundle(3) + arguments.putString(KIND_ARG, Kind.TAG.name) + arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + fragment.arguments = arguments + return fragment + } + + private fun filterContextMatchesKind(kind: Kind?, filterContext: List): Boolean { + // home, notifications, public, thread + return when (kind) { + Kind.HOME, Kind.LIST -> filterContext.contains(Filter.HOME) + Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains(Filter.PUBLIC) + Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains(Filter.NOTIFICATIONS) + Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains(Filter.ACCOUNT) + else -> false + } + } + + private val diffCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean { + return oldItem.viewDataId == newItem.viewDataId + } + + override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload(oldItem: StatusViewData, newItem: StatusViewData): Any? { + return if (oldItem.deepEquals(newItem)) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else // If items are different - update the whole view holder + null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 8594dfc60..859162da3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -22,7 +22,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData import kotlin.math.min // Not using lambdas because there's boxing of int then -interface StatusProvider { +fun interface StatusProvider { fun getStatus(pos: Int): StatusViewData? }