Fix filters in timelines in a simple way, fix #2546 (#2566)

Loading of statuses and loading of filters is an "intended" race:
we want to display statuses first, especially if they are cached.
Unfortunately we do not cache filters themselves so when we load cached
statuses we do not apply filters.

One part of the solution is to re-filter the statuses once we fetch the
filters. This commit implements it. Caching of filters is not included
yet.
This commit is contained in:
Ivan Kupalov 2022-05-29 19:22:59 +02:00 committed by GitHub
parent 579f0eb833
commit e63cd68baf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 59 additions and 12 deletions

View file

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.filter import androidx.paging.filter
import androidx.paging.map import androidx.paging.map
@ -37,6 +38,7 @@ import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
@ -66,7 +68,16 @@ class CachedTimelineViewModel @Inject constructor(
filterModel: FilterModel, filterModel: FilterModel,
private val db: AppDatabase, private val db: AppDatabase,
private val gson: Gson private val gson: Gson
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { ) : TimelineViewModel(
timelineCases,
api,
eventHub,
accountManager,
sharedPreferences,
filterModel
) {
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
override val statuses = Pager( override val statuses = Pager(
@ -78,6 +89,8 @@ class CachedTimelineViewModel @Inject constructor(
EmptyTimelinePagingSource() EmptyTimelinePagingSource()
} else { } else {
db.timelineDao().getStatuses(activeAccount.id) db.timelineDao().getStatuses(activeAccount.id)
}.also { newPagingSource ->
this.currentPagingSource = newPagingSource
} }
} }
).flow ).flow
@ -113,13 +126,15 @@ class CachedTimelineViewModel @Inject constructor(
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch { viewModelScope.launch {
db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) db.timelineDao()
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
} }
} }
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch { viewModelScope.launch {
db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) db.timelineDao()
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
} }
} }
@ -146,12 +161,21 @@ class CachedTimelineViewModel @Inject constructor(
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) timelineDao.insertStatus(
Placeholder(placeholderId, loading = true).toEntity(
activeAccount.id
)
)
val response = db.withTransaction { val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) val nextPlaceholderId =
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE) timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
api.homeTimeline(
maxId = idAbovePlaceholder,
sinceId = nextPlaceholderId,
limit = LOAD_AT_ONCE
)
}.await() }.await()
val statuses = response.body() val statuses = response.body()
@ -165,16 +189,21 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.delete(activeAccount.id, placeholderId) timelineDao.delete(activeAccount.id, placeholderId)
val overlappedStatuses = if (statuses.isNotEmpty()) { val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) timelineDao.deleteRange(
activeAccount.id,
statuses.last().id,
statuses.first().id
)
} else { } else {
0 0
} }
for (status in statuses) { for (status in statuses) {
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> status.reblog?.account?.toEntity(activeAccount.id, gson)
timelineDao.insertAccount(rebloggedAccount) ?.let { rebloggedAccount ->
} timelineDao.insertAccount(rebloggedAccount)
}
timelineDao.insertStatus( timelineDao.insertStatus(
status.toEntity( status.toEntity(
timelineUserId = activeAccount.id, timelineUserId = activeAccount.id,
@ -193,7 +222,10 @@ class CachedTimelineViewModel @Inject constructor(
to guarantee the placeholder has an id that exists on the server as not all to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */ servers handle client generated ids as expected */
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) Placeholder(
statuses.last().id,
loading = false
).toEntity(activeAccount.id)
) )
} }
} }
@ -208,7 +240,8 @@ class CachedTimelineViewModel @Inject constructor(
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w("CachedTimelineVM", "failed loading statuses", e) Log.w("CachedTimelineVM", "failed loading statuses", e)
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) db.timelineDao()
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
} }
override fun handleReblogEvent(reblogEvent: ReblogEvent) { override fun handleReblogEvent(reblogEvent: ReblogEvent) {
@ -234,6 +267,10 @@ class CachedTimelineViewModel @Inject constructor(
} }
} }
override fun invalidate() {
currentPagingSource?.invalidate()
}
companion object { companion object {
private const val MAX_STATUSES_IN_CACHE = 1000 private const val MAX_STATUSES_IN_CACHE = 1000
} }

View file

@ -249,6 +249,10 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource?.invalidate() currentSource?.invalidate()
} }
override fun invalidate() {
currentSource?.invalidate()
}
@Throws(IOException::class, HttpException::class) @Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind( suspend fun fetchStatusesForKind(
fromId: String?, fromId: String?,

View file

@ -173,6 +173,9 @@ abstract class TimelineViewModel(
abstract fun fullReload() abstract fun fullReload()
/** Triggered when currently displayed data must be reloaded. */
protected abstract fun invalidate()
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean {
val status = statusViewData.asStatusOrNull()?.status ?: return false val status = statusViewData.asStatusOrNull()?.status ?: return false
return status.inReplyToId != null && filterRemoveReplies || return status.inReplyToId != null && filterRemoveReplies ||
@ -288,6 +291,9 @@ abstract class TimelineViewModel(
filterContextMatchesKind(kind, it.context) filterContextMatchesKind(kind, it.context)
} }
) )
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
} }
} }