Yuito-app-android/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt

1265 lines
54 KiB
Kotlin

/* 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 <http://www.gnu.org/licenses>. */
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<String> = 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<Either<Placeholder, Status>, 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<Either<Placeholder, Status>> ->
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<Either<Placeholder, Status>> ->
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<Int>) {
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 ?: "<null>",
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<Response<List<Status>>> {
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<Either<Placeholder, Status>> -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) },
{ t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) }
)
} else {
getFetchCallByTimelineType(maxId, sinceId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe(
{ response: Response<List<Status>> ->
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<Either<Placeholder, Status>>,
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<Either<Placeholder, Status>>) {
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<Either<Placeholder, Status>>, 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<Either<Placeholder, Status>?>) {
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<Either<Placeholder, Status>>) {
statuses.removeAll{ status -> status.isLeft() }
}
private fun replacePlaceholderWithStatuses(newStatuses: MutableList<Either<Placeholder, Status>>,
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<Status, Either<Placeholder, Status>> = { value -> Right(value) }
private fun findStatusAndPosition(position: Int, status: Status): Pair<StatusViewData.Concrete, Int>? {
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<Status>): List<Either<Placeholder, Status>> {
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<StatusViewData> = object : TimelineAdapter.AdapterDataSource<StatusViewData> {
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<String>): 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<String>): 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<StatusViewData> = object : DiffUtil.ItemCallback<StatusViewData>() {
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
}
}
}
}