fix: Ensure refreshing does not create a gap in the timeline (#43)

The previous code did not handle refreshing correctly; it retained some
of the cache, and tried merge new statuses in to the cache. This does
not work, and resulted in the app creating gaps in the timeline if more
than a page's worth of statuses had appeared since the user last
refreshed (e.g., overnight).

Fix this by treating the on-device cache as disposable, as the Paging3
library intends. On refresh the cached timeline is emptied and replaced
with a fresh page.

This causes a problem for state that is not stored on the server but is
part of a status' viewdata (has the user toggled viewing a piece of
media, expanded a CW, etc).

The previous code tried to work around that by pulling the state out of
the page cache and copying it in to the new statuses. That won't work
when the page cache is being destroyed.

So do it properly -- store the viewdata state in a separate (sparse)
table that contains only rows for statuses that have a non-default
state.

Save changes to the state when the user interacts with a status, and
use the state to ensure that the viewdata for a status in a thread
matches the viewdata for the same status if it is shown on the home
timeline (and vice-versa).

Fixes #16
This commit is contained in:
Nik Clayton 2023-09-14 15:12:48 +02:00 committed by GitHub
parent 6679a411e2
commit 402bcef588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1281 additions and 219 deletions

File diff suppressed because it is too large Load Diff

View File

@ -26,10 +26,12 @@ import androidx.paging.PagingData
import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator
import app.pachli.db.AccountManager
import app.pachli.db.AppDatabase
import app.pachli.db.StatusViewDataEntity
import app.pachli.db.TimelineStatusWithAccount
import app.pachli.di.ApplicationScope
import app.pachli.network.MastodonApi
import app.pachli.util.EmptyPagingSource
import app.pachli.viewdata.StatusViewData
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@ -107,23 +109,24 @@ class CachedTimelineRepository @Inject constructor(
factory?.invalidate()
}
/** Set and store the "expanded" state of the given status, for the active account */
suspend fun setExpanded(expanded: Boolean, statusId: String) = externalScope.launch {
appDatabase.timelineDao()
.setExpanded(activeAccount!!.id, statusId, expanded)
suspend fun saveStatusViewData(statusViewData: StatusViewData) = externalScope.launch {
appDatabase.timelineDao().upsertStatusViewData(
StatusViewDataEntity(
serverId = statusViewData.actionableId,
timelineUserId = activeAccount!!.id,
expanded = statusViewData.isExpanded,
contentShowing = statusViewData.isShowingContent,
contentCollapsed = statusViewData.isCollapsed,
),
)
}.join()
/** Set and store the "content showing" state of the given status, for the active account */
suspend fun setContentShowing(showing: Boolean, statusId: String) = externalScope.launch {
appDatabase.timelineDao()
.setContentShowing(activeAccount!!.id, statusId, showing)
}.join()
/** Set and store the "content collapsed" ("Show more") state of the given status, for the active account */
suspend fun setContentCollapsed(collapsed: Boolean, statusId: String) = externalScope.launch {
appDatabase.timelineDao()
.setContentCollapsed(activeAccount!!.id, statusId, collapsed)
}.join()
/**
* @return Map between statusIDs and any viewdata for them cached in the repository.
*/
suspend fun getStatusViewData(statusId: List<String>): Map<String, StatusViewDataEntity> {
return appDatabase.timelineDao().getStatusViewData(activeAccount!!.id, statusId)
}
/** Remove all statuses authored/boosted by the given account, for the active account */
suspend fun removeAllByAccountId(accountId: String) = externalScope.launch {

View File

@ -68,9 +68,6 @@ fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
fun Status.toEntity(
timelineUserId: Long,
gson: Gson,
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean,
): TimelineStatusEntity {
return TimelineStatusEntity(
serverId = this.id,
@ -99,9 +96,6 @@ fun Status.toEntity(
reblogAccountId = reblog?.let { this.account.id },
poll = actionableStatus.poll.let(gson::toJson),
muted = actionableStatus.muted,
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed,
pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount,
@ -110,7 +104,7 @@ fun Status.toEntity(
)
}
fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData {
fun TimelineStatusWithAccount.toViewData(gson: Gson, alwaysOpenSpoiler: Boolean, alwaysShowSensitiveMedia: Boolean, isDetailed: Boolean = false): StatusViewData {
val attachments: ArrayList<Attachment> = gson.fromJson(
status.attachments,
attachmentArrayListType,
@ -229,11 +223,12 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
filtered = status.filtered,
)
}
return StatusViewData(
status = status,
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsed = this.status.contentCollapsed,
isExpanded = this.viewData?.expanded ?: alwaysOpenSpoiler,
isShowingContent = this.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isCollapsed = this.viewData?.contentCollapsed ?: true,
isDetailed = isDetailed,
)
}

View File

@ -24,13 +24,13 @@ import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.Transaction
import androidx.room.withTransaction
import app.pachli.components.timeline.toEntity
import app.pachli.db.AccountManager
import app.pachli.db.AppDatabase
import app.pachli.db.RemoteKeyEntity
import app.pachli.db.RemoteKeyKind
import app.pachli.db.TimelineStatusEntity
import app.pachli.db.TimelineStatusWithAccount
import app.pachli.entity.Status
import app.pachli.network.Links
@ -113,6 +113,9 @@ class CachedTimelineRemoteMediator(
db.withTransaction {
when (loadType) {
LoadType.REFRESH -> {
remoteKeyDao.delete(activeAccount.id)
timelineDao.removeAllStatuses(activeAccount.id)
remoteKeyDao.upsert(
RemoteKeyEntity(
activeAccount.id,
@ -155,7 +158,7 @@ class CachedTimelineRemoteMediator(
)
}
}
replaceStatusRange(statuses, state)
insertStatuses(statuses)
}
return MediatorResult.Success(endOfPaginationReached = false)
@ -167,50 +170,23 @@ class CachedTimelineRemoteMediator(
}
/**
* Deletes all statuses in a given range and inserts new statuses.
* This is necessary so statuses that have been deleted on the server are cleaned up.
* Should be run in a transaction as it executes multiple db updates
* @param statuses the new statuses
* @return the number of old statuses that have been cleared from the database
* Inserts `statuses` and the accounts referenced by those statuses in to the cache.
*/
private suspend fun replaceStatusRange(statuses: List<Status>, state: PagingState<Int, TimelineStatusWithAccount>): Int {
val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
} else {
0
}
@Transaction
private suspend fun insertStatuses(statuses: List<Status>) {
for (status in statuses) {
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
timelineDao.insertAccount(rebloggedAccount)
}
// check if we already have one of the newly loaded statuses cached locally
// in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost
var oldStatus: TimelineStatusEntity? = null
for (page in state.pages) {
oldStatus = page.data.find { s ->
s.status.serverId == status.id
}?.status
if (oldStatus != null) break
}
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val contentCollapsed = oldStatus?.contentCollapsed ?: true
timelineDao.insertStatus(
status.toEntity(
timelineUserId = activeAccount.id,
gson = gson,
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed,
),
)
}
return overlappedStatuses
}
companion object {

View File

@ -96,7 +96,13 @@ class CachedTimelineViewModel @Inject constructor(
return repository.getStatusStream(kind = kind, initialKey = initialKey)
.map { pagingData ->
pagingData
.map { it.toViewData(gson) }
.map {
it.toViewData(
gson,
alwaysOpenSpoiler = activeAccount.alwaysOpenSpoiler,
alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia,
)
}
.filter { shouldFilterStatus(it) != Filter.Action.HIDE }
}
}
@ -107,19 +113,19 @@ class CachedTimelineViewModel @Inject constructor(
override fun changeExpanded(expanded: Boolean, status: StatusViewData) {
viewModelScope.launch {
repository.setExpanded(expanded, status.id)
repository.saveStatusViewData(status.copy(isExpanded = expanded))
}
}
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) {
viewModelScope.launch {
repository.setContentShowing(isShowing, status.id)
repository.saveStatusViewData(status.copy(isShowingContent = isShowing))
}
}
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) {
viewModelScope.launch {
repository.setContentCollapsed(isCollapsed, status.id)
repository.saveStatusViewData(status.copy(isCollapsed = isCollapsed))
}
}

View File

@ -27,10 +27,13 @@ import app.pachli.appstore.ReblogEvent
import app.pachli.appstore.StatusComposedEvent
import app.pachli.appstore.StatusDeletedEvent
import app.pachli.appstore.StatusEditedEvent
import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.components.timeline.toViewData
import app.pachli.components.timeline.util.ifExpected
import app.pachli.db.AccountEntity
import app.pachli.db.AccountManager
import app.pachli.db.AppDatabase
import app.pachli.db.StatusViewDataEntity
import app.pachli.entity.Filter
import app.pachli.entity.FilterV1
import app.pachli.entity.Status
@ -62,6 +65,7 @@ class ViewThreadViewModel @Inject constructor(
accountManager: AccountManager,
private val db: AppDatabase,
private val gson: Gson,
private val repository: CachedTimelineRepository,
) : ViewModel() {
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
@ -77,10 +81,12 @@ class ViewThreadViewModel @Inject constructor(
private val alwaysShowSensitiveMedia: Boolean
private val alwaysOpenSpoiler: Boolean
val activeAccount: AccountEntity
init {
val activeAccount = accountManager.activeAccount
alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
activeAccount = accountManager.activeAccount!!
alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia ?: false
alwaysOpenSpoiler = activeAccount.alwaysOpenSpoiler ?: false
viewModelScope.launch {
eventHub.events
@ -113,6 +119,8 @@ class ViewThreadViewModel @Inject constructor(
Log.d(TAG, "Loaded status from local timeline")
val viewData = timelineStatus.toViewData(
gson,
alwaysOpenSpoiler = alwaysOpenSpoiler,
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
isDetailed = true,
)
@ -121,7 +129,7 @@ class ViewThreadViewModel @Inject constructor(
// ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
// status content is the same. Then the status flickers as it is drawn twice.
if (viewData.actionableId == id) {
viewData.actionable.toViewData(isDetailed = true)
viewData.actionable.toViewData(isDetailed = true, viewData)
} else {
viewData
}
@ -144,15 +152,23 @@ class ViewThreadViewModel @Inject constructor(
// for the status. Ignore errors, the user still has a functioning UI if the fetch
// failed.
if (timelineStatus != null) {
val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true)
val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true, detailedStatus)
if (viewData != null) { detailedStatus = viewData }
}
val contextResult = contextCall.await()
contextResult.fold({ statusContext ->
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
val ids = statusContext.ancestors.map { it.id } + statusContext.descendants.map { it.id }
val cachedViewData = repository.getStatusViewData(ids)
val ancestors = statusContext.ancestors.map {
status ->
status.toViewData(statusViewDataEntity = cachedViewData[status.id])
}.filter()
val descendants = statusContext.descendants.map {
status ->
status.toViewData(statusViewDataEntity = cachedViewData[status.id])
}.filter()
val statuses = ancestors + detailedStatus + descendants
_uiState.value = ThreadUiState.Success(
@ -263,18 +279,27 @@ class ViewThreadViewModel @Inject constructor(
revealButton = statuses.getRevealButtonState(),
)
}
viewModelScope.launch {
repository.saveStatusViewData(status.copy(isExpanded = expanded))
}
}
fun changeContentShowing(isShowing: Boolean, status: StatusViewData) {
updateStatusViewData(status.id) { viewData ->
viewData.copy(isShowingContent = isShowing)
}
viewModelScope.launch {
repository.saveStatusViewData(status.copy(isShowingContent = isShowing))
}
}
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) {
updateStatusViewData(status.id) { viewData ->
viewData.copy(isCollapsed = isCollapsed)
}
viewModelScope.launch {
repository.saveStatusViewData(status.copy(isCollapsed = isCollapsed))
}
}
private fun handleFavEvent(event: FavoriteEvent) {
@ -459,6 +484,30 @@ class ViewThreadViewModel @Inject constructor(
}
}
/**
* Convert the status to a [StatusViewData], copying the view data from [statusViewData]
*/
private fun Status.toViewData(isDetailed: Boolean = false, statusViewData: StatusViewData): StatusViewData {
return toViewData(
isShowingContent = statusViewData.isShowingContent,
isExpanded = statusViewData.isExpanded,
isCollapsed = statusViewData.isCollapsed,
isDetailed = isDetailed,
)
}
/**
* Convert the status to a [StatusViewData], copying the view data from [statusViewDataEntity]
*/
private fun Status.toViewData(isDetailed: Boolean = false, statusViewDataEntity: StatusViewDataEntity?): StatusViewData {
return toViewData(
isShowingContent = statusViewDataEntity?.contentShowing ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isExpanded = statusViewDataEntity?.expanded ?: alwaysOpenSpoiler,
isCollapsed = statusViewDataEntity?.contentCollapsed ?: !isDetailed,
isDetailed = isDetailed,
)
}
private fun Status.toViewData(
isDetailed: Boolean = false,
): StatusViewData {

View File

@ -17,8 +17,11 @@
package app.pachli.db
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import app.pachli.components.conversation.ConversationEntity
@Database(
@ -30,8 +33,12 @@ import app.pachli.components.conversation.ConversationEntity
TimelineAccountEntity::class,
ConversationEntity::class,
RemoteKeyEntity::class,
StatusViewDataEntity::class,
],
version = 2,
autoMigrations = [
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
],
version = 1,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
@ -40,4 +47,9 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun timelineDao(): TimelineDao
abstract fun draftDao(): DraftDao
abstract fun remoteKeyDao(): RemoteKeyDao
@DeleteColumn("TimelineStatusEntity", "expanded")
@DeleteColumn("TimelineStatusEntity", "contentCollapsed")
@DeleteColumn("TimelineStatusEntity", "contentShowing")
class MIGRATE_1_2 : AutoMigrationSpec
}

View File

@ -18,8 +18,10 @@ package app.pachli.db
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapInfo
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import androidx.room.Upsert
@Dao
abstract class TimelineDao {
@ -36,7 +38,7 @@ SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered,
s.content, s.attachments, s.poll, s.card, s.muted, s.pinned, s.language, s.filtered,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
@ -44,10 +46,14 @@ a.emojis as 'a_emojis', a.bot as 'a_bot',
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId',
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot'
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
svd.serverId as 'svd_serverId', svd.timelineUserId as 'svd_timelineUserId',
svd.expanded as 'svd_expanded', svd.contentShowing as 'svd_contentShowing',
svd.contentCollapsed as 'svd_contentCollapsed'
FROM TimelineStatusEntity s
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
LEFT JOIN StatusViewDataEntity svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId))
WHERE s.timelineUserId = :account
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""",
)
@ -74,7 +80,7 @@ SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered,
s.content, s.attachments, s.poll, s.card, s.muted, s.pinned, s.language, s.filtered,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
@ -82,10 +88,14 @@ a.emojis as 'a_emojis', a.bot as 'a_bot',
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId',
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot'
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
svd.serverId as 'svd_serverId', svd.timelineUserId as 'svd_timelineUserId',
svd.expanded as 'svd_expanded', svd.contentShowing as 'svd_contentShowing',
svd.contentCollapsed as 'svd_contentCollapsed'
FROM TimelineStatusEntity s
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
LEFT JOIN StatusViewDataEntity svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId))
WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId)
AND s.authorServerId IS NOT NULL""",
)
@ -139,6 +149,9 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId =
@Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId")
abstract suspend fun removeAllAccounts(accountId: Long)
@Query("DELETE FROM StatusViewDataEntity WHERE timelineUserId = :accountId")
abstract suspend fun removeAllStatusViewData(accountId: Long)
@Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
AND serverId = :statusId""",
@ -153,6 +166,7 @@ AND serverId = :statusId""",
suspend fun cleanup(accountId: Long, limit: Int) {
cleanupStatuses(accountId, limit)
cleanupAccounts(accountId)
cleanupStatusViewData(accountId, limit)
}
/**
@ -179,29 +193,43 @@ AND serverId = :statusId""",
)
abstract suspend fun cleanupAccounts(accountId: Long)
/**
* Cleans the StatusViewDataEntity table of old view data, keeping the most recent [limit]
* entries.
*/
@Query(
"""DELETE
FROM StatusViewDataEntity
WHERE timelineUserId = :accountId
AND serverId NOT IN (
SELECT serverId FROM StatusViewDataEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit
)
""",
)
abstract suspend fun cleanupStatusViewData(accountId: Long, limit: Int)
@Query(
"""UPDATE TimelineStatusEntity SET poll = :poll
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""",
)
abstract suspend fun setVoted(accountId: Long, statusId: String, poll: String)
@Query(
"""UPDATE TimelineStatusEntity SET expanded = :expanded
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""",
)
abstract suspend fun setExpanded(accountId: Long, statusId: String, expanded: Boolean)
@Upsert
abstract suspend fun upsertStatusViewData(svd: StatusViewDataEntity)
/**
* @param accountId the accountId to query
* @param serverIds the IDs of the statuses to check
* @return Map between serverIds and any cached viewdata for those statuses
*/
@MapInfo(keyColumn = "serverId")
@Query(
"""UPDATE TimelineStatusEntity SET contentShowing = :contentShowing
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""",
"""SELECT *
FROM StatusViewDataEntity
WHERE timelineUserId = :accountId
AND serverId IN (:serverIds)""",
)
abstract suspend fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean)
@Query(
"""UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""",
)
abstract suspend fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean)
abstract suspend fun getStatusViewData(accountId: Long, serverIds: List<String>): Map<String, StatusViewDataEntity>
@Query(
"""UPDATE TimelineStatusEntity SET pinned = :pinned

View File

@ -78,9 +78,6 @@ data class TimelineStatusEntity(
val reblogAccountId: String?,
val poll: String?,
val muted: Boolean?,
val expanded: Boolean,
val contentCollapsed: Boolean,
val contentShowing: Boolean,
val pinned: Boolean,
val card: String?,
val language: String?,
@ -102,6 +99,27 @@ data class TimelineAccountEntity(
val bot: Boolean,
)
/**
* The local view data for a status.
*
* There is *no* foreignkey relationship between this and [TimelineStatusEntity], as the view
* data is kept even if the status is deleted from the local cache (e.g., during a refresh
* operation).
*/
@Entity(
primaryKeys = ["serverId", "timelineUserId"],
)
data class StatusViewDataEntity(
val serverId: String,
val timelineUserId: Long,
/** Corresponds to [app.pachli.viewdata.StatusViewData.isExpanded] */
val expanded: Boolean,
/** Corresponds to [app.pachli.viewdata.StatusViewData.isShowingContent] */
val contentShowing: Boolean,
/** Corresponds to [app.pachli.viewdata.StatusViewData.isCollapsed] */
val contentCollapsed: Boolean,
)
data class TimelineStatusWithAccount(
@Embedded
val status: TimelineStatusEntity,
@ -109,4 +127,6 @@ data class TimelineStatusWithAccount(
val account: TimelineAccountEntity,
@Embedded(prefix = "rb_")
val reblogAccount: TimelineAccountEntity? = null, // null when no reblog
@Embedded(prefix = "svd_")
val viewData: StatusViewDataEntity? = null,
)

View File

@ -53,6 +53,8 @@ class LogoutUsecase @Inject constructor(
// clear the database - this could trigger network calls so do it last when all tokens are gone
db.timelineDao().removeAll(activeAccount.id)
db.timelineDao().removeAllStatusViewData(activeAccount.id)
db.remoteKeyDao().delete(activeAccount.id)
db.conversationDao().deleteForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)

View File

@ -34,6 +34,10 @@ data class StatusViewData(
* Ignored if there is no content warning.
*/
val isExpanded: Boolean,
/**
* If the status contains attached media, specifies whether whether the media is shown
* (true), or not (false).
*/
val isShowingContent: Boolean,
/**
@ -43,6 +47,11 @@ data class StatusViewData(
* @return Whether the status is collapsed or fully expanded.
*/
val isCollapsed: Boolean,
/**
* Specifies whether this status should be shown with the "detailed" layout, meaning it is
* the status that has a focus when viewing a thread.
*/
val isDetailed: Boolean = false,
/** Whether this status should be filtered, and if so, how */

View File

@ -149,128 +149,6 @@ class CachedTimelineRemoteMediatorTest {
assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholder when less than a whole page is loaded`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
listOf(
mockStatus("8"),
mockStatus("7"),
mockStatus("5"),
),
)
onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1"),
),
)
},
factory = pagingSourceFactory,
db = db,
gson = Gson(),
)
val state = state(
pages = listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0,
),
),
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("8"),
mockStatusEntityWithAccount("7"),
mockStatusEntityWithAccount("5"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
),
)
}
@Test
@ExperimentalPagingApi
fun `should refresh and not insert placeholders when there is overlap with existing statuses`() {
val statusesAlreadyInDb = listOf(
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
)
db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator(
accountManager = accountManager,
api = mock {
onBlocking { homeTimeline(limit = 3) } doReturn Response.success(
listOf(
mockStatus("6"),
mockStatus("4"),
mockStatus("3"),
),
)
onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success(
listOf(
mockStatus("3"),
mockStatus("2"),
mockStatus("1"),
),
)
},
factory = pagingSourceFactory,
db = db,
gson = Gson(),
)
val state = state(
listOf(
PagingSource.LoadResult.Page(
data = statusesAlreadyInDb,
prevKey = null,
nextKey = 0,
),
),
pageSize = 3,
)
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) }
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
db.assertStatuses(
listOf(
mockStatusEntityWithAccount("6"),
mockStatusEntityWithAccount("4"),
mockStatusEntityWithAccount("3"),
mockStatusEntityWithAccount("2"),
mockStatusEntityWithAccount("1"),
),
)
}
@Test
@ExperimentalPagingApi
fun `should not try to refresh already cached statuses when db is empty`() {

View File

@ -1,5 +1,6 @@
package app.pachli.components.timeline
import app.pachli.db.StatusViewDataEntity
import app.pachli.db.TimelineStatusWithAccount
import app.pachli.entity.Status
import app.pachli.entity.TimelineAccount
@ -97,13 +98,17 @@ fun mockStatusEntityWithAccount(
status = mockedStatus.toEntity(
timelineUserId = userId,
gson = gson,
expanded = expanded,
contentShowing = false,
contentCollapsed = true,
),
account = mockedStatus.account.toEntity(
accountId = userId,
gson = gson,
),
viewData = StatusViewDataEntity(
serverId = id,
timelineUserId = userId,
expanded = expanded,
contentShowing = false,
contentCollapsed = true,
),
)
}

View File

@ -9,6 +9,7 @@ import app.pachli.appstore.BookmarkEvent
import app.pachli.appstore.EventHub
import app.pachli.appstore.FavoriteEvent
import app.pachli.appstore.ReblogEvent
import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.components.timeline.mockStatus
import app.pachli.components.timeline.mockStatusViewData
import app.pachli.db.AccountEntity
@ -29,6 +30,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
@ -100,7 +102,11 @@ class ViewThreadViewModelTest {
.build()
val gson = Gson()
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, gson)
val cachedTimelineRepository: CachedTimelineRepository = mock {
onBlocking { getStatusViewData(any()) } doReturn emptyMap()
}
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, gson, cachedTimelineRepository)
}
@After

View File

@ -362,9 +362,6 @@ class TimelineDaoTest {
reblogAccountId = reblogAuthor?.serverId,
poll = null,
muted = false,
expanded = false,
contentCollapsed = false,
contentShowing = true,
pinned = false,
card = card,
language = null,