/* 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.content.Context.CONNECTIVITY_SERVICE import android.content.SharedPreferences import android.net.ConnectivityManager 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.* 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.* import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID 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.* import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Right 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 net.accelf.yuito.TimelineStreamingListener import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.WebSocket import retrofit2.Response import java.io.IOException import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.collections.ArrayList 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 var isStreamingEnabled = false set(value) { field = value when (value) { true -> startStreaming() false -> stopStreaming() } } private var webSocket: WebSocket? = null 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 var reduceTimelineLoading = false private var checkMobileNetwork = true 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) isStreamingEnabled = arguments.getBoolean(ARG_ENABLE_STREAMING, false) 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), quoteEnabled = CAN_USE_QUOTE_ID.contains(accountManager.activeAccount?.domain), ) 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() } } } override fun onStart() { super.onStart() if (isStreamingEnabled) { startStreaming() } } override fun onStop() { super.onStop() stopStreaming() } private fun startStreaming() { accountManager.activeAccount?.let { activeAccount -> val params = when (kind) { Kind.HOME -> { "user" } Kind.PUBLIC_FEDERATED -> { "public" } Kind.PUBLIC_LOCAL -> { "public:local" } Kind.LIST -> { "list&list=$id" } else -> { return } } val endpoint = ("wss://${activeAccount.domain}/api/v1/streaming/?access_token=${activeAccount.accessToken}&stream=${params}") if (webSocket != null) { stopStreaming() } val request: Request = Request.Builder().url(endpoint).build() val client: OkHttpClient = OkHttpClient.Builder().build() webSocket = client.newWebSocket(request, TimelineStreamingListener(eventHub, kind!!, id)) } } private fun stopStreaming() { webSocket?.close(1000, null) webSocket = null } 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 val preferences = PreferenceManager.getDefaultSharedPreferences(context) if (kind == Kind.HOME) { filterRemoveReplies = !preferences.getBoolean("tabFilterHomeReplies", true) filterRemoveReblogs = !preferences.getBoolean("tabFilterHomeBoosts", true) } reloadFilters(false) updateLimitedBandwidthStatus(preferences) } 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, Lifecycle.Event.ON_DESTROY)) .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) } is StreamUpdateEvent -> { if (isStreamingEnabled) { handleStreamUpdateEvent(event) } } } } 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) { when (kind) { Kind.HOME, Kind.PUBLIC_LOCAL, Kind.PUBLIC_FEDERATED, Kind.TAG, Kind.FAVOURITES, Kind.LIST -> { eventHub.dispatch(QuickReplyEvent(statuses[position].asRight().actionableStatus)) } Kind.BOOKMARKS, Kind.USER, Kind.USER_PINNED, Kind.USER_WITH_REPLIES -> { 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 onQuote(position: Int) { super.quote(statuses[position].asRight()) } 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 } PrefKeys.LIMITED_BANDWIDTH_ACTIVE, PrefKeys.LIMITED_BANDWIDTH_TIMELINE_LOADING, PrefKeys.LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK -> { updateLimitedBandwidthStatus(sharedPreferences) } } } private fun updateLimitedBandwidthStatus(sharedPreferences: SharedPreferences) { reduceTimelineLoading = sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_ACTIVE, false) && sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_TIMELINE_LOADING, true) checkMobileNetwork = sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK, true) } 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() } } private fun addStatus(item: Either) { if (item.isRight()) { val status = item.asRight() if (!(status.inReplyToId != null && filterRemoveReplies || status.reblog != null && filterRemoveReblogs || shouldFilterStatus(status))) { if (findStatusOrReblogPositionById(status.id) < 0) { statuses.add(0, item) updateAdapter() if (kind == Kind.HOME) { timelineRepo.addSingleStatusToDb(status) } } } } else { statuses.add(0, item) 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) { if (isStreamingEnabled) { return } var reload = when (kind) { Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> true Kind.USER, Kind.USER_WITH_REPLIES -> status.account.id == id Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> false else -> false } if (!reload) { return } if (reduceTimelineLoading) { reload = false if (checkMobileNetwork && activity?.let { (activity?.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager).isActiveNetworkMetered } == false) { reload = true } } if (reload) { onRefresh() } } private fun handleStreamUpdateEvent(event: StreamUpdateEvent) { if (event.targetKind != kind || event.targetIdentifier != null && event.targetIdentifier != id) { return } val status = event.status if (event.first && statuses[0].isRight()) { val placeholder = Placeholder(statuses[0].asRight().id + 1) updateStatuses(mutableListOf(Right(status), Left(placeholder)), false) } else { addStatus(Right(status)) } } 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 && layoutManager?.findFirstVisibleItemPosition() == 0 && adapter.itemCount != count) { when { count == 1 -> { layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() scrollListener?.reset() } 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 onReset() { fullyRefresh() } 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 ARG_ENABLE_STREAMING = "enableStreaming" private const val LOAD_AT_ONCE = 30 fun newInstance(kind: Kind, hashtagOrId: String? = null, enableSwipeToRefresh: Boolean = true, enableStreaming: Boolean = false): 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) arguments.putBoolean(ARG_ENABLE_STREAMING, enableStreaming) 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 } } } }