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:
parent
6679a411e2
commit
402bcef588
1076
app/schemas/app.pachli.db.AppDatabase/2.json
Normal file
1076
app/schemas/app.pachli.db.AppDatabase/2.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 */
|
||||
|
@ -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`() {
|
||||
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user