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

1412 lines
58 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.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<String> = 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<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)
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<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
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<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
}
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<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()
}
}
private fun addStatus(item: Either<Placeholder, Status>) {
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<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) {
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<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 && 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<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 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<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
}
}
}
}