From 7bf322c4f3d316786f76ec0f0e19d3907aaed13f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 23 Jan 2025 13:23:17 +0100 Subject: [PATCH] refactor: Use same patterns as Notifications* implementation (#1222) The modifications to the Notifications* classes highlighted different (and better) ways of writing the code that manages status timelines. Follow those practices here. Changes include: - Move `pachliAccountId` in to `IStatusViewData` so the adapter does not need to be initialised with the information. This allows the parameter to be removed from functions that operate on `IStatusViewData`, and the adapter does not need to be marked `lateinit`. - Convert Fragment/ViewModel communication to use the `uiResult` pattern instead of separate `uiSuccess` and `uiError`. - Show a `LinearProgressIndicator` when refreshing the list. - Restore the reading position more smoothly by responding when the first page of results is loaded. - Save the reading position to `RemoteKeyEntity` instead of a dedicated property in `AccountEntity`. - Fixed queries for returning the row number of a notification or status in the database. Fixes #238, #872, #928, #1190 --- .../adapter/FilterableStatusViewHolder.kt | 16 +- .../pachli/adapter/StatusBaseViewHolder.kt | 35 +- .../adapter/StatusDetailedViewHolder.kt | 10 +- .../app/pachli/adapter/StatusViewHolder.kt | 13 +- .../conversation/ConversationAdapter.kt | 3 +- .../conversation/ConversationViewData.kt | 4 +- .../conversation/ConversationViewHolder.kt | 12 +- .../conversation/ConversationsFragment.kt | 20 +- .../conversation/ConversationsViewModel.kt | 47 +- .../notifications/NotificationsFragment.kt | 173 +- .../NotificationsPagingAdapter.kt | 2 +- .../notifications/NotificationsViewModel.kt | 2 +- .../notifications/StatusViewHolder.kt | 2 - .../components/report/ReportViewModel.kt | 30 +- .../components/search/SearchViewModel.kt | 3 +- .../search/adapter/SearchStatusesAdapter.kt | 3 +- .../fragments/SearchStatusesFragment.kt | 18 +- .../timeline/CachedTimelineRepository.kt | 84 +- .../timeline/NetworkTimelineRepository.kt | 28 +- .../components/timeline/TimelineFragment.kt | 519 ++--- .../timeline/TimelinePagingAdapter.kt | 2 - .../components/timeline/TimelineRepository.kt | 41 + .../viewmodel/CachedTimelineRemoteMediator.kt | 80 +- .../viewmodel/CachedTimelineViewModel.kt | 54 +- .../viewmodel/NetworkTimelineViewModel.kt | 38 +- .../timeline/viewmodel/TimelineViewModel.kt | 143 +- .../components/viewthread/ThreadAdapter.kt | 3 +- .../viewthread/ViewThreadFragment.kt | 14 +- .../viewthread/ViewThreadViewModel.kt | 17 +- .../pachli/interfaces/StatusActionListener.kt | 10 +- .../pachli/usecase/DeveloperToolsUseCase.kt | 2 +- .../java/app/pachli/usecase/TimelineCases.kt | 10 +- .../util/CombinedLoadStatesExtensions.kt | 285 --- .../util/ListStatusAccessibilityDelegate.kt | 6 +- .../pachli/viewdata/NotificationViewData.kt | 3 +- .../res/layout-sw640dp/fragment_timeline.xml | 15 +- .../fragment_timeline_notifications.xml | 2 +- app/src/main/res/layout/fragment_timeline.xml | 15 +- .../fragment_timeline_notifications.xml | 2 +- .../java/app/pachli/StatusComparisonTest.kt | 6 + ...icationsViewModelTestStatusFilterAction.kt | 6 +- .../CachedTimelineRemoteMediatorTest.kt | 24 +- .../CachedTimelineViewModelTestBase.kt | 2 +- ...TimelineViewModelTestStatusFilterAction.kt | 59 +- .../CachedTimelineViewModelTestVisibleId.kt | 55 - .../NetworkTimelineViewModelTestBase.kt | 2 +- ...TimelineViewModelTestStatusFilterAction.kt | 76 +- .../NetworkTimelineViewModelTestVisibleId.kt | 47 - .../components/timeline/StatusMocker.kt | 15 +- .../app/pachli/core/activity/LogEntryTree.kt | 26 +- .../pachli/core/data/model/StatusViewData.kt | 10 +- .../core/data/repository/AccountManager.kt | 5 - .../NotificationsRemoteMediator.kt | 12 +- .../notifications/NotificationsRepository.kt | 8 +- .../14.json | 1980 +++++++++++++++++ .../app/pachli/core/database/AppDatabase.kt | 7 +- .../pachli/core/database/dao/AccountDao.kt | 9 - .../pachli/core/database/dao/LogEntryDao.kt | 7 +- .../core/database/dao/NotificationDao.kt | 24 +- .../pachli/core/database/dao/TimelineDao.kt | 39 +- .../core/database/model/AccountEntity.kt | 6 - .../extensions/PagingDataAdapterExtensions.kt | 40 + 62 files changed, 2836 insertions(+), 1395 deletions(-) create mode 100644 app/src/main/java/app/pachli/components/timeline/TimelineRepository.kt delete mode 100644 app/src/main/java/app/pachli/util/CombinedLoadStatesExtensions.kt delete mode 100644 app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt delete mode 100644 app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestVisibleId.kt create mode 100644 core/database/schemas/app.pachli.core.database.AppDatabase/14.json create mode 100644 core/ui/src/main/kotlin/app/pachli/core/ui/extensions/PagingDataAdapterExtensions.kt diff --git a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt index e348b3dbac..ab0a0475a4 100644 --- a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt @@ -35,28 +35,26 @@ open class FilterableStatusViewHolder( var matchedFilter: Filter? = null override fun setupWithStatus( - pachliAccountId: Long, viewData: T, listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, payloads: Any?, ) { - super.setupWithStatus(pachliAccountId, viewData, listener, statusDisplayOptions, payloads) - setupFilterPlaceholder(pachliAccountId, viewData, listener) + super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads) + setupFilterPlaceholder(viewData, listener) } private fun setupFilterPlaceholder( - pachliAccountId: Long, - status: T, + viewData: T, listener: StatusActionListener, ) { - if (status.contentFilterAction !== FilterAction.WARN) { + if (viewData.contentFilterAction !== FilterAction.WARN) { matchedFilter = null setPlaceholderVisibility(false) return } - status.actionable.filtered?.find { it.filter.filterAction === NetworkFilterAction.WARN }?.let { result -> + viewData.actionable.filtered?.find { it.filter.filterAction === NetworkFilterAction.WARN }?.let { result -> this.matchedFilter = result.filter setPlaceholderVisibility(true) @@ -71,10 +69,10 @@ open class FilterableStatusViewHolder( binding.statusFilteredPlaceholder.statusFilterLabel.text = label binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener { - listener.clearContentFilter(pachliAccountId, status) + listener.clearContentFilter(viewData) } binding.statusFilteredPlaceholder.statusFilterEditFilter.setOnClickListener { - listener.onEditFilterById(pachliAccountId, result.filter.id) + listener.onEditFilterById(viewData.pachliAccountId, result.filter.id) } } ?: { matchedFilter = null diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index 1d89907ff3..33e217c5e7 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -145,7 +145,6 @@ abstract class StatusBaseViewHolder protected constructor( } protected fun setSpoilerAndContent( - pachliAccountId: Long, viewData: T, statusDisplayOptions: StatusDisplayOptions, listener: StatusActionListener, @@ -165,7 +164,6 @@ abstract class StatusBaseViewHolder protected constructor( setContentWarningButtonText(expanded) contentWarningButton.setOnClickListener { toggleExpandedState( - pachliAccountId, viewData, true, !expanded, @@ -197,7 +195,6 @@ abstract class StatusBaseViewHolder protected constructor( } protected open fun toggleExpandedState( - pachliAccountId: Long, viewData: T, sensitive: Boolean, expanded: Boolean, @@ -205,11 +202,10 @@ abstract class StatusBaseViewHolder protected constructor( listener: StatusActionListener, ) { contentWarningDescription.invalidate() - listener.onExpandedChange(pachliAccountId, viewData, expanded) + listener.onExpandedChange(viewData, expanded) setContentWarningButtonText(expanded) setTextVisible(sensitive, expanded, viewData, statusDisplayOptions, listener) setupCard( - pachliAccountId, viewData, expanded, statusDisplayOptions.cardViewMode, @@ -466,7 +462,6 @@ abstract class StatusBaseViewHolder protected constructor( } protected fun setMediaPreviews( - pachliAccountId: Long, viewData: T, attachments: List, sensitive: Boolean, @@ -498,7 +493,7 @@ abstract class StatusBaseViewHolder protected constructor( } else { imageView.foreground = null } - setAttachmentClickListener(pachliAccountId, viewData, imageView, listener, i, attachment, true) + setAttachmentClickListener(viewData, imageView, listener, i, attachment, true) if (sensitive) { sensitiveMediaWarning.setText(R.string.post_sensitive_media_title) } else { @@ -509,13 +504,13 @@ abstract class StatusBaseViewHolder protected constructor( descriptionIndicator.visibility = if (hasDescription && showingContent) View.VISIBLE else View.GONE sensitiveMediaShow.setOnClickListener { v: View -> - listener.onContentHiddenChange(pachliAccountId, viewData, false) + listener.onContentHiddenChange(viewData, false) v.visibility = View.GONE sensitiveMediaWarning.visibility = View.VISIBLE descriptionIndicator.visibility = View.GONE } sensitiveMediaWarning.setOnClickListener { v: View -> - listener.onContentHiddenChange(pachliAccountId, viewData, true) + listener.onContentHiddenChange(viewData, true) v.visibility = View.GONE sensitiveMediaShow.visibility = View.VISIBLE descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE @@ -530,7 +525,6 @@ abstract class StatusBaseViewHolder protected constructor( } protected fun setMediaLabel( - pachliAccountId: Long, viewData: T, attachments: List, sensitive: Boolean, @@ -548,7 +542,7 @@ abstract class StatusBaseViewHolder protected constructor( // Set the icon next to the label. val drawableId = attachments[0].iconResource() mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0) - setAttachmentClickListener(pachliAccountId, viewData, mediaLabel, listener, i, attachment, false) + setAttachmentClickListener(viewData, mediaLabel, listener, i, attachment, false) } else { mediaLabel.visibility = View.GONE } @@ -556,7 +550,6 @@ abstract class StatusBaseViewHolder protected constructor( } private fun setAttachmentClickListener( - pachliAccountId: Long, viewData: T, view: View, listener: StatusActionListener, @@ -566,7 +559,7 @@ abstract class StatusBaseViewHolder protected constructor( ) { view.setOnClickListener { v: View? -> if (sensitiveMediaWarning.visibility == View.VISIBLE) { - listener.onContentHiddenChange(pachliAccountId, viewData, true) + listener.onContentHiddenChange(viewData, true) } else { listener.onViewMedia(viewData, index, if (animateTransition) v else null) } @@ -584,7 +577,6 @@ abstract class StatusBaseViewHolder protected constructor( } protected fun setupButtons( - pachliAccountId: Long, viewData: T, listener: StatusActionListener, accountId: String, @@ -594,7 +586,7 @@ abstract class StatusBaseViewHolder protected constructor( avatar.setOnClickListener(profileButtonClickListener) displayName.setOnClickListener(profileButtonClickListener) replyButton.setOnClickListener { - listener.onReply(pachliAccountId, viewData) + listener.onReply(viewData) } reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean -> // return true to play animation @@ -683,7 +675,6 @@ abstract class StatusBaseViewHolder protected constructor( } open fun setupWithStatus( - pachliAccountId: Long, viewData: T, listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, @@ -715,7 +706,6 @@ abstract class StatusBaseViewHolder protected constructor( val sensitive = actionable.sensitive if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { setMediaPreviews( - pachliAccountId, viewData, attachments, sensitive, @@ -731,13 +721,12 @@ abstract class StatusBaseViewHolder protected constructor( mediaLabel.visibility = View.GONE } } else { - setMediaLabel(pachliAccountId, viewData, attachments, sensitive, listener, viewData.isShowingContent) + setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent) // Hide all unused views. mediaPreview.visibility = View.GONE hideSensitiveMediaWarning() } setupCard( - pachliAccountId, viewData, viewData.isExpanded, statusDisplayOptions.cardViewMode, @@ -745,14 +734,13 @@ abstract class StatusBaseViewHolder protected constructor( listener, ) setupButtons( - pachliAccountId, viewData, listener, actionable.account.id, statusDisplayOptions, ) setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility) - setSpoilerAndContent(pachliAccountId, viewData, statusDisplayOptions, listener) + setSpoilerAndContent(viewData, statusDisplayOptions, listener) setContentDescriptionForStatus(viewData, statusDisplayOptions) // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 @@ -876,7 +864,6 @@ abstract class StatusBaseViewHolder protected constructor( } protected fun setupCard( - pachliAccountId: Long, viewData: T, expanded: Boolean, cardViewMode: CardViewMode, @@ -898,13 +885,13 @@ abstract class StatusBaseViewHolder protected constructor( cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions, false) { card, target -> if (target == PreviewCardView.Target.BYLINE) { card.authors?.firstOrNull()?.account?.id?.let { - context.startActivity(AccountActivityIntent(context, pachliAccountId, it)) + context.startActivity(AccountActivityIntent(context, viewData.pachliAccountId, it)) } return@bind } if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) { - context.startActivity(ViewMediaActivityIntent(context, pachliAccountId, viewData.actionable.account.username, card.embedUrl)) + context.startActivity(ViewMediaActivityIntent(context, viewData.pachliAccountId, viewData.actionable.account.username, card.embedUrl)) return@bind } diff --git a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt index 9f6c425def..99a75aa773 100644 --- a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt @@ -106,26 +106,24 @@ class StatusDetailedViewHolder( } override fun setupWithStatus( - pachliAccountId: Long, viewData: StatusViewData, listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, payloads: Any?, ) { // We never collapse statuses in the detail view - val uncollapsedStatus = + val uncollapsedViewdata = if (viewData.isCollapsible && viewData.isCollapsed) viewData.copy(isCollapsed = false) else viewData - super.setupWithStatus(pachliAccountId, uncollapsedStatus, listener, statusDisplayOptions, payloads) + super.setupWithStatus(uncollapsedViewdata, listener, statusDisplayOptions, payloads) setupCard( - pachliAccountId, - uncollapsedStatus, + uncollapsedViewdata, viewData.isExpanded, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener, ) // Always show card for detailed status if (payloads == null) { - val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedStatus.actionable + val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedViewdata.actionable if (!statusDisplayOptions.hideStats) { setReblogAndFavCount(viewData, reblogsCount, favouritesCount, listener) } else { diff --git a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt index 4775956cee..34ddb19a74 100644 --- a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt @@ -41,7 +41,6 @@ open class StatusViewHolder( ) : StatusBaseViewHolder(root ?: binding.root) { override fun setupWithStatus( - pachliAccountId: Long, viewData: T, listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, @@ -50,7 +49,7 @@ open class StatusViewHolder( if (payloads == null) { val sensitive = !TextUtils.isEmpty(viewData.actionable.spoilerText) val expanded = viewData.isExpanded - setupCollapsedState(pachliAccountId, viewData, sensitive, expanded, listener) + setupCollapsedState(viewData, sensitive, expanded, listener) val reblogging = viewData.rebloggingStatus if (reblogging == null || viewData.contentFilterAction === FilterAction.WARN) { statusInfo.hide() @@ -70,7 +69,7 @@ open class StatusViewHolder( statusFavouritesCount.visible(statusDisplayOptions.showStatsInline) setFavouritedCount(viewData.actionable.favouritesCount) setReblogsCount(viewData.actionable.reblogsCount) - super.setupWithStatus(pachliAccountId, viewData, listener, statusDisplayOptions, payloads) + super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads) } private fun setRebloggedByDisplayName( @@ -108,7 +107,6 @@ open class StatusViewHolder( } private fun setupCollapsedState( - pachliAccountId: Long, viewData: T, sensitive: Boolean, expanded: Boolean, @@ -117,7 +115,7 @@ open class StatusViewHolder( /* input filter for TextViews have to be set before text */ if (viewData.isCollapsible && (!sensitive || expanded)) { buttonToggleContent.setOnClickListener { - listener.onContentCollapsedChange(pachliAccountId, viewData, !viewData.isCollapsed) + listener.onContentCollapsedChange(viewData, !viewData.isCollapsed) } buttonToggleContent.show() if (viewData.isCollapsed) { @@ -139,15 +137,14 @@ open class StatusViewHolder( } override fun toggleExpandedState( - pachliAccountId: Long, viewData: T, sensitive: Boolean, expanded: Boolean, statusDisplayOptions: StatusDisplayOptions, listener: StatusActionListener, ) { - setupCollapsedState(pachliAccountId, viewData, sensitive, expanded, listener) - super.toggleExpandedState(pachliAccountId, viewData, sensitive, expanded, statusDisplayOptions, listener) + setupCollapsedState(viewData, sensitive, expanded, listener) + super.toggleExpandedState(viewData, sensitive, expanded, statusDisplayOptions, listener) } companion object { diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt b/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt index 384446f9da..a31d830006 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt @@ -26,7 +26,6 @@ import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.interfaces.StatusActionListener class ConversationAdapter( - private val pachliAccountId: Long, private var statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener, ) : PagingDataAdapter(CONVERSATION_COMPARATOR) { @@ -54,7 +53,7 @@ class ConversationAdapter( payloads: List, ) { getItem(position)?.let { conversationViewData -> - holder.setupWithConversation(pachliAccountId, conversationViewData, payloads.firstOrNull()) + holder.setupWithConversation(conversationViewData, payloads.firstOrNull()) } } diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt b/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt index f407e66d2e..69ff813dbd 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt @@ -35,12 +35,12 @@ data class ConversationViewData( val lastStatus: StatusViewData, ) : IStatusViewData by lastStatus { companion object { - fun from(conversationEntity: ConversationEntity) = ConversationViewData( + fun from(pachliAccountId: Long, conversationEntity: ConversationEntity) = ConversationViewData( id = conversationEntity.id, order = conversationEntity.order, accounts = conversationEntity.accounts, unread = conversationEntity.unread, - lastStatus = StatusViewData.from(conversationEntity.lastStatus), + lastStatus = StatusViewData.from(pachliAccountId, conversationEntity.lastStatus), ) } } diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt index 0bfa0751cf..9810ef2931 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt @@ -46,13 +46,12 @@ class ConversationViewHolder internal constructor( ) fun setupWithConversation( - pachliAccountId: Long, viewData: ConversationViewData, payloads: Any?, ) { val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = viewData.status if (payloads == null) { - setupCollapsedState(pachliAccountId, viewData, listener) + setupCollapsedState(viewData, listener) setDisplayName(account.name, account.emojis, statusDisplayOptions) setUsername(account.username) setMetaData(viewData, statusDisplayOptions, listener) @@ -61,7 +60,6 @@ class ConversationViewHolder internal constructor( setBookmarked(bookmarked) if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { setMediaPreviews( - pachliAccountId, viewData, attachments, sensitive, @@ -77,19 +75,18 @@ class ConversationViewHolder internal constructor( mediaLabel.visibility = View.GONE } } else { - setMediaLabel(pachliAccountId, viewData, attachments, sensitive, listener, viewData.isShowingContent) + setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent) // Hide all unused views. mediaPreview.visibility = View.GONE hideSensitiveMediaWarning() } setupButtons( - pachliAccountId, viewData, listener, account.id, statusDisplayOptions, ) - setSpoilerAndContent(pachliAccountId, viewData, statusDisplayOptions, listener) + setSpoilerAndContent(viewData, statusDisplayOptions, listener) setConversationName(viewData.accounts) setAvatars(viewData.accounts) } else { @@ -139,14 +136,13 @@ class ConversationViewHolder internal constructor( } private fun setupCollapsedState( - pachliAccountId: Long, viewData: ConversationViewData, listener: StatusActionListener, ) { /* input filter for TextViews have to be set before text */ if (viewData.isCollapsible && (viewData.isExpanded || TextUtils.isEmpty(viewData.spoilerText))) { contentCollapseButton.setOnClickListener { - listener.onContentCollapsedChange(pachliAccountId, viewData, !viewData.isCollapsed) + listener.onContentCollapsedChange(viewData, !viewData.isCollapsed) } contentCollapseButton.show() if (viewData.isCollapsed) { diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt index 4a1a9d32fe..26d059a4bd 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt @@ -114,7 +114,7 @@ class ConversationsFragment : viewLifecycleOwner.lifecycleScope.launch { val statusDisplayOptions = statusDisplayOptionsRepository.flow.value - adapter = ConversationAdapter(pachliAccountId, statusDisplayOptions, this@ConversationsFragment) + adapter = ConversationAdapter(statusDisplayOptions, this@ConversationsFragment) setupRecyclerView() @@ -322,16 +322,16 @@ class ConversationsFragment : // there are no reblogs in conversations } - override fun onExpandedChange(pachliAccountId: Long, viewData: ConversationViewData, expanded: Boolean) { - viewModel.expandHiddenStatus(pachliAccountId, expanded, viewData.lastStatus.id) + override fun onExpandedChange(viewData: ConversationViewData, expanded: Boolean) { + viewModel.expandHiddenStatus(viewData.pachliAccountId, expanded, viewData.lastStatus.id) } - override fun onContentHiddenChange(pachliAccountId: Long, viewData: ConversationViewData, isShowingContent: Boolean) { - viewModel.showContent(pachliAccountId, isShowingContent, viewData.lastStatus.id) + override fun onContentHiddenChange(viewData: ConversationViewData, isShowingContent: Boolean) { + viewModel.showContent(viewData.pachliAccountId, isShowingContent, viewData.lastStatus.id) } - override fun onContentCollapsedChange(pachliAccountId: Long, viewData: ConversationViewData, isCollapsed: Boolean) { - viewModel.collapseLongStatus(pachliAccountId, isCollapsed, viewData.lastStatus.id) + override fun onContentCollapsedChange(viewData: ConversationViewData, isCollapsed: Boolean) { + viewModel.collapseLongStatus(viewData.pachliAccountId, isCollapsed, viewData.lastStatus.id) } override fun onViewAccount(id: String) { @@ -348,15 +348,15 @@ class ConversationsFragment : // not needed } - override fun onReply(pachliAccountId: Long, viewData: ConversationViewData) { - reply(pachliAccountId, viewData.lastStatus.actionable) + override fun onReply(viewData: ConversationViewData) { + reply(viewData.pachliAccountId, viewData.lastStatus.actionable) } override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List) { viewModel.voteInPoll(choices, viewData.lastStatus.actionableId, poll.id) } - override fun clearContentFilter(pachliAccountId: Long, viewData: ConversationViewData) { + override fun clearContentFilter(viewData: ConversationViewData) { } // Filters don't apply in conversations diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt b/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt index 37847899cc..dc58805a73 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt @@ -24,20 +24,24 @@ import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.Loadable import app.pachli.core.database.Converters import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.di.TransactionProvider +import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases -import app.pachli.util.EmptyPagingSource import at.connyduck.calladapter.networkresult.fold import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch @@ -55,26 +59,27 @@ class ConversationsViewModel @Inject constructor( ) : ViewModel() { @OptIn(ExperimentalPagingApi::class) - val conversationFlow = Pager( - config = PagingConfig(pageSize = 30), - remoteMediator = ConversationsRemoteMediator( - api, - transactionProvider, - conversationsDao, - accountManager, - ), - pagingSourceFactory = { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - conversationsDao.conversationsForAccount(activeAccount.id) - } - }, - ) - .flow - .map { pagingData -> - pagingData.map { conversation -> ConversationViewData.from(conversation) } + val conversationFlow = accountManager.activeAccountFlow + .filterIsInstance>() + .mapNotNull { it.data } + .flatMapLatest { account -> + Pager( + config = PagingConfig(pageSize = 30), + remoteMediator = ConversationsRemoteMediator( + api, + transactionProvider, + conversationsDao, + accountManager, + ), + pagingSourceFactory = { + conversationsDao.conversationsForAccount(account.id) + }, + ).flow + .map { pagingData -> + pagingData.map { conversation -> + ConversationViewData.from(account.id, conversation) + } + } } .cachedIn(viewModelScope) diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index b2d3cb0c38..e369e47f44 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -37,7 +37,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState -import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -92,9 +91,9 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import postPrepend import timber.log.Timber @AndroidEntryPoint @@ -117,7 +116,12 @@ class NotificationsFragment : private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) - private lateinit var adapter: NotificationsPagingAdapter + private val adapter = NotificationsPagingAdapter( + notificationDiffCallback, + statusActionListener = this, + notificationActionListener = this, + accountActionListener = this, + ) private lateinit var layoutManager: LinearLayoutManager @@ -191,14 +195,6 @@ class NotificationsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = false - adapter = NotificationsPagingAdapter( - notificationDiffCallback, - statusActionListener = this@NotificationsFragment, - notificationActionListener = this@NotificationsFragment, - accountActionListener = this@NotificationsFragment, - statusDisplayOptions = viewModel.statusDisplayOptions.value, - ) - binding.recyclerView.setAccessibilityDelegateCompat( ListStatusAccessibilityDelegate(pachliAccountId, binding.recyclerView, this@NotificationsFragment) { pos: Int -> if (pos in 0 until adapter.itemCount) { @@ -211,11 +207,7 @@ class NotificationsFragment : val saveIdListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - if (newState != SCROLL_STATE_IDLE) return - - // Save the ID of the first notification visible in the list, so the user's - // reading position is always restorable. - saveVisibleId() + if (newState == SCROLL_STATE_IDLE) saveVisibleId() } } binding.recyclerView.addOnScrollListener(saveIdListener) @@ -239,62 +231,60 @@ class NotificationsFragment : // Update status display from statusDisplayOptions. If the new options request // relative time display collect the flow to periodically update the timestamp in the list gui elements. launch { - viewModel.statusDisplayOptions - .collectLatest { - // NOTE this this also triggered (emitted?) on resume. + viewModel.statusDisplayOptions.collectLatest { + // NOTE this this also triggered (emitted?) on resume. - adapter.statusDisplayOptions = it - adapter.notifyItemRangeChanged(0, adapter.itemCount, null) + adapter.statusDisplayOptions = it + adapter.notifyItemRangeChanged(0, adapter.itemCount, null) - if (!it.useAbsoluteTime) { - updateTimestampFlow.collect() - } + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() } + } } // Update the UI from the loadState - adapter.loadStateFlow - .collect { loadState -> - when (loadState.refresh) { - is LoadState.Error -> { - binding.progressIndicator.hide() - binding.statusView.setup((loadState.refresh as LoadState.Error).error) { - adapter.retry() - } - binding.recyclerView.hide() - binding.statusView.show() - binding.swipeRefreshLayout.isRefreshing = false + adapter.loadStateFlow.collect { loadState -> + when (loadState.refresh) { + is LoadState.Error -> { + binding.progressIndicator.hide() + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { + adapter.retry() } + binding.recyclerView.hide() + binding.statusView.show() + binding.swipeRefreshLayout.isRefreshing = false + } - LoadState.Loading -> { - /* nothing */ - binding.statusView.hide() - binding.progressIndicator.show() - } + LoadState.Loading -> { + /* nothing */ + binding.statusView.hide() + binding.progressIndicator.show() + } - is LoadState.NotLoading -> { - // Might still be loading if source.refresh is Loading, so only update - // the UI when loading is completely quiet. - if (loadState.source.refresh !is LoadState.Loading) { - binding.progressIndicator.hide() - binding.swipeRefreshLayout.isRefreshing = false - if (adapter.itemCount == 0) { - binding.statusView.setup(BackgroundMessage.Empty()) - binding.recyclerView.hide() - binding.statusView.show() - } else { - binding.statusView.hide() - binding.recyclerView.show() - } + is LoadState.NotLoading -> { + // Might still be loading if source.refresh is Loading, so only update + // the UI when loading is completely quiet. + if (loadState.source.refresh !is LoadState.Loading) { + binding.progressIndicator.hide() + binding.swipeRefreshLayout.isRefreshing = false + if (adapter.itemCount == 0) { + binding.statusView.setup(BackgroundMessage.Empty()) + binding.recyclerView.hide() + binding.statusView.show() + } else { + binding.statusView.hide() + binding.recyclerView.show() } } } } + } } } } - private suspend fun bindUiResult(uiResult: Result) { + private fun bindUiResult(uiResult: Result) { // Show errors from the view model as snack bars. // // Errors are shown: @@ -345,9 +335,7 @@ class NotificationsFragment : } when (uiSuccess) { - is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> viewLifecycleOwner.lifecycleScope.launch { - refreshAdapterAndScrollToVisibleId() - } + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> refreshAdapterAndScrollToVisibleId() is UiSuccess.LoadNewest -> { // Scroll to the top when prepending completes. @@ -368,7 +356,7 @@ class NotificationsFragment : /** * Refreshes the adapter, waits for the first page to be updated, and scrolls the - * the recyclerview to the first notification that was visible before the refresh. + * recyclerview to the first notification that was visible before the refresh. * * This ensures the user's position is not lost during adapter refreshes. */ @@ -385,23 +373,6 @@ class NotificationsFragment : adapter.refresh() } - /** - * Performs [action] after the next prepend operation completes on the adapter. - * - * A prepend operation is complete when the adapter's prepend [LoadState] transitions - * from [LoadState.Loading] to [LoadState.NotLoading]. - */ - private suspend fun PagingDataAdapter.postPrepend( - action: () -> Unit, - ) { - val initial: Pair = Pair(null, null) - loadStateFlow - .runningFold(initial) { prev, next -> prev.second to next.prepend } - .filter { it.first is LoadState.Loading && it.second is LoadState.NotLoading } - .take(1) - .collect { action() } - } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) menu.findItem(R.id.action_refresh)?.apply { @@ -450,9 +421,7 @@ class NotificationsFragment : } binding.swipeRefreshLayout.isRefreshing = false - viewLifecycleOwner.lifecycleScope.launch { - refreshAdapterAndScrollToVisibleId() - } + refreshAdapterAndScrollToVisibleId() clearNotificationsForAccount(requireContext(), pachliAccountId) } @@ -483,20 +452,18 @@ class NotificationsFragment : override fun onResume() { super.onResume() - if (::adapter.isInitialized) { - val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) - val wasEnabled = talkBackWasEnabled - talkBackWasEnabled = a11yManager?.isEnabled == true - if (talkBackWasEnabled && !wasEnabled) { - adapter.notifyItemRangeChanged(0, adapter.itemCount) - } + val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + if (talkBackWasEnabled && !wasEnabled) { + adapter.notifyItemRangeChanged(0, adapter.itemCount) } clearNotificationsForAccount(requireContext(), pachliAccountId) } - override fun onReply(pachliAccountId: Long, viewData: NotificationViewData) { - super.reply(pachliAccountId, viewData.statusViewData!!.actionable) + override fun onReply(viewData: NotificationViewData) { + super.reply(viewData.pachliAccountId, viewData.statusViewData!!.actionable) } override fun onReblog(viewData: NotificationViewData, reblog: Boolean) { @@ -546,31 +513,23 @@ class NotificationsFragment : ) } - // This is required by the interface StatusActionListener; the interface's ViewData T - // doesn't include pachliAccountId as a property. - // TODO: Update StatusActionListener.onExpandedChange to include the account ID. - override fun onExpandedChange(pachliAccountId: Long, viewData: NotificationViewData, expanded: Boolean) { - onExpandedChange(viewData, expanded) - } - override fun onContentHiddenChange( - pachliAccountId: Long, viewData: NotificationViewData, isShowingContent: Boolean, ) { viewModel.accept( InfallibleUiAction.SetShowingContent( - pachliAccountId, + viewData.pachliAccountId, viewData.statusViewData!!, isShowingContent, ), ) } - override fun onContentCollapsedChange(pachliAccountId: Long, viewData: NotificationViewData, isCollapsed: Boolean) { + override fun onContentCollapsedChange(viewData: NotificationViewData, isCollapsed: Boolean) { viewModel.accept( InfallibleUiAction.SetContentCollapsed( - pachliAccountId, + viewData.pachliAccountId, viewData.statusViewData!!, isCollapsed, ), @@ -585,17 +544,17 @@ class NotificationsFragment : } override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, viewData: NotificationViewData) { - onContentCollapsedChange(viewData.pachliAccountId, viewData, isCollapsed) + onContentCollapsedChange(viewData, isCollapsed) } - override fun clearContentFilter(pachliAccountId: Long, viewData: NotificationViewData) { - viewModel.accept(InfallibleUiAction.ClearContentFilter(pachliAccountId, viewData.id)) + override fun clearContentFilter(viewData: NotificationViewData) { + viewModel.accept(InfallibleUiAction.ClearContentFilter(viewData.pachliAccountId, viewData.id)) } override fun clearAccountFilter(viewData: NotificationViewData) { viewModel.accept( InfallibleUiAction.OverrideAccountFilter( - pachliAccountId, + viewData.pachliAccountId, viewData.id, viewData.accountFilterDecision, ), @@ -629,15 +588,11 @@ class NotificationsFragment : } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - viewLifecycleOwner.lifecycleScope.launch { - refreshAdapterAndScrollToVisibleId() - } + refreshAdapterAndScrollToVisibleId() } override fun onBlock(block: Boolean, id: String, position: Int) { - viewLifecycleOwner.lifecycleScope.launch { - refreshAdapterAndScrollToVisibleId() - } + refreshAdapterAndScrollToVisibleId() } override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt index d7a4d4a58f..ba9b7e8701 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt @@ -148,7 +148,7 @@ class NotificationsPagingAdapter( private val statusActionListener: StatusActionListener, private val notificationActionListener: NotificationActionListener, private val accountActionListener: AccountActionListener, - var statusDisplayOptions: StatusDisplayOptions, + var statusDisplayOptions: StatusDisplayOptions = StatusDisplayOptions(), ) : PagingDataAdapter(diffCallback) { private val absoluteTimeFormatter = AbsoluteTimeFormatter() diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt index 5d0abbe720..c94829f16d 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt @@ -367,7 +367,7 @@ class NotificationsViewModel @AssistedInject constructor( private val sharedPreferencesRepository: SharedPreferencesRepository, @Assisted val pachliAccountId: Long, ) : ViewModel() { - val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId) + private val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId) .filterNotNull() .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) diff --git a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt index de8b4ee2ea..e25c154e31 100644 --- a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt +++ b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt @@ -46,7 +46,6 @@ internal class StatusViewHolder( showStatusContent(true) } setupWithStatus( - viewData.pachliAccountId, viewData, statusActionListener, statusDisplayOptions, @@ -81,7 +80,6 @@ class FilterableStatusViewHolder( showStatusContent(true) } setupWithStatus( - viewData.pachliAccountId, viewData, statusActionListener, statusDisplayOptions, diff --git a/app/src/main/java/app/pachli/components/report/ReportViewModel.kt b/app/src/main/java/app/pachli/components/report/ReportViewModel.kt index 716f4ef438..143106a947 100644 --- a/app/src/main/java/app/pachli/components/report/ReportViewModel.kt +++ b/app/src/main/java/app/pachli/components/report/ReportViewModel.kt @@ -27,7 +27,10 @@ import androidx.paging.map import app.pachli.components.report.adapter.StatusesPagingSource import app.pachli.components.report.model.StatusViewState import app.pachli.core.data.model.StatusViewData +import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.Loadable import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.database.model.AccountEntity import app.pachli.core.eventhub.BlockEvent import app.pachli.core.eventhub.EventHub import app.pachli.core.eventhub.MuteEvent @@ -45,12 +48,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch @HiltViewModel class ReportViewModel @Inject constructor( + private val accountManager: AccountManager, private val mastodonApi: MastodonApi, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val eventHub: EventHub, @@ -78,19 +85,24 @@ class ReportViewModel @Inject constructor( val statusDisplayOptions = statusDisplayOptionsRepository.flow - val statusesFlow = accountIdFlow.flatMapLatest { accountId -> + val activeAccountFlow = accountManager.accountsFlow + .filterIsInstance>() + .mapNotNull { it.data } + + val statusesFlow = activeAccountFlow.combine(accountIdFlow) { activeAccount, reportedAccountId -> + Pair(activeAccount, reportedAccountId) + }.flatMapLatest { (activeAccount, reportedAccountId) -> Pager( initialKey = statusId, config = PagingConfig(pageSize = 20, initialLoadSize = 20), - pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }, + pagingSourceFactory = { StatusesPagingSource(reportedAccountId, mastodonApi) }, ).flow - } - .map { pagingData -> - /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData - instead of StatusViewState */ - pagingData.map { status -> StatusViewData.from(status, false, false, false) } - } - .cachedIn(viewModelScope) + .map { pagingData -> + /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData + instead of StatusViewState */ + pagingData.map { status -> StatusViewData.from(activeAccount.id, status, false, false, false) } + } + }.cachedIn(viewModelScope) private val selectedIds = HashSet() val statusViewState = StatusViewState() diff --git a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt index c2f8f0c06a..169860f0ee 100644 --- a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt +++ b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt @@ -83,7 +83,7 @@ class SearchViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val timelineCases: TimelineCases, private val accountManager: AccountManager, - private val serverRepository: ServerRepository, + serverRepository: ServerRepository, ) : ViewModel() { var currentQuery: String = "" @@ -204,6 +204,7 @@ class SearchViewModel @Inject constructor( private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { it.statuses.map { status -> StatusViewData.from( + pachliAccountId = activeAccount!!.id, status, isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, isExpanded = alwaysOpenSpoiler, diff --git a/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt index 4d07a56f3c..8c65924e55 100644 --- a/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt @@ -27,7 +27,6 @@ import app.pachli.databinding.ItemStatusBinding import app.pachli.interfaces.StatusActionListener class SearchStatusesAdapter( - private val pachliAccountId: Long, private val statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener, ) : PagingDataAdapter>(STATUS_COMPARATOR) { @@ -40,7 +39,7 @@ class SearchStatusesAdapter( override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { getItem(position)?.let { item -> - holder.setupWithStatus(pachliAccountId, item, statusListener, statusDisplayOptions) + holder.setupWithStatus(item, statusListener, statusDisplayOptions) } } diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt index 6b3841da1e..55ed30d072 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt @@ -85,15 +85,15 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis MaterialDividerItemDecoration(requireContext(), MaterialDividerItemDecoration.VERTICAL), ) binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) - return SearchStatusesAdapter(viewModel.activeAccount!!.id, statusDisplayOptions, this) + return SearchStatusesAdapter(statusDisplayOptions, this) } - override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) { + override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) { viewModel.contentHiddenChange(viewData, isShowingContent) } - override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { - reply(pachliAccountId, viewData) + override fun onReply(viewData: StatusViewData) { + reply(viewData) } override fun onFavourite(viewData: StatusViewData, favourite: Boolean) { @@ -148,11 +148,11 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis bottomSheetActivity?.viewAccount(pachliAccountId, status.account.id) } - override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) { + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { viewModel.expandedChange(viewData, expanded) } - override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) { + override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { viewModel.collapsedChange(viewData, isCollapsed) } @@ -160,7 +160,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis viewModel.voteInPoll(viewData, poll, choices) } - override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {} + override fun clearContentFilter(viewData: StatusViewData) {} override fun onReblog(viewData: StatusViewData, reblog: Boolean) { viewModel.reblog(viewData, reblog) @@ -173,7 +173,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis ) } - private fun reply(pachliAccountId: Long, status: StatusViewData) { + private fun reply(status: StatusViewData) { val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() @@ -184,7 +184,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis val intent = ComposeActivityIntent( requireContext(), - pachliAccountId, + status.pachliAccountId, ComposeOptions( inReplyToId = status.actionableId, replyVisibility = actionableStatus.visibility, diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index b1c9589a50..fbe08d41b9 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -22,7 +22,9 @@ import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import app.pachli.components.timeline.TimelineRepository.Companion.PAGE_SIZE import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator +import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator.Companion.RKE_TIMELINE_ID import app.pachli.core.common.di.ApplicationScope import app.pachli.core.data.model.StatusViewData import app.pachli.core.database.dao.RemoteKeyDao @@ -30,6 +32,8 @@ import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TranslatedStatusDao import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.model.RemoteKeyEntity +import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TranslatedStatusEntity @@ -42,6 +46,7 @@ import at.connyduck.calladapter.networkresult.fold import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import timber.log.Timber @@ -62,47 +67,33 @@ class CachedTimelineRepository @Inject constructor( private val remoteKeyDao: RemoteKeyDao, private val translatedStatusDao: TranslatedStatusDao, @ApplicationScope private val externalScope: CoroutineScope, -) { +) : TimelineRepository { private var factory: InvalidatingPagingSourceFactory? = null - /** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ + /** @return flow of Mastodon [TimelineStatusWithAccount. */ @OptIn(ExperimentalPagingApi::class) - fun getStatusStream( + override suspend fun getStatusStream( account: AccountEntity, kind: Timeline, - pageSize: Int = PAGE_SIZE, - initialKey: String? = null, ): Flow> { - Timber.d("getStatusStream(): key: %s", initialKey) - Timber.d("getStatusStream, account is %s", account.fullName) factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) } - val row = initialKey?.let { key -> - // Room is row-keyed (by Int), not item-keyed, so the status ID string that was - // passed as `initialKey` won't work. - // - // Instead, get all the status IDs for this account, in timeline order, and find the - // row index that contains the status. The row index is the correct initialKey. - timelineDao.getStatusRowNumber(account.id) - .indexOfFirst { it == key }.takeIf { it != -1 } - } + val initialKey = remoteKeyDao.remoteKeyForKind(account.id, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH) + val row = initialKey?.key?.let { timelineDao.getStatusRowNumber(account.id, it) } Timber.d("initialKey: %s is row: %d", initialKey, row) return Pager( + initialKey = row, config = PagingConfig( - pageSize = pageSize, - jumpThreshold = PAGE_SIZE * 3, - enablePlaceholders = true, + pageSize = PAGE_SIZE, + enablePlaceholders = false, ), - initialKey = row, remoteMediator = CachedTimelineRemoteMediator( - initialKey, mastodonApi, account.id, - factory!!, transactionProvider, timelineDao, remoteKeyDao, @@ -112,7 +103,7 @@ class CachedTimelineRepository @Inject constructor( } /** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */ - suspend fun invalidate(pachliAccountId: Long) { + override suspend fun invalidate(pachliAccountId: Long) { // Invalidating when no statuses have been loaded can cause empty timelines because it // cancels the network load. if (timelineDao.getStatusCount(pachliAccountId) < 1) { @@ -122,11 +113,11 @@ class CachedTimelineRepository @Inject constructor( factory?.invalidate() } - suspend fun saveStatusViewData(pachliAccountId: Long, statusViewData: StatusViewData) = externalScope.launch { + suspend fun saveStatusViewData(statusViewData: StatusViewData) = externalScope.launch { timelineDao.upsertStatusViewData( StatusViewDataEntity( serverId = statusViewData.actionableId, - timelineUserId = pachliAccountId, + timelineUserId = statusViewData.pachliAccountId, expanded = statusViewData.isExpanded, contentShowing = statusViewData.isShowingContent, contentCollapsed = statusViewData.isCollapsed, @@ -164,28 +155,15 @@ class CachedTimelineRepository @Inject constructor( timelineDao.clearWarning(pachliAccountId, statusId) }.join() - /** Remove all statuses and invalidate the pager, for the active account */ - suspend fun clearAndReload(pachliAccountId: Long) = externalScope.launch { - Timber.d("clearAndReload()") - timelineDao.removeAll(pachliAccountId) - factory?.invalidate() - }.join() - - suspend fun clearAndReloadFromNewest(pachliAccountId: Long) = externalScope.launch { - timelineDao.removeAll(pachliAccountId) - remoteKeyDao.delete(pachliAccountId, CachedTimelineRemoteMediator.RKE_TIMELINE_ID) - invalidate(pachliAccountId) - } - - suspend fun translate(pachliAccountId: Long, statusViewData: StatusViewData): NetworkResult { - saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.TRANSLATING)) + suspend fun translate(statusViewData: StatusViewData): NetworkResult { + saveStatusViewData(statusViewData.copy(translationState = TranslationState.TRANSLATING)) val translation = mastodonApi.translate(statusViewData.actionableId) translation.fold( { translatedStatusDao.upsert( TranslatedStatusEntity( serverId = statusViewData.actionableId, - timelineUserId = pachliAccountId, + timelineUserId = statusViewData.pachliAccountId, // TODO: Should this embed the network type instead of copying data // from one type to another? content = it.content, @@ -195,21 +173,31 @@ class CachedTimelineRepository @Inject constructor( provider = it.provider, ), ) - saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION)) + saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION)) }, { // Reset the translation state - saveStatusViewData(pachliAccountId, statusViewData) + saveStatusViewData(statusViewData) }, ) return translation } - suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) { - saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL)) + suspend fun translateUndo(statusViewData: StatusViewData) { + saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL)) } - companion object { - private const val PAGE_SIZE = 30 - } + /** + * Saves the ID of the notification that future refreshes will try and restore + * from. + * + * @param pachliAccountId + * @param key Notification ID to restore from. Null indicates the refresh should + * refresh the newest notifications. + */ + suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async { + remoteKeyDao.upsert( + RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key), + ) + }.await() } diff --git a/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt index c1aae7f9d1..9c2d530544 100644 --- a/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt @@ -22,7 +22,7 @@ import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import androidx.paging.PagingSource +import app.pachli.components.timeline.TimelineRepository.Companion.PAGE_SIZE import app.pachli.components.timeline.viewmodel.NetworkTimelinePagingSource import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator import app.pachli.components.timeline.viewmodel.PageCache @@ -70,30 +70,25 @@ import timber.log.Timber /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, -) { +) : TimelineRepository { private val pageCache = PageCache() private var factory: InvalidatingPagingSourceFactory? = null - // TODO: This should use assisted injection, and inject the account. - private var activeAccount: AccountEntity? = null - - /** @return flow of Mastodon [Status], loaded in [pageSize] increments */ + /** @return flow of Mastodon [Status]. */ @OptIn(ExperimentalPagingApi::class) - fun getStatusStream( + override suspend fun getStatusStream( account: AccountEntity, kind: Timeline, - pageSize: Int = PAGE_SIZE, - initialKey: String? = null, ): Flow> { - Timber.d("getStatusStream(): key: %s", initialKey) + Timber.d("getStatusStream()") factory = InvalidatingPagingSourceFactory { NetworkTimelinePagingSource(pageCache) } return Pager( - config = PagingConfig(pageSize = pageSize), + config = PagingConfig(pageSize = PAGE_SIZE), remoteMediator = NetworkTimelineRemoteMediator( mastodonApi, account, @@ -105,10 +100,9 @@ class NetworkTimelineRepository @Inject constructor( ).flow } - /** Invalidate the active paging source, see [PagingSource.invalidate] */ - fun invalidate() { - factory?.invalidate() - } + override suspend fun invalidate(pachliAccountId: Long) = factory?.invalidate() ?: Unit + + fun invalidate() = factory?.invalidate() fun removeAllByAccountId(accountId: String) { synchronized(pageCache) { @@ -171,8 +165,4 @@ class NetworkTimelineRepository @Inject constructor( } invalidate() } - - companion object { - private const val PAGE_SIZE = 30 - } } diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 90410a5a1c..927281d3df 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -44,13 +44,13 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import app.pachli.BuildConfig import app.pachli.R import app.pachli.adapter.StatusBaseViewHolder -import app.pachli.components.timeline.util.isExpected import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel import app.pachli.components.timeline.viewmodel.InfallibleUiAction import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.TimelineViewModel +import app.pachli.components.timeline.viewmodel.UiError import app.pachli.components.timeline.viewmodel.UiSuccess import app.pachli.core.activity.RefreshableFragment import app.pachli.core.activity.ReselectableFragment @@ -78,11 +78,10 @@ import app.pachli.interfaces.ActionButtonActivity import app.pachli.interfaces.AppBarLayoutHost import app.pachli.interfaces.StatusActionListener import app.pachli.util.ListStatusAccessibilityDelegate -import app.pachli.util.PresentationState -import app.pachli.util.UserRefreshState -import app.pachli.util.asRefreshState -import app.pachli.util.withPresentationState import at.connyduck.sparkbutton.helpers.Utils +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.color.MaterialColors import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar @@ -96,12 +95,13 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import postPrepend import timber.log.Timber @AndroidEntryPoint @@ -120,7 +120,7 @@ class TimelineFragment : // // If the navigation library was being used this would happen automatically, so this // workaround can be removed when that change happens. - private val viewModel: TimelineViewModel by lazy { + private val viewModel: TimelineViewModel by lazy { if (timeline == Timeline.Home) { viewModels( extrasProducer = { @@ -160,6 +160,19 @@ class TimelineFragment : override var pachliAccountId by Delegates.notNull() + /** + * Collect this flow to notify the adapter that the timestamps of the visible items have + * changed + */ + private val updateTimestampFlow = flow { + while (true) { + delay(60.seconds) + emit(Unit) + } + }.onEach { + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -171,7 +184,7 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - adapter = TimelinePagingAdapter(pachliAccountId, this, viewModel.statusDisplayOptions.value) + adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) } override fun onCreateView( @@ -179,16 +192,6 @@ class TimelineFragment : container: ViewGroup?, savedInstanceState: Bundle?, ): View? { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { - launch { - viewModel.statuses.collectLatest { pagingData -> - adapter.submitData(pagingData) - } - } - } - } - return inflater.inflate(R.layout.fragment_timeline, container, false) } @@ -216,123 +219,13 @@ class TimelineFragment : } } - /** - * Collect this flow to notify the adapter that the timestamps of the visible items have - * changed - */ - // TODO: Copied from NotificationsFragment - val updateTimestampFlow = flow { - while (true) { - delay(60.seconds) - emit(Unit) - } - }.onEach { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - // Show errors from the view model as snack bars. - // - // Errors are shown: - // - Indefinitely, so the user has a chance to read and understand - // the message - // - With a max of 5 text lines, to allow space for longer errors. - // E.g., on a typical device, an error message like "Bookmarking - // post failed: Unable to resolve host 'mastodon.social': No - // address associated with hostname" is 3 lines. - // - With a "Retry" option if the error included a UiAction to retry. - // TODO: Very similar to same code in NotificationsFragment - launch { - viewModel.uiError.collect { error -> - val message = getString( - error.message, - error.throwable.getErrorString(requireContext()), - ) - Timber.d(error.throwable, message) - snackbar?.dismiss() - snackbar = Snackbar.make( - // Without this the FAB will not move out of the way - (activity as? ActionButtonActivity)?.actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE, - ) - error.action?.let { action -> - snackbar!!.setAction(app.pachli.core.ui.R.string.action_retry) { - viewModel.accept(action) - } - } - snackbar!!.show() - - // The status view has pre-emptively updated its state to show - // that the action succeeded. Since it hasn't, re-bind the view - // to show the correct data. - error.action?.let { action -> - if (action !is StatusAction) return@let - - adapter.snapshot() - .indexOfFirst { it?.id == action.statusViewData.id } - .takeIf { it != RecyclerView.NO_POSITION } - ?.let { adapter.notifyItemChanged(it) } - } - } - } - - // Update adapter data when status actions are successful, and re-bind to update - // the UI. - launch { - viewModel.uiSuccess - .filterIsInstance() - .collect { - val indexedViewData = adapter.snapshot() - .withIndex() - .firstOrNull { indexed -> - indexed.value?.id == it.action.statusViewData.id - } ?: return@collect - - val statusViewData = - indexedViewData.value ?: return@collect - - val status = when (it) { - is StatusActionSuccess.Bookmark -> - statusViewData.status.copy(bookmarked = it.action.state) - is StatusActionSuccess.Favourite -> - statusViewData.status.copy(favourited = it.action.state) - is StatusActionSuccess.Reblog -> - statusViewData.status.copy(reblogged = it.action.state) - is StatusActionSuccess.VoteInPoll -> - statusViewData.status.copy( - poll = it.action.poll.votedCopy(it.action.choices), - ) - is StatusActionSuccess.Translate -> statusViewData.status - } - (indexedViewData.value as StatusViewData).status = status + launch { viewModel.statuses.collectLatest { adapter.submitData(it) } } - adapter.notifyItemChanged(indexedViewData.index) - } - } - - // Refresh adapter on mutes and blocks - launch { - viewModel.uiSuccess.collectLatest { - when (it) { - is UiSuccess.Block, - is UiSuccess.Mute, - is UiSuccess.MuteConversation, - -> - adapter.refresh() - - is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) - is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) - - else -> { /* nothing to do */ } - } - } - } + launch { viewModel.uiResult.collect(::bindUiResult) } - // Collect the uiState. Nothing is done with it, but if you don't collect it then - // accessing viewModel.uiState.value (e.g., to check whether the FAB should be - // hidden) always returns the initial state. + // Collect the uiState. launch { viewModel.uiState.collect { uiState -> if (layoutManager.reverseLayout != uiState.reverseTimeline) { @@ -348,162 +241,188 @@ class TimelineFragment : // Update status display from statusDisplayOptions. If the new options request // relative time display collect the flow to periodically re-bind the UI. launch { - viewModel.statusDisplayOptions - .collectLatest { - adapter.statusDisplayOptions = it - layoutManager.findFirstVisibleItemPosition().let { first -> - first == RecyclerView.NO_POSITION && return@let - val count = layoutManager.findLastVisibleItemPosition() - first - adapter.notifyItemRangeChanged( - first, - count, - null, - ) - } + viewModel.statusDisplayOptions.collectLatest { + adapter.statusDisplayOptions = it + adapter.notifyItemRangeChanged(0, adapter.itemCount, null) - if (!it.useAbsoluteTime) { - updateTimestampFlow.collect() - } + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() } + } } - /** StateFlow (to allow multiple consumers) of UserRefreshState */ - val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) - - // Scroll the list down (peek) if a refresh has completely finished. A refresh is - // finished when both the initial refresh is complete and any prepends have - // finished (so that DiffUtil has had a chance to process the data). - launch { - if (!isSwipeToRefreshEnabled) return@launch - - /** True if the previous prepend resulted in a peek, false otherwise */ - var peeked = false + adapter.loadStateFlow.collect { loadState -> + when (loadState.refresh) { + is LoadState.Error -> { + binding.progressIndicator.hide() + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { + adapter.retry() + } + binding.recyclerView.hide() + binding.statusView.show() + binding.swipeRefreshLayout.isRefreshing = false + } - /** ID of the item that was first in the adapter before the refresh */ - var previousFirstId: String? = null + LoadState.Loading -> { + /* nothing */ + binding.statusView.hide() + binding.progressIndicator.show() + } - refreshState.collect { userRefreshState -> - if (userRefreshState == UserRefreshState.ACTIVE) { - // Refresh has started, reset peeked, and save the ID of the first item - // in the adapter - peeked = false - if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id + is LoadState.NotLoading -> { + // Might still be loading if source.refresh is Loading, so only update + // the UI when loading is completely quiet. + Timber.d("NotLoading .refresh: ${loadState.refresh}") + Timber.d(" NotLoading .source.refresh: ${loadState.source.refresh}") + Timber.d(" NotLoading .mediator.refresh: ${loadState.mediator?.refresh}") + if (loadState.source.refresh !is LoadState.Loading) { + binding.progressIndicator.hide() + binding.swipeRefreshLayout.isRefreshing = false + if (adapter.itemCount == 0) { + binding.statusView.setup(BackgroundMessage.Empty()) + if (timeline == Timeline.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + binding.recyclerView.hide() + binding.statusView.show() + } else { + binding.statusView.hide() + binding.recyclerView.show() + } + } } + } + } + } + } + } - if (userRefreshState == UserRefreshState.COMPLETE) { - // Refresh has finished, pages are being prepended. + private fun bindUiResult(uiResult: Result) { + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + uiResult.onFailure { uiError -> + val message = getString( + uiError.message, + uiError.throwable.getErrorString(requireContext()), + ) + Timber.d(uiError.throwable, message) + snackbar?.dismiss() + snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as? ActionButtonActivity)?.actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE, + ) + uiError.action?.let { action -> + snackbar!!.setAction(app.pachli.core.ui.R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar!!.show() - // There might be multiple prepends after a refresh, only continue - // if one them has not already caused a peek. - if (peeked) return@collect + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + uiError.action?.let { action -> + if (action !is StatusAction) return@let - // Compare the ID of the current first item with the previous first - // item. If they're the same then this prepend did not add any new - // items, and can be ignored. - val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null - if (previousFirstId == firstId) return@collect + adapter.snapshot() + .indexOfFirst { it?.id == action.statusViewData.id } + .takeIf { it != RecyclerView.NO_POSITION } + ?.let { adapter.notifyItemChanged(it) } + } + } - // New items were added and haven't peeked for this refresh. Schedule - // a scroll to disclose that new items are available. - binding.recyclerView.post { - getView() ?: return@post - binding.recyclerView.smoothScrollBy( - 0, - Utils.dpToPx(requireContext(), -30), - ) - } - peeked = true - } - } + uiResult.onSuccess { + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + // TODO: No - this should be handled by the ViewModel updating the data + // and invalidating the paging source + if (it is StatusActionSuccess) { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { indexed -> + indexed.value?.id == it.action.statusViewData.id + } ?: return + + val statusViewData = indexedViewData.value ?: return + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices), + ) + + is StatusActionSuccess.Translate -> statusViewData.status } + (indexedViewData.value as StatusViewData).status = status - // Manage the progress display. Rather than hide as soon as the Refresh portion - // completes, hide when then first Prepend completes. This is a better signal to - // the user that it is now possible to scroll up and see new content. - launch { - refreshState.collect { - when (it) { - UserRefreshState.COMPLETE, UserRefreshState.ERROR -> { - binding.swipeRefreshLayout.isRefreshing = false + adapter.notifyItemChanged(indexedViewData.index) + } + + // Refresh adapter on mutes and blocks + when (it) { + is UiSuccess.Block, + is UiSuccess.Mute, + is UiSuccess.MuteConversation, + -> + refreshAdapterAndScrollToVisibleId() + + is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) + is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) + + is UiSuccess.LoadNewest -> { + // Scroll to the top when prepending completes. + viewLifecycleOwner.lifecycleScope.launch { + adapter.postPrepend { + binding.recyclerView.post { + view ?: return@post + binding.recyclerView.scrollToPosition(0) } - else -> { /* nothing to do */ } } } + adapter.refresh() } - // Update the UI from the combined load state - launch { - adapter.loadStateFlow.withPresentationState() - .collect { (loadState, presentationState) -> - when (presentationState) { - PresentationState.ERROR -> { - val error = (loadState.mediator?.refresh as? LoadState.Error)?.error - ?: (loadState.source.refresh as? LoadState.Error)?.error - ?: IllegalStateException("unknown error") - - // TODO: This error message should be specific about the operation - // At the moment it's just e.g., "An error occurred: HTTP 503" - // and a "Retry" button, so the user has no idea what's going - // to be retried. - val message = error.getErrorString(requireContext()) - - // Show errors as a snackbar if there is existing content to show - // (either cached, or in the adapter), or as a full screen error - // otherwise. - // - // Expected errors can be retried, unexpected ones cannot - if (adapter.itemCount > 0) { - snackbar = Snackbar.make( - (activity as ActionButtonActivity).actionButton - ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE, - ).apply { - if (error.isExpected()) { - setAction(app.pachli.core.ui.R.string.action_retry) { adapter.retry() } - } - } - - snackbar!!.show() - } else { - val callback: ((v: View) -> Unit)? = if (error.isExpected()) { - { - snackbar?.dismiss() - adapter.retry() - } - } else { - null - } - - binding.statusView.setup(error, callback) - binding.statusView.show() - binding.recyclerView.hide() - } - } - - PresentationState.PRESENTED -> { - if (adapter.itemCount == 0) { - binding.statusView.setup(BackgroundMessage.Empty()) - if (timeline == Timeline.Home) { - binding.statusView.showHelp(R.string.help_empty_home) - } - binding.statusView.show() - binding.recyclerView.hide() - } else { - binding.recyclerView.show() - binding.statusView.hide() - } - } + else -> { /* nothing to do */ } + } + } + } - else -> { - // Nothing to do -- show/hiding the progress bars in non-error states - // is handled via refreshState. - } - } - } + /** + * Refreshes the adapter, waits for the first page to be updated, and scrolls the + * recyclerview to the first status that was visible before the refresh. + * + * This ensures the user's position is not lost during adapter refreshes. + */ + private fun refreshAdapterAndScrollToVisibleId() { + getFirstVisibleStatus()?.id?.let { id -> + viewLifecycleOwner.lifecycleScope.launch { + adapter.onPagesUpdatedFlow.conflate().take(1).collect { + binding.recyclerView.scrollToPosition( + adapter.snapshot().items.indexOfFirst { it.id == id }, + ) } } } + adapter.refresh() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -541,8 +460,17 @@ class TimelineFragment : } } + private fun getFirstVisibleStatus() = ( + layoutManager.findFirstCompletelyVisibleItemPosition() + .takeIf { it != RecyclerView.NO_POSITION } + ?: layoutManager.findLastVisibleItemPosition() + .takeIf { it != RecyclerView.NO_POSITION } + )?.let { adapter.snapshot().getOrNull(it) } + /** - * Save [statusId] as the reading position. If null then the ID of the best status is used. + * Saves the ID of the first visible status as the reading position. + * + * If null then the ID of the best status is used. * * The best status is the first completely visible status, if available. We assume the user * has read this far, or will recognise it on return. @@ -554,23 +482,14 @@ class TimelineFragment : * In this case the best status is the last partially visible status, as we can assume the * user has read this far. */ - fun saveVisibleId(statusId: String? = null) { - val id = statusId ?: ( - layoutManager.findFirstCompletelyVisibleItemPosition() - .takeIf { it != RecyclerView.NO_POSITION } - ?: layoutManager.findLastVisibleItemPosition() - .takeIf { it != RecyclerView.NO_POSITION } - ) - ?.let { adapter.snapshot().getOrNull(it)?.id } - + fun saveVisibleId() { + val id = getFirstVisibleStatus()?.id if (BuildConfig.DEBUG && id == null) { Toast.makeText(requireActivity(), "Could not find ID of item to save", LENGTH_LONG).show() } - id?.let { - Timber.d("saveVisibleId: Saving ID: %s", it) - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = it)) - } ?: Timber.d("saveVisibleId: Not saving, as no ID was visible") + viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, id)) + } } private fun setupSwipeRefreshLayout() { @@ -615,7 +534,6 @@ class TimelineFragment : /** Refresh the displayed content, as if the user had swiped on the SwipeRefreshLayout */ override fun refreshContent() { Timber.d("Reloading via refreshContent") - binding.swipeRefreshLayout.isRefreshing = true onRefresh() } @@ -625,13 +543,26 @@ class TimelineFragment : */ override fun onRefresh() { Timber.d("Reloading via onRefresh") - binding.statusView.hide() - snackbar?.dismiss() - adapter.refresh() + + // Peek the list when refreshing completes. + viewLifecycleOwner.lifecycleScope.launch { + adapter.postPrepend { + binding.recyclerView.post { + view ?: return@post + binding.recyclerView.smoothScrollBy( + 0, + Utils.dpToPx(requireContext(), -30), + ) + } + } + } + + binding.swipeRefreshLayout.isRefreshing = false + refreshAdapterAndScrollToVisibleId() } - override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { - super.reply(pachliAccountId, viewData.actionable) + override fun onReply(viewData: StatusViewData) { + super.reply(viewData.pachliAccountId, viewData.actionable) } override fun onReblog(viewData: StatusViewData, reblog: Boolean) { @@ -650,8 +581,8 @@ class TimelineFragment : viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData)) } - override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) { - viewModel.clearWarning(pachliAccountId, viewData) + override fun clearContentFilter(viewData: StatusViewData) { + viewModel.clearWarning(viewData) } override fun onEditFilterById(pachliAccountId: Long, filterId: String) { @@ -669,12 +600,12 @@ class TimelineFragment : super.openReblog(status) } - override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) { - viewModel.changeExpanded(pachliAccountId, expanded, viewData) + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { + viewModel.changeExpanded(expanded, viewData) } - override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) { - viewModel.changeContentShowing(pachliAccountId, isShowingContent, viewData) + override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) { + viewModel.changeContentShowing(isShowingContent, viewData) } override fun onShowReblogs(statusId: String) { @@ -687,8 +618,8 @@ class TimelineFragment : activity?.startActivityWithDefaultTransition(intent) } - override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) { - viewModel.changeContentCollapsed(pachliAccountId, isCollapsed, viewData) + override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { + viewModel.changeContentCollapsed(isCollapsed, viewData) } // Can only translate the home timeline at the moment diff --git a/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt index e40b3eb51d..fe69f5adcc 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt @@ -33,7 +33,6 @@ import app.pachli.databinding.ItemStatusWrapperBinding import app.pachli.interfaces.StatusActionListener class TimelinePagingAdapter( - private val pachliAccountId: Long, private val statusListener: StatusActionListener, var statusDisplayOptions: StatusDisplayOptions, ) : PagingDataAdapter(TimelineDifferCallback) { @@ -73,7 +72,6 @@ class TimelinePagingAdapter( null }?.let { (viewHolder as StatusViewHolder).setupWithStatus( - pachliAccountId, it, statusListener, statusDisplayOptions, diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/TimelineRepository.kt new file mode 100644 index 0000000000..993cc3bab5 --- /dev/null +++ b/app/src/main/java/app/pachli/components/timeline/TimelineRepository.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.components.timeline + +import androidx.paging.PagingData +import androidx.paging.PagingSource +import app.pachli.core.database.model.AccountEntity +import app.pachli.core.model.Timeline +import kotlinx.coroutines.flow.Flow + +/** + * Common interface for a repository that provides a [PagingData] timeline of items + * of type [T]. + */ +interface TimelineRepository { + /** @return Flow of [T] for [account] and [kind]. */ + suspend fun getStatusStream(account: AccountEntity, kind: Timeline): Flow> + + /** Invalidate the active paging source for [pachliAccountId], see [PagingSource.invalidate] */ + suspend fun invalidate(pachliAccountId: Long) + + companion object { + /** Default page size when fetching remote items. */ + const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 464bd47ebd..ed20cf3837 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -18,7 +18,6 @@ package app.pachli.components.timeline.viewmodel import androidx.paging.ExperimentalPagingApi -import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator @@ -42,10 +41,8 @@ import timber.log.Timber @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( - private val initialKey: String?, - private val api: MastodonApi, + private val mastodonApi: MastodonApi, private val pachliAccountId: Long, - private val factory: InvalidatingPagingSourceFactory, private val transactionProvider: TransactionProvider, private val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, @@ -59,32 +56,35 @@ class CachedTimelineRemoteMediator( return try { val response = when (loadType) { LoadType.REFRESH -> { - val closestItem = state.anchorPosition?.let { - state.closestItemToPosition(maxOf(0, it - (state.config.pageSize / 2))) - }?.status?.serverId - val statusId = closestItem ?: initialKey + // Ignore the provided state, always try and fetch from the remote + // REFRESH key. + val statusId = remoteKeyDao.remoteKeyForKind( + pachliAccountId, + RKE_TIMELINE_ID, + RemoteKeyKind.REFRESH, + )?.key Timber.d("Loading from item: %s", statusId) getInitialPage(statusId, state.config.pageSize) } - LoadType.APPEND -> { + LoadType.PREPEND -> { val rke = remoteKeyDao.remoteKeyForKind( pachliAccountId, RKE_TIMELINE_ID, - RemoteKeyKind.NEXT, + RemoteKeyKind.PREV, ) ?: return MediatorResult.Success(endOfPaginationReached = true) Timber.d("Loading from remoteKey: %s", rke) - api.homeTimeline(maxId = rke.key, limit = state.config.pageSize) + mastodonApi.homeTimeline(minId = rke.key, limit = state.config.pageSize) } - LoadType.PREPEND -> { + LoadType.APPEND -> { val rke = remoteKeyDao.remoteKeyForKind( pachliAccountId, RKE_TIMELINE_ID, - RemoteKeyKind.PREV, + RemoteKeyKind.NEXT, ) ?: return MediatorResult.Success(endOfPaginationReached = true) Timber.d("Loading from remoteKey: %s", rke) - api.homeTimeline(minId = rke.key, limit = state.config.pageSize) + mastodonApi.homeTimeline(maxId = rke.key, limit = state.config.pageSize) } } @@ -98,7 +98,6 @@ class CachedTimelineRemoteMediator( // This request succeeded with no new data, and pagination ends (unless this is a // REFRESH, which must always set endOfPaginationReached to false). if (statuses.isEmpty()) { - factory.invalidate() return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH) } @@ -109,8 +108,8 @@ class CachedTimelineRemoteMediator( transactionProvider { when (loadType) { LoadType.REFRESH -> { - remoteKeyDao.delete(pachliAccountId, RKE_TIMELINE_ID) - timelineDao.removeAllStatuses(pachliAccountId) + remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID) + timelineDao.deleteAllStatusesForAccount(pachliAccountId) remoteKeyDao.upsert( RemoteKeyEntity( @@ -120,6 +119,7 @@ class CachedTimelineRemoteMediator( links.next, ), ) + remoteKeyDao.upsert( RemoteKeyEntity( pachliAccountId, @@ -179,7 +179,7 @@ class CachedTimelineRemoteMediator( */ private suspend fun getInitialPage(statusId: String?, pageSize: Int): Response> = coroutineScope { // If the key is null this is straightforward, just return the most recent statuses. - statusId ?: return@coroutineScope api.homeTimeline(limit = pageSize) + statusId ?: return@coroutineScope mastodonApi.homeTimeline(limit = pageSize) // It's important to return *something* from this state. If an empty page is returned // (even with next/prev links) Pager3 assumes there is no more data to load and stops. @@ -189,13 +189,17 @@ class CachedTimelineRemoteMediator( // you can not fetch the page itself. // Fetch the requested status, and the page immediately after (next) - val deferredStatus = async { api.status(statusId = statusId) } + val deferredStatus = async { mastodonApi.status(statusId = statusId) } + val deferredPrevPage = async { + mastodonApi.homeTimeline(minId = statusId, limit = pageSize * 3) + } val deferredNextPage = async { - api.homeTimeline(maxId = statusId, limit = pageSize) + mastodonApi.homeTimeline(maxId = statusId, limit = pageSize * 3) } deferredStatus.await().getOrNull()?.let { status -> val statuses = buildList { + deferredPrevPage.await().body()?.let { this.addAll(it) } this.add(status) deferredNextPage.await().body()?.let { this.addAll(it) } } @@ -218,43 +222,41 @@ class CachedTimelineRemoteMediator( // The user's last read status was missing. Use the page of statuses chronologically older // than their desired status. This page must *not* be empty (as noted earlier, if it is, // paging stops). - deferredNextPage.await().let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response + deferredNextPage.await().apply { + if (isSuccessful && !body().isNullOrEmpty()) { + return@coroutineScope this } } // There were no statuses older than the user's desired status. Return the page // of statuses immediately newer than their desired status. This page must // *not* be empty (as noted earlier, if it is, paging stops). - api.homeTimeline(minId = statusId, limit = pageSize).let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response + deferredPrevPage.await().apply { + if (isSuccessful && !body().isNullOrEmpty()) { + return@coroutineScope this } } // Everything failed -- fallback to fetching the most recent statuses - return@coroutineScope api.homeTimeline(limit = pageSize) + return@coroutineScope mastodonApi.homeTimeline(limit = pageSize) } /** * Inserts `statuses` and the accounts referenced by those statuses in to the cache. */ private suspend fun insertStatuses(pachliAccountId: Long, statuses: List) { - for (status in statuses) { - timelineDao.insertAccount(TimelineAccountEntity.from(status.account, pachliAccountId)) - status.reblog?.account?.let { - val account = TimelineAccountEntity.from(it, pachliAccountId) - timelineDao.insertAccount(account) - } + check(transactionProvider.inTransaction()) - timelineDao.insertStatus( - TimelineStatusEntity.from( - status, - timelineUserId = pachliAccountId, - ), - ) + /** Unique accounts referenced in this batch of statuses. */ + val accounts = buildSet { + statuses.forEach { status -> + add(status.account) + status.reblog?.account?.let { add(it) } + } } + + timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) + timelineDao.upsertStatuses(statuses.map { TimelineStatusEntity.from(it, pachliAccountId) }) } companion object { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index 768c89e01f..d3b3335886 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -28,6 +28,7 @@ import app.pachli.core.data.model.StatusViewData import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.eventhub.BookmarkEvent import app.pachli.core.eventhub.EventHub import app.pachli.core.eventhub.FavoriteEvent @@ -59,35 +60,30 @@ class CachedTimelineViewModel @Inject constructor( accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, -) : TimelineViewModel( +) : TimelineViewModel( savedStateHandle, timelineCases, eventHub, accountManager, + repository, statusDisplayOptionsRepository, sharedPreferencesRepository, ) { - override var statuses: Flow> - - init { - readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId - - statuses = refreshFlow.flatMapLatest { - getStatuses(it.second, initialKey = getInitialKey()) - }.cachedIn(viewModelScope) - } + override var statuses = accountFlow.flatMapLatest { + getStatuses(it.data!!) + }.cachedIn(viewModelScope) /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ - private fun getStatuses( + private suspend fun getStatuses( account: AccountEntity, - initialKey: String? = null, ): Flow> { - Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey) - return repository.getStatusStream(account, kind = timeline, initialKey = initialKey) + Timber.d("getStatuses: kind: %s", timeline) + return repository.getStatusStream(account, timeline) .map { pagingData -> pagingData .map { StatusViewData.from( + pachliAccountId = account.id, it, isExpanded = activeAccount.alwaysOpenSpoiler, isShowingContent = activeAccount.alwaysShowSensitiveMedia, @@ -102,21 +98,21 @@ class CachedTimelineViewModel @Inject constructor( // handled by CacheUpdater } - override fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) { + override fun changeExpanded(expanded: Boolean, status: StatusViewData) { viewModelScope.launch { - repository.saveStatusViewData(pachliAccountId, status.copy(isExpanded = expanded)) + repository.saveStatusViewData(status.copy(isExpanded = expanded)) } } - override fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData) { + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { viewModelScope.launch { - repository.saveStatusViewData(pachliAccountId, status.copy(isShowingContent = isShowing)) + repository.saveStatusViewData(status.copy(isShowingContent = isShowing)) } } - override fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData) { + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { viewModelScope.launch { - repository.saveStatusViewData(pachliAccountId, status.copy(isCollapsed = isCollapsed)) + repository.saveStatusViewData(status.copy(isCollapsed = isCollapsed)) } } @@ -132,9 +128,9 @@ class CachedTimelineViewModel @Inject constructor( } } - override fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData) { + override fun clearWarning(statusViewData: StatusViewData) { viewModelScope.launch { - repository.clearStatusWarning(pachliAccountId, statusViewData.actionableId) + repository.clearStatusWarning(statusViewData.pachliAccountId, statusViewData.actionableId) } } @@ -158,20 +154,6 @@ class CachedTimelineViewModel @Inject constructor( // handled by CacheUpdater } - override fun reloadKeepingReadingPosition(pachliAccountId: Long) { - super.reloadKeepingReadingPosition(pachliAccountId) - viewModelScope.launch { - repository.clearAndReload(pachliAccountId) - } - } - - override fun reloadFromNewest(pachliAccountId: Long) { - super.reloadFromNewest(pachliAccountId) - viewModelScope.launch { - repository.clearAndReloadFromNewest(pachliAccountId) - } - } - override suspend fun invalidate(pachliAccountId: Long) { repository.invalidate(pachliAccountId) } diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 7cf4a10f42..b0635caac9 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -35,6 +35,7 @@ import app.pachli.core.eventhub.PinEvent import app.pachli.core.eventhub.ReblogEvent import app.pachli.core.model.FilterAction import app.pachli.core.network.model.Poll +import app.pachli.core.network.model.Status import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import dagger.hilt.android.lifecycle.HiltViewModel @@ -59,11 +60,12 @@ class NetworkTimelineViewModel @Inject constructor( accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, -) : TimelineViewModel( +) : TimelineViewModel( savedStateHandle, timelineCases, eventHub, accountManager, + repository, statusDisplayOptionsRepository, sharedPreferencesRepository, ) { @@ -72,22 +74,20 @@ class NetworkTimelineViewModel @Inject constructor( override var statuses: Flow> init { - statuses = refreshFlow - .flatMapLatest { - getStatuses(it.second, initialKey = getInitialKey()) - }.cachedIn(viewModelScope) + statuses = accountFlow + .flatMapLatest { getStatuses(it.data!!) }.cachedIn(viewModelScope) } /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ - private fun getStatuses( + private suspend fun getStatuses( account: AccountEntity, - initialKey: String? = null, ): Flow> { - Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey) - return repository.getStatusStream(account, kind = timeline, initialKey = initialKey) + Timber.d("getStatuses: kind: %s", timeline) + return repository.getStatusStream(account, kind = timeline) .map { pagingData -> pagingData.map { modifiedViewData[it.id] ?: StatusViewData.from( + pachliAccountId = account.id, it, isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive, isExpanded = statusDisplayOptions.value.openSpoiler, @@ -105,21 +105,21 @@ class NetworkTimelineViewModel @Inject constructor( repository.invalidate() } - override fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) { + override fun changeExpanded(expanded: Boolean, status: StatusViewData) { modifiedViewData[status.id] = status.copy( isExpanded = expanded, ) repository.invalidate() } - override fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData) { + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { modifiedViewData[status.id] = status.copy( isShowingContent = isShowing, ) repository.invalidate() } - override fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData) { + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { Timber.d("changeContentCollapsed: %s", isCollapsed) Timber.d(" %s", status.content) modifiedViewData[status.id] = status.copy( @@ -181,19 +181,7 @@ class NetworkTimelineViewModel @Inject constructor( repository.invalidate() } - override fun reloadKeepingReadingPosition(pachliAccountId: Long) { - super.reloadKeepingReadingPosition(pachliAccountId) - viewModelScope.launch { - repository.reload() - } - } - - override fun reloadFromNewest(pachliAccountId: Long) { - super.reloadFromNewest(pachliAccountId) - reloadKeepingReadingPosition(pachliAccountId) - } - - override fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData) { + override fun clearWarning(statusViewData: StatusViewData) { viewModelScope.launch { repository.updateActionableStatusById(statusViewData.actionableId) { it.copy(filtered = null) diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt index 416d3c1476..59a3b6a1e6 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt @@ -17,7 +17,6 @@ package app.pachli.components.timeline.viewmodel -import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.core.os.bundleOf @@ -26,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import app.pachli.R +import app.pachli.components.timeline.TimelineRepository import app.pachli.core.common.extensions.throttleFirst import app.pachli.core.data.model.StatusViewData import app.pachli.core.data.repository.AccountManager @@ -58,21 +58,21 @@ import app.pachli.core.preferences.TabTapBehaviour import app.pachli.network.ContentFilterModel import app.pachli.usecase.TimelineCases import at.connyduck.calladapter.networkresult.getOrThrow +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.fold -import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -114,7 +114,10 @@ sealed interface InfallibleUiAction : UiAction { * Infallible because if it fails there's nowhere to show the error, and nothing the user * can do. */ - data class SaveVisibleId(val visibleId: String) : InfallibleUiAction + data class SaveVisibleId( + val pachliAccountId: Long, + val visibleId: String, + ) : InfallibleUiAction /** Ignore the saved reading position, load the page with the newest items */ // Resets the account's reading position, which can't fail, which is why this is @@ -145,6 +148,12 @@ sealed interface UiSuccess { /** A status the user wrote was successfully edited */ data class StatusEdited(val status: Status) : UiSuccess + + /** + * Resetting the reading position completed, the UI should refresh the adapter + * to load content at the new position. + */ + data object LoadNewest : UiSuccess } /** Actions the user can trigger on an individual status */ @@ -268,11 +277,12 @@ sealed interface UiError { } } -abstract class TimelineViewModel( +abstract class TimelineViewModel( savedStateHandle: SavedStateHandle, private val timelineCases: TimelineCases, private val eventHub: EventHub, protected val accountManager: AccountManager, + private val repository: TimelineRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, ) : ViewModel() { @@ -286,26 +296,8 @@ abstract class TimelineViewModel( /** Flow of user actions received from the UI */ private val uiAction = MutableSharedFlow() - /** Flow that can be used to trigger a full reload */ - protected val reload = MutableStateFlow(0) - - /** Flow of successful action results */ - // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be - // retained. A message is shown once to a user and then dismissed. Re-collecting the flow - // (e.g., after a device orientation change) should not re-show the most recent success - // message, as it will be confusing to the user. - val uiSuccess = MutableSharedFlow() - - @Suppress("ktlint:standard:property-naming") - /** Channel for error results */ - // Errors are sent to a channel to ensure that any errors that occur *before* there are any - // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it - // was a StateFlow any errors would be retained, and there would need to be an explicit - // mechanism to dismiss them. - private val _uiErrorChannel = Channel() - - /** Expose UI errors as a flow */ - val uiError = _uiErrorChannel.receiveAsFlow() + private val _uiResult = Channel>() + val uiResult = _uiResult.receiveAsFlow() /** Accept UI actions in to actionStateFlow */ val accept: (UiAction) -> Unit = { action -> @@ -323,18 +315,10 @@ abstract class TimelineViewModel( return accountManager.activeAccount!! } - protected val refreshFlow = reload.combine( - accountManager.activeAccountFlow - .filterIsInstance>() - .filter { it.data != null } - .distinctUntilChangedBy { it.data?.id!! }, - ) { refresh, account -> Pair(refresh, account.data!!) } - - /** The ID of the status to which the user's reading position should be restored */ - // Not part of the UiState as it's only used once in the lifespan of the fragment. - // Subclasses should set this if they support restoring the reading position. - open var readingPositionId: String? = null - protected set + protected val accountFlow = accountManager.activeAccountFlow + .filterIsInstance>() + .filter { it.data != null } + .distinctUntilChangedBy { it.data?.id!! } private var contentFilterModel: ContentFilterModel? = null @@ -348,9 +332,7 @@ abstract class TimelineViewModel( ContentFilterVersion.V2 -> ContentFilterModel(filterContext) ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters) } - if (reload) { - reloadKeepingReadingPosition(account.id) - } + if (reload) repository.invalidate(account.id) true } } @@ -385,12 +367,15 @@ abstract class TimelineViewModel( action.choices, ) is StatusAction.Translate -> { - timelineCases.translate(activeAccount.id, action.statusViewData) + timelineCases.translate(action.statusViewData) } }.getOrThrow() - uiSuccess.emit(StatusActionSuccess.from(action)) + // TODO: This should look like the equivalent code in + // NotificationsViewModel when timelineCases returns + // Result<_, _> instead of NetworkResult. + _uiResult.send(Ok(StatusActionSuccess.from(action))) } catch (e: Exception) { - _uiErrorChannel.send(UiError.make(e, action)) + _uiResult.send(Err(UiError.make(e, action))) } } } @@ -399,11 +384,11 @@ abstract class TimelineViewModel( viewModelScope.launch { eventHub.events.collectLatest { when (it) { - is BlockEvent -> uiSuccess.emit(UiSuccess.Block) - is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) - is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) - is StatusComposedEvent -> uiSuccess.emit(UiSuccess.StatusSent(it.status)) - is StatusEditedEvent -> uiSuccess.emit(UiSuccess.StatusEdited(it.status)) + is BlockEvent -> _uiResult.send(Ok(UiSuccess.Block)) + is MuteEvent -> _uiResult.send(Ok(UiSuccess.Mute)) + is MuteConversationEvent -> _uiResult.send(Ok(UiSuccess.MuteConversation)) + is StatusComposedEvent -> _uiResult.send(Ok(UiSuccess.StatusSent(it.status))) + is StatusEditedEvent -> _uiResult.send(Ok(UiSuccess.StatusEdited(it.status))) } } } @@ -455,8 +440,7 @@ abstract class TimelineViewModel( .distinctUntilChanged() .collectLatest { action -> Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId) - accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, action.visibleId) - readingPositionId = action.visibleId + timelineCases.saveRefreshKey(activeAccount.id, action.visibleId) } } } @@ -467,10 +451,10 @@ abstract class TimelineViewModel( .filterIsInstance() .collectLatest { if (timeline == Timeline.Home) { - accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, null) + timelineCases.saveRefreshKey(activeAccount.id, null) } Timber.d("Reload because InfallibleUiAction.LoadNewest") - reloadFromNewest(activeAccount.id) + _uiResult.send(Ok(UiSuccess.LoadNewest)) } } @@ -481,27 +465,16 @@ abstract class TimelineViewModel( } } - viewModelScope.launch { - eventHub.events - .collect { event -> handleEvent(event) } - } - } - - fun getInitialKey(): String? { - if (timeline != Timeline.Home) { - return null - } - - return activeAccount.lastVisibleHomeTimelineStatusId + viewModelScope.launch { eventHub.events.collect { handleEvent(it) } } } abstract fun updatePoll(newPoll: Poll, status: StatusViewData) - abstract fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) + abstract fun changeExpanded(expanded: Boolean, status: StatusViewData) - abstract fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData) + abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData) - abstract fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData) + abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) abstract fun removeAllByAccountId(pachliAccountId: Long, accountId: String) @@ -517,27 +490,7 @@ abstract class TimelineViewModel( abstract fun handlePinEvent(pinEvent: PinEvent) - /** - * Reload data for this timeline while preserving the user's reading position. - * - * Subclasses should call this, then start loading data. - */ - @CallSuper - open fun reloadKeepingReadingPosition(pachliAccountId: Long) { - reload.getAndUpdate { it + 1 } - } - - /** - * Load the most recent data for this timeline, ignoring the user's reading position. - * - * Subclasses should call this, then start loading data. - */ - @CallSuper - open fun reloadFromNewest(pachliAccountId: Long) { - reload.getAndUpdate { it + 1 } - } - - abstract fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData) + abstract fun clearWarning(statusViewData: StatusViewData) /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate(pachliAccountId: Long) @@ -556,7 +509,7 @@ abstract class TimelineViewModel( } // TODO: Update this so that the list of UIPrefs is correct - private fun onPreferenceChanged(key: String) { + private suspend fun onPreferenceChanged(key: String) { when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { val filter = sharedPreferencesRepository.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) @@ -564,7 +517,7 @@ abstract class TimelineViewModel( filterRemoveReplies = timeline is Timeline.Home && !filter if (oldRemoveReplies != filterRemoveReplies) { Timber.d("Reload because TAB_FILTER_HOME_REPLIES changed") - reloadKeepingReadingPosition(activeAccount.id) + repository.invalidate(activeAccount.id) } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { @@ -573,7 +526,7 @@ abstract class TimelineViewModel( filterRemoveReblogs = timeline is Timeline.Home && !filter if (oldRemoveReblogs != filterRemoveReblogs) { Timber.d("Reload because TAB_FILTER_HOME_BOOSTS changed") - reloadKeepingReadingPosition(activeAccount.id) + repository.invalidate(activeAccount.id) } } PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { @@ -582,13 +535,13 @@ abstract class TimelineViewModel( filterRemoveSelfReblogs = timeline is Timeline.Home && !filter if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { Timber.d("Reload because TAB_SHOW_SOME_SELF_BOOSTS changed") - reloadKeepingReadingPosition(activeAccount.id) + repository.invalidate(activeAccount.id) } } } } - private fun handleEvent(event: Event) { + private suspend fun handleEvent(event: Event) { when (event) { is FavoriteEvent -> handleFavEvent(event) is ReblogEvent -> handleReblogEvent(event) @@ -596,7 +549,7 @@ abstract class TimelineViewModel( is PinEvent -> handlePinEvent(event) is MuteConversationEvent -> { Timber.d("Reload because MuteConversationEvent") - reloadKeepingReadingPosition(activeAccount.id) + repository.invalidate(activeAccount.id) } is UnfollowEvent -> { if (timeline is Timeline.Home) { diff --git a/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt b/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt index 738df7b474..95748bdc03 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt @@ -33,7 +33,6 @@ import app.pachli.databinding.ItemStatusWrapperBinding import app.pachli.interfaces.StatusActionListener class ThreadAdapter( - private val pachliAccountId: Long, private val statusDisplayOptions: StatusDisplayOptions, private val statusActionListener: StatusActionListener, ) : ListAdapter>(ThreadDifferCallback) { @@ -56,7 +55,7 @@ class ThreadAdapter( override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { val status = getItem(position) - viewHolder.setupWithStatus(pachliAccountId, status, statusActionListener, statusDisplayOptions) + viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt index 1e7f9ed0ae..93a94b2529 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt @@ -96,7 +96,7 @@ class ViewThreadFragment : lifecycleScope.launch { val statusDisplayOptions = viewModel.statusDisplayOptions.value - adapter = ThreadAdapter(pachliAccountId, statusDisplayOptions, this@ViewThreadFragment) + adapter = ThreadAdapter(statusDisplayOptions, this@ViewThreadFragment) } } @@ -279,8 +279,8 @@ class ViewThreadFragment : viewModel.refresh(thisThreadsStatusId) } - override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { - super.reply(pachliAccountId, viewData.actionable) + override fun onReply(viewData: StatusViewData) { + super.reply(viewData.pachliAccountId, viewData.actionable) } override fun onReblog(viewData: StatusViewData, reblog: Boolean) { @@ -339,11 +339,11 @@ class ViewThreadFragment : ) } - override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) { + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { viewModel.changeExpanded(expanded, viewData) } - override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) { + override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) { viewModel.changeContentShowing(isShowingContent, viewData) } @@ -357,7 +357,7 @@ class ViewThreadFragment : activity?.startActivityWithDefaultTransition(intent) } - override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) { + override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { viewModel.changeContentCollapsed(isCollapsed, viewData) } @@ -397,7 +397,7 @@ class ViewThreadFragment : } } - override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) { + override fun clearContentFilter(viewData: StatusViewData) { viewModel.clearWarning(viewData) } diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index d7386412aa..2ab1f73d72 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -147,6 +147,7 @@ class ViewThreadViewModel @Inject constructor( // status content is the same. Then the status flickers as it is drawn twice. if (status.actionableId == id) { StatusViewData.from( + pachliAccountId = account.id, status = status.actionableStatus, isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler, isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), @@ -157,6 +158,7 @@ class ViewThreadViewModel @Inject constructor( ) } else { StatusViewData.from( + pachliAccountId = account.id, timelineStatusWithAccount, isExpanded = account.alwaysOpenSpoiler, isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), @@ -186,6 +188,7 @@ class ViewThreadViewModel @Inject constructor( if (timelineStatusWithAccount != null) { api.status(id).getOrNull()?.let { detailedStatus = StatusViewData.from( + pachliAccountId = account.id, it, isShowingContent = detailedStatus.isShowingContent, isExpanded = detailedStatus.isExpanded, @@ -207,6 +210,7 @@ class ViewThreadViewModel @Inject constructor( val ancestors = statusContext.ancestors.map { status -> val svd = cachedViewData[status.id] StatusViewData.from( + pachliAccountId = account.id, status, isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler, @@ -219,6 +223,7 @@ class ViewThreadViewModel @Inject constructor( val descendants = statusContext.descendants.map { status -> val svd = cachedViewData[status.id] StatusViewData.from( + pachliAccountId = account.id, status, isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler, @@ -336,7 +341,7 @@ class ViewThreadViewModel @Inject constructor( ) } viewModelScope.launch { - repository.saveStatusViewData(activeAccount.id, status.copy(isExpanded = expanded)) + repository.saveStatusViewData(status.copy(isExpanded = expanded)) } } @@ -345,7 +350,7 @@ class ViewThreadViewModel @Inject constructor( viewData.copy(isShowingContent = isShowing) } viewModelScope.launch { - repository.saveStatusViewData(activeAccount.id, status.copy(isShowingContent = isShowing)) + repository.saveStatusViewData(status.copy(isShowingContent = isShowing)) } } @@ -354,7 +359,7 @@ class ViewThreadViewModel @Inject constructor( viewData.copy(isCollapsed = isCollapsed) } viewModelScope.launch { - repository.saveStatusViewData(activeAccount.id, status.copy(isCollapsed = isCollapsed)) + repository.saveStatusViewData(status.copy(isCollapsed = isCollapsed)) } } @@ -456,11 +461,11 @@ class ViewThreadViewModel @Inject constructor( fun translate(statusViewData: StatusViewData) { viewModelScope.launch { - repository.translate(activeAccount.id, statusViewData).fold( + repository.translate(statusViewData).fold( { val translatedEntity = TranslatedStatusEntity( serverId = statusViewData.actionableId, - timelineUserId = activeAccount.id, + timelineUserId = statusViewData.pachliAccountId, content = it.content, spoilerText = it.spoilerText, poll = it.poll, @@ -490,7 +495,6 @@ class ViewThreadViewModel @Inject constructor( } viewModelScope.launch { repository.saveStatusViewData( - activeAccount.id, statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL), ) } @@ -563,6 +567,7 @@ class ViewThreadViewModel @Inject constructor( private fun StatusViewData.Companion.fromStatusAndUiState(account: AccountEntity, status: Status, isDetailed: Boolean = false): StatusViewData { val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id } return from( + pachliAccountId = account.id, status, isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler, diff --git a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt index 8182b9f7fa..64f5e0191e 100644 --- a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt +++ b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt @@ -24,7 +24,7 @@ import app.pachli.core.network.model.Status import app.pachli.core.ui.LinkListener interface StatusActionListener : LinkListener { - fun onReply(pachliAccountId: Long, viewData: T) + fun onReply(viewData: T) fun onReblog(viewData: T, reblog: Boolean) fun onFavourite(viewData: T, favourite: Boolean) fun onBookmark(viewData: T, bookmark: Boolean) @@ -36,8 +36,8 @@ interface StatusActionListener : LinkListener { * Open reblog author for the status. */ fun onOpenReblog(status: Status) - fun onExpandedChange(pachliAccountId: Long, viewData: T, expanded: Boolean) - fun onContentHiddenChange(pachliAccountId: Long, viewData: T, isShowingContent: Boolean) + fun onExpandedChange(viewData: T, expanded: Boolean) + fun onContentHiddenChange(viewData: T, isShowingContent: Boolean) /** * Called when the status [android.widget.ToggleButton] responsible for collapsing long @@ -45,7 +45,7 @@ interface StatusActionListener : LinkListener { * * @param isCollapsed Whether the status content is shown in a collapsed state or fully. */ - fun onContentCollapsedChange(pachliAccountId: Long, viewData: T, isCollapsed: Boolean) + fun onContentCollapsedChange(viewData: T, isCollapsed: Boolean) /** * called when the reblog count has been clicked @@ -60,7 +60,7 @@ interface StatusActionListener : LinkListener { fun onShowEdits(statusId: String) {} /** Remove the content filter from the status. */ - fun clearContentFilter(pachliAccountId: Long, viewData: T) + fun clearContentFilter(viewData: T) /** Edit the filter that matched this status. */ fun onEditFilterById(pachliAccountId: Long, filterId: String) diff --git a/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt index 9c1a996d16..78b2f7831b 100644 --- a/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt @@ -33,7 +33,7 @@ class DeveloperToolsUseCase @Inject constructor( * Clear the home timeline cache. */ suspend fun clearHomeTimelineCache(accountId: Long) { - timelineDao.removeAllStatuses(accountId) + timelineDao.deleteAllStatusesForAccount(accountId) } /** diff --git a/app/src/main/java/app/pachli/usecase/TimelineCases.kt b/app/src/main/java/app/pachli/usecase/TimelineCases.kt index 3f6b58a10c..b616da9bd9 100644 --- a/app/src/main/java/app/pachli/usecase/TimelineCases.kt +++ b/app/src/main/java/app/pachli/usecase/TimelineCases.kt @@ -143,11 +143,15 @@ class TimelineCases @Inject constructor( return mastodonApi.rejectFollowRequest(accountId) } - suspend fun translate(pachliAccountId: Long, statusViewData: StatusViewData): NetworkResult { - return cachedTimelineRepository.translate(pachliAccountId, statusViewData) + suspend fun translate(statusViewData: StatusViewData): NetworkResult { + return cachedTimelineRepository.translate(statusViewData) } suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) { - cachedTimelineRepository.translateUndo(pachliAccountId, statusViewData) + cachedTimelineRepository.translateUndo(statusViewData) + } + + suspend fun saveRefreshKey(pachliAccountId: Long, statusId: String?) { + cachedTimelineRepository.saveRefreshKey(pachliAccountId, statusId) } } diff --git a/app/src/main/java/app/pachli/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/app/pachli/util/CombinedLoadStatesExtensions.kt deleted file mode 100644 index 61b4d13d5c..0000000000 --- a/app/src/main/java/app/pachli/util/CombinedLoadStatesExtensions.kt +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright 2023 Pachli Association - * - * This file is a part of Pachli. - * - * 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. - * - * Pachli 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 Pachli; if not, - * see . - */ - -package app.pachli.util - -import androidx.paging.CombinedLoadStates -import androidx.paging.LoadState -import app.pachli.BuildConfig -import app.pachli.util.PresentationState.INITIAL -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.scan -import timber.log.Timber - -/** - * Each [CombinedLoadStates] state does not contain enough information to understand the actual - * state unless previous states have been observed. - * - * This tracks those states and provides a [PresentationState] that describes whether the most - * recent refresh has presented the data via the associated adapter. - */ -enum class PresentationState { - /** Initial state, nothing is known about the load state */ - INITIAL, - - /** RemoteMediator is loading the first requested page of results */ - REMOTE_LOADING, - - /** PagingSource is loading the first requested page of results */ - SOURCE_LOADING, - - /** There was an error loading the first page of results */ - ERROR, - - /** The first request page of results is visible via the adapter */ - PRESENTED, - - ; - - /** - * Take the next step in the PresentationState state machine, given [loadState] - */ - fun next(loadState: CombinedLoadStates): PresentationState { - if (loadState.mediator?.refresh is LoadState.Error) return ERROR - if (loadState.source.refresh is LoadState.Error) return ERROR - - return when (this) { - INITIAL -> when (loadState.mediator?.refresh) { - is LoadState.Loading -> REMOTE_LOADING - else -> this - } - - REMOTE_LOADING -> when (loadState.source.refresh) { - is LoadState.Loading -> SOURCE_LOADING - else -> this - } - - SOURCE_LOADING -> when (loadState.refresh) { - is LoadState.NotLoading -> PRESENTED - else -> this - } - - ERROR -> INITIAL.next(loadState) - - PRESENTED -> when (loadState.mediator?.refresh) { - is LoadState.Loading -> REMOTE_LOADING - else -> this - } - } - } -} - -/** - * @return Flow that combines the [CombinedLoadStates] with its associated [PresentationState]. - */ -fun Flow.withPresentationState(): Flow> { - val presentationStateFlow = scan(INITIAL) { state, loadState -> - state.next(loadState) - } - .distinctUntilChanged() - - return this.combine(presentationStateFlow) { loadState, presentationState -> - Pair(loadState, presentationState) - } -} - -/** - * The state of the refresh from the user's perspective. A refresh is "complete" for a user if - * the refresh has completed, **and** the first prepend triggered by that refresh has also - * completed. - * - * This means that new data has been loaded and (if the prepend found new data) the user can - * start scrolling up to see it. Any progress indicators can be removed, and the UI can scroll - * to disclose new content. - */ -enum class UserRefreshState { - /** No active refresh, waiting for one to start */ - WAITING, - - /** A refresh (and possibly the first prepend) is underway */ - ACTIVE, - - /** The refresh and the first prepend after a refresh has completed */ - COMPLETE, - - /** A refresh or prepend operation was [LoadState.Error] */ - ERROR, -} - -/** - * Each [CombinedLoadStates] state does not contain enough information to understand the actual - * state unless previous states have been observed. - * - * This tracks those states and provides a [UserRefreshState] that describes whether the most recent - * [Refresh][androidx.paging.PagingSource.LoadParams.Refresh] and its associated first - * [Prepend][androidx.paging.PagingSource.LoadParams.Prepend] operation has completed. - */ -fun Flow.asRefreshState(): Flow { - // Can't use CombinedLoadStates.refresh and .prepend on their own. In testing I've observed - // situations where: - // - // - the refresh completes before the prepend starts - // - the prepend starts before the refresh completes - // - the prepend *ends* before the refresh completes (but after the refresh starts) - // - // So you need to track the state of both the refresh and the prepend actions, and merge them - // in to a single state that answers the question "Has the refresh, and the first prepend - // started by that refresh, finished?" - // - // In addition, a prepend operation might involve both the mediator and the source, or only - // one of them. Since loadState.prepend tracks the mediator property this means a prepend that - // only modifies loadState.source will not be reflected in loadState.prepend. - // - // So the code also has to track whether the prepend transition was initiated by the mediator - // or the source property, and look for the end of the transition on the same property. - - /** The state of the "refresh" portion of the user refresh */ - var refresh = UserRefreshState.WAITING - - /** The state of the "prepend" portion of the user refresh */ - var prepend = UserRefreshState.WAITING - - /** True if the state of the prepend portion is derived from the mediator property */ - var usePrependMediator = false - - var previousLoadState: CombinedLoadStates? = null - - return map { loadState -> - // Debug helper, show the differences between successive load states. - if (BuildConfig.DEBUG) { - previousLoadState?.let { - val loadStateDiff = loadState.diff(previousLoadState) - Timber.d("Current state: %s %s", refresh, prepend) - if (loadStateDiff.isNotEmpty()) Timber.d(loadStateDiff) - } - previousLoadState = loadState - } - - // Bail early on errors - if (loadState.refresh is LoadState.Error || loadState.prepend is LoadState.Error) { - refresh = UserRefreshState.WAITING - prepend = UserRefreshState.WAITING - return@map UserRefreshState.ERROR - } - - // Handling loadState.refresh is straightforward - refresh = when (loadState.refresh) { - is LoadState.Loading -> if (refresh == UserRefreshState.WAITING) UserRefreshState.ACTIVE else refresh - is LoadState.NotLoading -> if (refresh == UserRefreshState.ACTIVE) UserRefreshState.COMPLETE else refresh - else -> { - throw IllegalStateException("can't happen, LoadState.Error is already handled") - } - } - - // Prepend can only transition to active if there is an active or complete refresh - // (i.e., the refresh is not WAITING). - if (refresh != UserRefreshState.WAITING && prepend == UserRefreshState.WAITING) { - if (loadState.mediator?.prepend is LoadState.Loading) { - usePrependMediator = true - prepend = UserRefreshState.ACTIVE - } - if (loadState.source.prepend is LoadState.Loading) { - usePrependMediator = false - prepend = UserRefreshState.ACTIVE - } - // There may be no page to prepend (e.g., the refresh loaded the most recent page, - // and there is no earlier page, like the TrendingStatuses timeline kind). If so, - // endOfPaginationReached will be true, and prepend can transition directly to COMPLETE. - if (loadState.source.prepend is LoadState.NotLoading && loadState.source.prepend.endOfPaginationReached) { - prepend = UserRefreshState.COMPLETE - } - } - - if (prepend == UserRefreshState.ACTIVE) { - if (usePrependMediator && loadState.mediator?.prepend is LoadState.NotLoading) { - prepend = UserRefreshState.COMPLETE - } - if (!usePrependMediator && loadState.source.prepend is LoadState.NotLoading) { - prepend = UserRefreshState.COMPLETE - } - } - - // Determine the new user refresh state by combining the refresh and prepend states - // - // - If refresh and prepend are identical use the refresh value - // - If refresh is WAITING then the state is WAITING (waiting for a refresh implies waiting - // for a prepend too) - // - Otherwise, one of them is active (doesn't matter which), so the state is ACTIVE - val newUserRefreshState = when (refresh) { - prepend -> refresh - UserRefreshState.WAITING -> UserRefreshState.WAITING - else -> UserRefreshState.ACTIVE - } - - // If the new state is COMPLETE reset the individual states back to WAITING, ready for - // the next user refresh. - if (newUserRefreshState == UserRefreshState.COMPLETE) { - refresh = UserRefreshState.WAITING - prepend = UserRefreshState.WAITING - } - - return@map newUserRefreshState - } - .distinctUntilChanged() -} - -/** - * Debug helper that generates a string showing the effective difference between two [CombinedLoadStates]. - * - * @param prev the value to compare against - * @return A (possibly multi-line) string showing the fields that differed - */ -fun CombinedLoadStates.diff(prev: CombinedLoadStates?): String { - prev ?: return "" - - val result = mutableListOf() - - if (prev.refresh != refresh) { - result.add(".refresh ${prev.refresh} -> $refresh") - } - if (prev.source.refresh != source.refresh) { - result.add(" .source.refresh ${prev.source.refresh} -> ${source.refresh}") - } - if (prev.mediator?.refresh != mediator?.refresh) { - result.add(" .mediator.refresh ${prev.mediator?.refresh} -> ${mediator?.refresh}") - } - - if (prev.prepend != prepend) { - result.add(".prepend ${prev.prepend} -> $prepend") - } - if (prev.source.prepend != source.prepend) { - result.add(" .source.prepend ${prev.source.prepend} -> ${source.prepend}") - } - if (prev.mediator?.prepend != mediator?.prepend) { - result.add(" .mediator.prepend ${prev.mediator?.prepend} -> ${mediator?.prepend}") - } - - if (prev.append != append) { - result.add(".append ${prev.append} -> $append") - } - if (prev.source.append != source.append) { - result.add(" .source.append ${prev.source.append} -> ${source.append}") - } - if (prev.mediator?.append != mediator?.append) { - result.add(" .mediator.append ${prev.mediator?.append} -> ${mediator?.append}") - } - - return result.joinToString("\n") -} diff --git a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt index f8c29ca52e..8775cd245b 100644 --- a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt @@ -113,7 +113,7 @@ class ListStatusAccessibilityDelegate( when (action) { app.pachli.core.ui.R.id.action_reply -> { interrupt() - statusActionListener.onReply(pachliAccountId, status) + statusActionListener.onReply(status) } app.pachli.core.ui.R.id.action_favourite -> statusActionListener.onFavourite(status, true) app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false) @@ -152,7 +152,7 @@ class ListStatusAccessibilityDelegate( forceFocus(host) } app.pachli.core.ui.R.id.action_collapse_cw -> { - statusActionListener.onExpandedChange(pachliAccountId, status, false) + statusActionListener.onExpandedChange(status, false) interrupt() } @@ -201,7 +201,7 @@ class ListStatusAccessibilityDelegate( app.pachli.core.ui.R.id.action_more -> { statusActionListener.onMore(host, status) } - app.pachli.core.ui.R.id.action_show_anyway -> statusActionListener.clearContentFilter(pachliAccountId, status) + app.pachli.core.ui.R.id.action_show_anyway -> statusActionListener.clearContentFilter(status) app.pachli.core.ui.R.id.action_edit_filter -> { (recyclerView.findContainingViewHolder(host) as? FilterableStatusViewHolder<*>)?.matchedFilter?.let { statusActionListener.onEditFilterById(pachliAccountId, it.id) diff --git a/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt b/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt index 0cf34fac1d..6f914830bb 100644 --- a/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt @@ -55,7 +55,7 @@ import app.pachli.core.network.model.TimelineAccount * because of the account that sent it, and why. */ data class NotificationViewData( - val pachliAccountId: Long, + override val pachliAccountId: Long, val localDomain: String, val type: NotificationEntity.Type, val id: String, @@ -93,6 +93,7 @@ data class NotificationViewData( account = data.account.toTimelineAccount(), statusViewData = data.status?.let { StatusViewData.from( + pachliAccountId = pachliAccountEntity.id, it, isExpanded = isExpanded, isShowingContent = isShowingContent, diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index 69f649a1e6..91455f8d86 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -1,5 +1,7 @@ - @@ -9,6 +11,17 @@ android:layout_gravity="center_horizontal" android:background="?android:attr/colorBackground"> + + diff --git a/app/src/main/res/layout/fragment_timeline.xml b/app/src/main/res/layout/fragment_timeline.xml index c9fa97a1cb..1a4d71c98a 100644 --- a/app/src/main/res/layout/fragment_timeline.xml +++ b/app/src/main/res/layout/fragment_timeline.xml @@ -1,8 +1,21 @@ - + + diff --git a/app/src/test/java/app/pachli/StatusComparisonTest.kt b/app/src/test/java/app/pachli/StatusComparisonTest.kt index 8f3e5a3b08..caaaa0d47e 100644 --- a/app/src/test/java/app/pachli/StatusComparisonTest.kt +++ b/app/src/test/java/app/pachli/StatusComparisonTest.kt @@ -54,6 +54,7 @@ class StatusComparisonTest { @Test fun `two equal status view data - should be equal`() { val viewdata1 = StatusViewData( + pachliAccountId = 1L, status = createStatus(), isExpanded = false, isShowingContent = false, @@ -61,6 +62,7 @@ class StatusComparisonTest { translationState = TranslationState.SHOW_ORIGINAL, ) val viewdata2 = StatusViewData( + pachliAccountId = 1L, status = createStatus(), isExpanded = false, isShowingContent = false, @@ -73,6 +75,7 @@ class StatusComparisonTest { @Test fun `status view data with different isExpanded - should not be equal`() { val viewdata1 = StatusViewData( + pachliAccountId = 1L, status = createStatus(), isExpanded = true, isShowingContent = false, @@ -80,6 +83,7 @@ class StatusComparisonTest { translationState = TranslationState.SHOW_ORIGINAL, ) val viewdata2 = StatusViewData( + pachliAccountId = 1L, status = createStatus(), isExpanded = false, isShowingContent = false, @@ -92,6 +96,7 @@ class StatusComparisonTest { @Test fun `status view data with different statuses- should not be equal`() { val viewdata1 = StatusViewData( + pachliAccountId = 1L, status = createStatus(content = "whatever"), isExpanded = true, isShowingContent = false, @@ -99,6 +104,7 @@ class StatusComparisonTest { translationState = TranslationState.SHOW_ORIGINAL, ) val viewdata2 = StatusViewData( + pachliAccountId = 1L, status = createStatus(), isExpanded = false, isShowingContent = false, diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt index 7fd14ad4f5..67a31bf6ce 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt @@ -31,7 +31,6 @@ import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.stub @@ -47,6 +46,7 @@ import org.mockito.kotlin.stub class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() { private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val statusViewData = StatusViewData( + pachliAccountId = 1L, status = status, isExpanded = true, isShowingContent = false, @@ -70,10 +70,6 @@ class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestB statusViewData, ) - /** Captors for status ID and state arguments */ - private val id = argumentCaptor() - private val state = argumentCaptor() - @Test fun `bookmark succeeds && emits UiSuccess`() = runTest { // Given diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt index 51de51ef05..336eb8307d 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -96,12 +96,10 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call returns error code`() { val remoteMediator = CachedTimelineRemoteMediator( - initialKey = null, - api = mock { + mastodonApi = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, pachliAccountId = activeAccount.id, - factory = pagingSourceFactory, transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), @@ -118,12 +116,10 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call fails`() { val remoteMediator = CachedTimelineRemoteMediator( - initialKey = null, - api = mock { + mastodonApi = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, pachliAccountId = activeAccount.id, - factory = pagingSourceFactory, transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), @@ -139,10 +135,8 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should not prepend statuses`() { val remoteMediator = CachedTimelineRemoteMediator( - initialKey = null, - api = mock(), + mastodonApi = mock(), pachliAccountId = activeAccount.id, - factory = pagingSourceFactory, transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), @@ -170,8 +164,7 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should not try to refresh already cached statuses when db is empty`() { val remoteMediator = CachedTimelineRemoteMediator( - initialKey = null, - api = mock { + mastodonApi = mock { onBlocking { homeTimeline(limit = 20) } doReturn Response.success( listOf( mockStatus("5"), @@ -181,7 +174,6 @@ class CachedTimelineRemoteMediatorTest { ) }, pachliAccountId = activeAccount.id, - factory = pagingSourceFactory, transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), @@ -224,8 +216,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - initialKey = null, - api = mock { + mastodonApi = mock { onBlocking { homeTimeline(limit = 20) } doReturn Response.success( listOf( mockStatus("3"), @@ -234,7 +225,6 @@ class CachedTimelineRemoteMediatorTest { ) }, pachliAccountId = activeAccount.id, - factory = pagingSourceFactory, transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), @@ -280,8 +270,7 @@ class CachedTimelineRemoteMediatorTest { db.remoteKeyDao().upsert(RemoteKeyEntity(1, RKE_TIMELINE_ID, RemoteKeyKind.NEXT, "5")) val remoteMediator = CachedTimelineRemoteMediator( - initialKey = null, - api = mock { + mastodonApi = mock { onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success( listOf( mockStatus("3"), @@ -294,7 +283,6 @@ class CachedTimelineRemoteMediatorTest { ) }, pachliAccountId = activeAccount.id, - factory = pagingSourceFactory, transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index 883f1bbd1d..561753febc 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -103,7 +103,7 @@ abstract class CachedTimelineViewModelTestBase { lateinit var moshi: Moshi protected lateinit var timelineCases: TimelineCases - protected lateinit var viewModel: TimelineViewModel + protected lateinit var viewModel: CachedTimelineViewModel private val eventHub = EventHub() diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusFilterAction.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusFilterAction.kt index 4de5611e01..7b5fc81467 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusFilterAction.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusFilterAction.kt @@ -25,6 +25,8 @@ import app.pachli.components.timeline.viewmodel.UiError import app.pachli.core.data.model.StatusViewData import app.pachli.core.database.model.TranslationState import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.get +import com.github.michaelbull.result.getError import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest @@ -50,6 +52,7 @@ import org.mockito.kotlin.verify class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTestBase() { private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val statusViewData = StatusViewData( + pachliAccountId = 1L, status = status, isExpanded = true, isShowingContent = false, @@ -82,14 +85,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes // Given timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(bookmarkAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction) + val item = awaitItem().get() as? StatusActionSuccess.Bookmark + assertThat(item?.action).isEqualTo(bookmarkAction) } // Then @@ -103,14 +105,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes // Given timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(bookmarkAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Bookmark::class.java) - assertThat(item.action).isEqualTo(bookmarkAction) + val item = awaitItem().getError() as? UiError.Bookmark + assertThat(item?.action).isEqualTo(bookmarkAction) } } @@ -121,14 +122,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(favouriteAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction) + val item = awaitItem().get() as? StatusActionSuccess.Favourite + assertThat(item?.action).isEqualTo(favouriteAction) } // Then @@ -142,14 +142,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes // Given timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(favouriteAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Favourite::class.java) - assertThat(item.action).isEqualTo(favouriteAction) + val item = awaitItem().getError() as? UiError.Favourite + assertThat(item?.action).isEqualTo(favouriteAction) } } @@ -158,14 +157,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes // Given timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(reblogAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction) + val item = awaitItem().get() as? StatusActionSuccess.Reblog + assertThat(item?.action).isEqualTo(reblogAction) } // Then @@ -179,14 +177,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes // Given timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(reblogAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Reblog::class.java) - assertThat(item.action).isEqualTo(reblogAction) + val item = awaitItem().getError() as? UiError.Reblog + assertThat(item?.action).isEqualTo(reblogAction) } } @@ -197,14 +194,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(voteInPollAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction) + val item = awaitItem().get() as? StatusActionSuccess.VoteInPoll + assertThat(item?.action).isEqualTo(voteInPollAction) } // Then @@ -221,14 +217,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes // Given timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(voteInPollAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) - assertThat(item.action).isEqualTo(voteInPollAction) + val item = awaitItem().getError() as? UiError.VoteInPoll + assertThat(item?.action).isEqualTo(voteInPollAction) } } } diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt deleted file mode 100644 index a3511af14e..0000000000 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2023 Pachli Association - * - * This file is a part of Pachli. - * - * 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. - * - * Pachli 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 Pachli; if not, - * see . - */ - -package app.pachli.components.timeline - -import app.cash.turbine.test -import app.pachli.components.timeline.viewmodel.InfallibleUiAction -import app.pachli.core.data.repository.Loadable -import app.pachli.core.database.model.AccountEntity -import app.pachli.core.model.Timeline -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.test.runTest -import org.junit.Test - -@HiltAndroidTest -class CachedTimelineViewModelTestVisibleId : CachedTimelineViewModelTestBase() { - - @Test - fun `should save status ID to active account`() = runTest { - assertThat(viewModel.timeline).isEqualTo(Timeline.Home) - - accountManager - .activeAccountFlow.filterIsInstance>() - .filter { it.data != null } - .map { it.data } - .test { - // Given - assertThat(expectMostRecentItem()!!.lastVisibleHomeTimelineStatusId).isNull() - - // When - viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) - - // Then - assertThat(awaitItem()!!.lastVisibleHomeTimelineStatusId).isEqualTo("1234") - } - } -} diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt index 43ac5c1b76..03a19031cf 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -93,7 +93,7 @@ abstract class NetworkTimelineViewModelTestBase { lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository protected lateinit var timelineCases: TimelineCases - protected lateinit var viewModel: TimelineViewModel + protected lateinit var viewModel: NetworkTimelineViewModel private val eventHub = EventHub() diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusFilterAction.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusFilterAction.kt index e24750d4d0..6ea9bfb367 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusFilterAction.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusFilterAction.kt @@ -22,9 +22,12 @@ import app.pachli.ContentFilterV1Test.Companion.mockStatus import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.UiError +import app.pachli.components.timeline.viewmodel.UiSuccess import app.pachli.core.data.model.StatusViewData import app.pachli.core.database.model.TranslationState import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.get +import com.github.michaelbull.result.getError import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest @@ -50,6 +53,7 @@ import org.mockito.kotlin.verify class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelTestBase() { private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val statusViewData = StatusViewData( + pachliAccountId = 1L, status = status, isExpanded = true, isShowingContent = false, @@ -78,18 +82,17 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT private val state = argumentCaptor() @Test - fun `bookmark succeeds && emits UiSuccess`() = runTest { + fun `bookmark succeeds && emits Ok uiResult`() = runTest { // Given timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(bookmarkAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction) + val item = awaitItem().get() as? StatusActionSuccess.Bookmark + assertThat(item?.action).isEqualTo(bookmarkAction) } // Then @@ -99,36 +102,34 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT } @Test - fun `bookmark fails && emits UiError`() = runTest { + fun `bookmark fails && emits Err uiResult`() = runTest { // Given timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(bookmarkAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Bookmark::class.java) - assertThat(item.action).isEqualTo(bookmarkAction) + val item = awaitItem().getError() as? UiError.Bookmark + assertThat(item?.action).isEqualTo(bookmarkAction) } } @Test - fun `favourite succeeds && emits UiSuccess`() = runTest { + fun `favourite succeeds && emits Ok uiResult`() = runTest { // Given timelineCases.stub { onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(favouriteAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction) + val item = awaitItem().get() as? StatusActionSuccess.Favourite + assertThat(item?.action).isEqualTo(favouriteAction) } // Then @@ -138,34 +139,32 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT } @Test - fun `favourite fails && emits UiError`() = runTest { + fun `favourite fails && emits Err uiResult`() = runTest { // Given timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(favouriteAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Favourite::class.java) - assertThat(item.action).isEqualTo(favouriteAction) + val item = awaitItem().getError() as? UiError.Favourite + assertThat(item?.action).isEqualTo(favouriteAction) } } @Test - fun `reblog succeeds && emits UiSuccess`() = runTest { + fun `reblog succeeds && emits Ok uiResult`() = runTest { // Given timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(reblogAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction) + val item = awaitItem().get() as? StatusActionSuccess.Reblog + assertThat(item?.action).isEqualTo(reblogAction) } // Then @@ -175,36 +174,34 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT } @Test - fun `reblog fails && emits UiError`() = runTest { + fun `reblog fails && emits Err uiResult`() = runTest { // Given timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(reblogAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Reblog::class.java) - assertThat(item.action).isEqualTo(reblogAction) + val item = awaitItem().getError() as? UiError.Reblog + assertThat(item?.action).isEqualTo(reblogAction) } } @Test - fun `voteinpoll succeeds && emits UiSuccess`() = runTest { + fun `voteinpoll succeeds && emits Ok uiResult`() = runTest { // Given timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) } - viewModel.uiSuccess.test { + viewModel.uiResult.test { // When viewModel.accept(voteInPollAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction) + val item = awaitItem().get() as? StatusActionSuccess.VoteInPoll + assertThat(item?.action).isEqualTo(voteInPollAction) } // Then @@ -217,18 +214,17 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT } @Test - fun `voteinpoll fails && emits UiError`() = runTest { + fun `voteinpoll fails && emits Err uiResult`() = runTest { // Given timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } - viewModel.uiError.test { + viewModel.uiResult.test { // When viewModel.accept(voteInPollAction) // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) - assertThat(item.action).isEqualTo(voteInPollAction) + val item = awaitItem().getError() as? UiError.VoteInPoll + assertThat(item?.action).isEqualTo(voteInPollAction) } } } diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestVisibleId.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestVisibleId.kt deleted file mode 100644 index ce7b34c1b3..0000000000 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestVisibleId.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2023 Pachli Association - * - * This file is a part of Pachli. - * - * 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. - * - * Pachli 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 Pachli; if not, - * see . - */ - -package app.pachli.components.timeline - -import app.pachli.components.timeline.viewmodel.InfallibleUiAction -import app.pachli.core.model.Timeline -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.test.runTest -import org.junit.Test - -@HiltAndroidTest -class NetworkTimelineViewModelTestVisibleId : NetworkTimelineViewModelTestBase() { - - @Test - fun `should not save status ID to active account`() = runTest { - // Given - assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) - .isNull() - assertThat(viewModel.timeline) - .isNotEqualTo(Timeline.Home) - - // When - viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) - - // Then - // As a non-Home timline this should *not* save the account, and - // the last visible property should *not* have changed. - assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) - .isNull() - } -} diff --git a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt index cdc80cef9e..e0aca2a724 100644 --- a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt +++ b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt @@ -6,15 +6,8 @@ import app.pachli.core.database.model.TimelineAccountEntity import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TranslationState -import app.pachli.core.network.json.BooleanIfNull -import app.pachli.core.network.json.DefaultIfNull -import app.pachli.core.network.json.Guarded -import app.pachli.core.network.json.InstantJsonAdapter -import app.pachli.core.network.json.LenientRfc3339DateJsonAdapter import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineAccount -import com.squareup.moshi.Moshi -import java.time.Instant import java.util.Date private val fixedDate = Date(1638889052000) @@ -81,6 +74,7 @@ fun mockStatusViewData( favourited: Boolean = true, bookmarked: Boolean = true, ) = StatusViewData( + pachliAccountId = 1L, status = mockStatus( id = id, inReplyToId = inReplyToId, @@ -103,13 +97,6 @@ fun mockStatusEntityWithAccount( expanded: Boolean = false, ): TimelineStatusWithAccount { val mockedStatus = mockStatus(id) - val moshi = Moshi.Builder() - .add(Date::class.java, LenientRfc3339DateJsonAdapter()) - .add(Instant::class.java, InstantJsonAdapter()) - .add(Guarded.Factory()) - .add(DefaultIfNull.Factory()) - .add(BooleanIfNull.Factory()) - .build() return TimelineStatusWithAccount( status = TimelineStatusEntity.from( diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt index a71028d8f8..64b920e73b 100644 --- a/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt @@ -17,6 +17,7 @@ package app.pachli.core.activity +import android.database.sqlite.SQLiteException import android.util.Log import app.pachli.core.common.di.ApplicationScope import app.pachli.core.database.dao.LogEntryDao @@ -58,15 +59,22 @@ class LogEntryTree @Inject constructor( override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { externalScope.launch { - logEntryDao.upsert( - LogEntryEntity( - instant = Instant.now(), - priority = priority, - tag = tag, - message = message, - t = t, - ), - ) + try { + logEntryDao.insert( + LogEntryEntity( + instant = Instant.now(), + priority = priority, + tag = tag, + message = message, + t = t, + ), + ) + } catch (e: SQLiteException) { + // Might trigger a "cannot start a transaction within a transaction" + // exception here if the log is being written inside another + // transaction. Nothing to do except swallow the exception and + // continue. + } } } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/StatusViewData.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/StatusViewData.kt index aeb2f50496..ec03aac3a4 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/model/StatusViewData.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/StatusViewData.kt @@ -35,6 +35,8 @@ import app.pachli.core.network.replaceCrashingCharacters * [app.pachli.components.conversation.ConversationViewData]. */ interface IStatusViewData { + /** ID of the Pachli account that loaded this status. */ + val pachliAccountId: Long val username: String val rebloggedAvatar: String? @@ -117,6 +119,7 @@ interface IStatusViewData { * Data required to display a status. */ data class StatusViewData( + override val pachliAccountId: Long, override var status: Status, override var translation: TranslatedStatusEntity? = null, override val isExpanded: Boolean, @@ -196,6 +199,7 @@ data class StatusViewData( companion object { fun from( + pachliAccountId: Long, status: Status, isShowingContent: Boolean, isExpanded: Boolean, @@ -217,6 +221,7 @@ data class StatusViewData( } return StatusViewData( + pachliAccountId = pachliAccountId, status = status, isShowingContent = isShowingContent, isCollapsed = isCollapsed, @@ -228,7 +233,8 @@ data class StatusViewData( ) } - fun from(conversationStatusEntity: ConversationStatusEntity) = StatusViewData( + fun from(pachliAccountId: Long, conversationStatusEntity: ConversationStatusEntity) = StatusViewData( + pachliAccountId = pachliAccountId, status = Status( id = conversationStatusEntity.id, url = conversationStatusEntity.url, @@ -281,6 +287,7 @@ data class StatusViewData( * the status viewdata is null. */ fun from( + pachliAccountId: Long, timelineStatusWithAccount: TimelineStatusWithAccount, isExpanded: Boolean, isShowingContent: Boolean, @@ -290,6 +297,7 @@ data class StatusViewData( ): StatusViewData { val status = timelineStatusWithAccount.toStatus() return StatusViewData( + pachliAccountId = pachliAccountId, status = status, translation = timelineStatusWithAccount.translatedStatus, isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded, diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt index 2ccb7a2264..e8eace439c 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt @@ -786,11 +786,6 @@ class AccountManager @Inject constructor( accountDao.setNotificationAccountFilterLimitedByServer(accountId, action) } - suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?) { - Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", accountId, value) - accountDao.setLastVisibleHomeTimelineStatusId(accountId, value) - } - // -- Announcements suspend fun deleteAnnouncement(accountId: Long, announcementId: String) { announcementsDao.deleteForAccount(accountId, announcementId) diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt index dc10b4bdef..f5256fec0f 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt @@ -221,18 +221,18 @@ class NotificationsRemoteMediator( // The user's last read notification was missing. Use the page of notifications // chronologically older than their desired notification. This page must *not* be // empty (as noted earlier, if it is, paging stops). - deferredNextPage.await().let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response + deferredNextPage.await().apply { + if (isSuccessful && !body().isNullOrEmpty()) { + return@coroutineScope this } } // There were no notifications older than the user's desired notification. Return the page // of notifications immediately newer than their desired notification. This page must // *not* be empty (as noted earlier, if it is, paging stops). - mastodonApi.notifications(minId = notificationId, limit = pageSize).let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response + deferredPrevPage.await().apply { + if (isSuccessful && !body().isNullOrEmpty()) { + return@coroutineScope this } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt index 514d22863a..ad16e8c8db 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt @@ -99,7 +99,6 @@ class NotificationsRepository @Inject constructor( private val remoteKeyDao: RemoteKeyDao, private val eventHub: EventHub, ) { - private var factory: InvalidatingPagingSourceFactory? = null /** @@ -144,12 +143,7 @@ class NotificationsRepository @Inject constructor( */ suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async { remoteKeyDao.upsert( - RemoteKeyEntity( - pachliAccountId, - RKE_TIMELINE_ID, - RemoteKeyKind.REFRESH, - key, - ), + RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key), ) }.await() diff --git a/core/database/schemas/app.pachli.core.database.AppDatabase/14.json b/core/database/schemas/app.pachli.core.database.AppDatabase/14.json new file mode 100644 index 0000000000..3664c254cd --- /dev/null +++ b/core/database/schemas/app.pachli.core.database.AppDatabase/14.json @@ -0,0 +1,1980 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "1853222a207568002bd5cdccf65bc5ba", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` INTEGER, `language` TEXT, `statusId` TEXT, FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DraftEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderPictureUrl` TEXT NOT NULL DEFAULT '', `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationsSeveredRelationships` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `notificationAccountFilterNotFollowed` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterYounger30d` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterLimitedByServer` TEXT NOT NULL DEFAULT 'NONE')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileHeaderPictureUrl", + "columnName": "profileHeaderPictureUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSeveredRelationships", + "columnName": "notificationsSeveredRelationships", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "notificationAccountFilterNotFollowed", + "columnName": "notificationAccountFilterNotFollowed", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterYounger30d", + "columnName": "notificationAccountFilterYounger30d", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterLimitedByServer", + "columnName": "notificationAccountFilterLimitedByServer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `maxPostCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `enabledTranslation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxPostCharacters", + "columnName": "maxPostCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTranslation", + "columnName": "enabledTranslation", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EmojisEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, `createdAt` INTEGER, `limited` INTEGER NOT NULL DEFAULT false, `note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "limited", + "columnName": "limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineAccountEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineAccountEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusViewDataEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusViewDataEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LogEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `instant` INTEGER NOT NULL, `priority` INTEGER, `tag` TEXT, `message` TEXT NOT NULL, `t` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "t", + "columnName": "t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MastodonListEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `listId` TEXT NOT NULL, `title` TEXT NOT NULL, `repliesPolicy` TEXT NOT NULL, `exclusive` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `listId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repliesPolicy", + "columnName": "repliesPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exclusive", + "columnName": "exclusive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "listId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ServerEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `serverKind` TEXT NOT NULL, `version` TEXT NOT NULL, `capabilities` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverKind", + "columnName": "serverKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ContentFiltersEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `version` TEXT NOT NULL, `contentFilters` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilters", + "columnName": "contentFilters", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AnnouncementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `announcementId` TEXT NOT NULL, `announcement` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "announcementId", + "columnName": "announcementId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FollowingAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `type` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `accountServerId` TEXT NOT NULL, `statusServerId` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`accountServerId`, `pachliAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountServerId", + "columnName": "accountServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusServerId", + "columnName": "statusServerId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountServerId", + "pachliAccountId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `reportId` TEXT NOT NULL, `actionTaken` INTEGER NOT NULL, `actionTakenAt` INTEGER, `category` TEXT NOT NULL, `comment` TEXT NOT NULL, `forwarded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `statusIds` TEXT, `ruleIds` TEXT, `target_serverId` TEXT NOT NULL, `target_timelineUserId` INTEGER NOT NULL, `target_localUsername` TEXT NOT NULL, `target_username` TEXT NOT NULL, `target_displayName` TEXT NOT NULL, `target_url` TEXT NOT NULL, `target_avatar` TEXT NOT NULL, `target_emojis` TEXT NOT NULL, `target_bot` INTEGER NOT NULL, `target_createdAt` INTEGER, `target_limited` INTEGER NOT NULL DEFAULT false, `target_note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionTaken", + "columnName": "actionTaken", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionTakenAt", + "columnName": "actionTakenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "forwarded", + "columnName": "forwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleIds", + "columnName": "ruleIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "targetAccount.serverId", + "columnName": "target_serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.timelineUserId", + "columnName": "target_timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.localUsername", + "columnName": "target_localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.username", + "columnName": "target_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.displayName", + "columnName": "target_displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.url", + "columnName": "target_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.avatar", + "columnName": "target_avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.emojis", + "columnName": "target_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.bot", + "columnName": "target_bot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.createdAt", + "columnName": "target_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetAccount.limited", + "columnName": "target_limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "targetAccount.note", + "columnName": "target_note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + }, + { + "tableName": "NotificationViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `contentFilterAction` TEXT, `accountFilterDecision` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilterAction", + "columnName": "contentFilterAction", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountFilterDecision", + "columnName": "accountFilterDecision", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationRelationshipSeveranceEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `eventId` TEXT NOT NULL, `type` TEXT NOT NULL, `purged` INTEGER NOT NULL, `followersCount` INTEGER NOT NULL, `followingCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`, `eventId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purged", + "columnName": "purged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followersCount", + "columnName": "followersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followingCount", + "columnName": "followingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId", + "eventId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1853222a207568002bd5cdccf65bc5ba')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index 6fc6cd30c7..1a0d58d513 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -92,7 +92,7 @@ import java.util.TimeZone NotificationViewDataEntity::class, NotificationRelationshipSeveranceEventEntity::class, ], - version = 13, + version = 14, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 2, to = 3), @@ -105,6 +105,7 @@ import java.util.TimeZone AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12, spec = AppDatabase.MIGRATE_11_12::class), AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14, spec = AppDatabase.MIGRATE_13_14::class), ], ) abstract class AppDatabase : RoomDatabase() { @@ -180,6 +181,10 @@ abstract class AppDatabase : RoomDatabase() { // lastNotificationId removed in favour of the REFRESH key in RemoteKeyEntity. @DeleteColumn("AccountEntity", "lastNotificationId") class MIGRATE_11_12 : AutoMigrationSpec + + // lastVisibleHomeTimelineStatusId removed in favour of the REFRESH key in RemoteKeyEntity. + @DeleteColumn("AccountEntity", "lastVisibleHomeTimelineStatusId") + class MIGRATE_13_14 : AutoMigrationSpec } val MIGRATE_8_9 = object : Migration(8, 9) { diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt index 3e3290e896..5c147f8d9f 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt @@ -394,15 +394,6 @@ interface AccountDao { ) fun setNotificationLight(accountId: Long, value: Boolean) - @Query( - """ - UPDATE AccountEntity - SET lastVisibleHomeTimelineStatusId = :value - WHERE id = :accountId - """, - ) - suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?) - @Query( """ UPDATE AccountEntity diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt index 6b71f6881d..0d0fff85ad 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt @@ -18,9 +18,9 @@ package app.pachli.core.database.dao import androidx.room.Dao +import androidx.room.Insert import androidx.room.Query import androidx.room.TypeConverters -import androidx.room.Upsert import app.pachli.core.database.Converters import app.pachli.core.database.model.LogEntryEntity import java.time.Instant @@ -30,9 +30,8 @@ import java.time.Instant */ @Dao interface LogEntryDao { - /** Upsert [logEntry] */ - @Upsert - suspend fun upsert(logEntry: LogEntryEntity): Long + @Insert + suspend fun insert(logEntry: LogEntryEntity): Long /** Load all [LogEntryEntity], ordered oldest first */ @Query( diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt index 5f9d409cff..016b97bf44 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt @@ -152,18 +152,22 @@ ORDER BY LENGTH(n.serverId) DESC, n.serverId DESC ) fun pagingSource(pachliAccountId: Long): PagingSource - /** @return The database row number of the row for [notificationId]. */ + /** + * @return Row number (0-based) of the notification with ID [notificationId] + * for [pachliAccountId] + */ @Query( """ -SELECT RowNum -FROM - (SELECT pachliAccountId, serverId, - (SELECT count(*) + 1 - FROM notificationentity - WHERE rowid < t.rowid - ORDER BY length(serverId) DESC, serverId DESC) AS RowNum - FROM notificationentity t) -WHERE pachliAccountId = :pachliAccountId AND serverId = :notificationId; + SELECT rownum + FROM ( + SELECT t1.pachliAccountId AS pachliAccountId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum + FROM NotificationEntity t1 + JOIN NotificationEntity t2 ON t1.pachliAccountId = t2.pachliAccountId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId) + WHERE t1.pachliAccountId = :pachliAccountId + GROUP BY t1.serverId + ORDER BY length(t1.serverId) DESC, t1.serverId DESC + ) + WHERE serverId = :notificationId """, ) suspend fun getNotificationRowNumber(pachliAccountId: Long, notificationId: String): Int diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 776a6f0691..575b92ff2b 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -83,19 +83,25 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""", abstract fun getStatuses(account: Long): PagingSource /** - * All statuses for [account] in timeline ID. Used to find the correct initialKey to restore - * the user's reading position. + * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId]. * * @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses] */ @Query( """ -SELECT serverId - FROM TimelineStatusEntity - WHERE timelineUserId = :account - ORDER BY LENGTH(serverId) DESC, serverId DESC""", + SELECT rownum + FROM ( + SELECT t1.timelineUserId AS timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum + FROM TimelineStatusEntity t1 + JOIN TimelineStatusEntity t2 ON t1.timelineUserId = t2.timelineUserId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId) + WHERE t1.timelineUserId = :pachliAccountId + GROUP BY t1.serverId + ORDER BY length(t1.serverId) DESC, t1.serverId DESC + ) + WHERE serverId = :statusId + """, ) - abstract fun getStatusRowNumber(account: Long): List + abstract suspend fun getStatusRowNumber(pachliAccountId: Long, statusId: String): Int @Query( """ @@ -163,23 +169,6 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe ) abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String) - /** - * Removes everything for one account in the following tables: - * - * - TimelineStatusEntity - * - TimelineAccountEntity - * - StatusViewDataEntity - * - TranslatedStatusEntity - * - * @param accountId id of the account for which to clean tables - */ - suspend fun removeAll(accountId: Long) { - removeAllStatuses(accountId) - removeAllAccounts(accountId) - removeAllStatusViewData(accountId) - removeAllTranslatedStatus(accountId) - } - /** * Removes all statuses from the cached **home** timeline. * @@ -197,7 +186,7 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe ) """, ) - abstract suspend fun removeAllStatuses(accountId: Long) + abstract suspend fun deleteAllStatusesForAccount(accountId: Long) /** * Deletes [TimelineAccountEntity] that are not referenced by a diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt index c4b21b5038..694a5938cd 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt @@ -99,12 +99,6 @@ data class AccountEntity( val pushAuth: String = "", val pushServerKey: String = "", - /** - * ID of the status at the top of the visible list in the home timeline when the - * user navigated away. - */ - val lastVisibleHomeTimelineStatusId: String? = null, - /** True if the connected Mastodon account is locked (has to manually approve all follow requests **/ @ColumnInfo(defaultValue = "0") val locked: Boolean = false, diff --git a/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/PagingDataAdapterExtensions.kt b/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/PagingDataAdapterExtensions.kt new file mode 100644 index 0000000000..4068ee20bb --- /dev/null +++ b/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/PagingDataAdapterExtensions.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +import androidx.paging.LoadState +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.take + +/** + * Performs [action] after the next prepend operation completes on the adapter. + * + * A prepend operation is complete when the adapter's prepend [LoadState] transitions + * from [LoadState.Loading] to [LoadState.NotLoading]. + */ +suspend fun PagingDataAdapter.postPrepend( + action: () -> Unit, +) { + val initial: Pair = Pair(null, null) + loadStateFlow + .runningFold(initial) { prev, next -> prev.second to next.prepend } + .filter { it.first is LoadState.Loading && it.second is LoadState.NotLoading } + .take(1) + .collect { action() } +}