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
This commit is contained in:
Nik Clayton 2025-01-23 13:23:17 +01:00 committed by GitHub
parent 05c68f6df9
commit 7bf322c4f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 2853 additions and 1412 deletions

View File

@ -35,28 +35,26 @@ open class FilterableStatusViewHolder<T : IStatusViewData>(
var matchedFilter: Filter? = null var matchedFilter: Filter? = null
override fun setupWithStatus( override fun setupWithStatus(
pachliAccountId: Long,
viewData: T, viewData: T,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
payloads: Any?, payloads: Any?,
) { ) {
super.setupWithStatus(pachliAccountId, viewData, listener, statusDisplayOptions, payloads) super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads)
setupFilterPlaceholder(pachliAccountId, viewData, listener) setupFilterPlaceholder(viewData, listener)
} }
private fun setupFilterPlaceholder( private fun setupFilterPlaceholder(
pachliAccountId: Long, viewData: T,
status: T,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
) { ) {
if (status.contentFilterAction !== FilterAction.WARN) { if (viewData.contentFilterAction !== FilterAction.WARN) {
matchedFilter = null matchedFilter = null
setPlaceholderVisibility(false) setPlaceholderVisibility(false)
return 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 this.matchedFilter = result.filter
setPlaceholderVisibility(true) setPlaceholderVisibility(true)
@ -71,10 +69,10 @@ open class FilterableStatusViewHolder<T : IStatusViewData>(
binding.statusFilteredPlaceholder.statusFilterLabel.text = label binding.statusFilteredPlaceholder.statusFilterLabel.text = label
binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener { binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener {
listener.clearContentFilter(pachliAccountId, status) listener.clearContentFilter(viewData)
} }
binding.statusFilteredPlaceholder.statusFilterEditFilter.setOnClickListener { binding.statusFilteredPlaceholder.statusFilterEditFilter.setOnClickListener {
listener.onEditFilterById(pachliAccountId, result.filter.id) listener.onEditFilterById(viewData.pachliAccountId, result.filter.id)
} }
} ?: { } ?: {
matchedFilter = null matchedFilter = null

View File

@ -145,7 +145,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
protected fun setSpoilerAndContent( protected fun setSpoilerAndContent(
pachliAccountId: Long,
viewData: T, viewData: T,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
@ -165,7 +164,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
setContentWarningButtonText(expanded) setContentWarningButtonText(expanded)
contentWarningButton.setOnClickListener { contentWarningButton.setOnClickListener {
toggleExpandedState( toggleExpandedState(
pachliAccountId,
viewData, viewData,
true, true,
!expanded, !expanded,
@ -197,7 +195,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
protected open fun toggleExpandedState( protected open fun toggleExpandedState(
pachliAccountId: Long,
viewData: T, viewData: T,
sensitive: Boolean, sensitive: Boolean,
expanded: Boolean, expanded: Boolean,
@ -205,11 +202,10 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
) { ) {
contentWarningDescription.invalidate() contentWarningDescription.invalidate()
listener.onExpandedChange(pachliAccountId, viewData, expanded) listener.onExpandedChange(viewData, expanded)
setContentWarningButtonText(expanded) setContentWarningButtonText(expanded)
setTextVisible(sensitive, expanded, viewData, statusDisplayOptions, listener) setTextVisible(sensitive, expanded, viewData, statusDisplayOptions, listener)
setupCard( setupCard(
pachliAccountId,
viewData, viewData,
expanded, expanded,
statusDisplayOptions.cardViewMode, statusDisplayOptions.cardViewMode,
@ -466,7 +462,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
protected fun setMediaPreviews( protected fun setMediaPreviews(
pachliAccountId: Long,
viewData: T, viewData: T,
attachments: List<Attachment>, attachments: List<Attachment>,
sensitive: Boolean, sensitive: Boolean,
@ -498,7 +493,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} else { } else {
imageView.foreground = null imageView.foreground = null
} }
setAttachmentClickListener(pachliAccountId, viewData, imageView, listener, i, attachment, true) setAttachmentClickListener(viewData, imageView, listener, i, attachment, true)
if (sensitive) { if (sensitive) {
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title) sensitiveMediaWarning.setText(R.string.post_sensitive_media_title)
} else { } else {
@ -509,13 +504,13 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
descriptionIndicator.visibility = descriptionIndicator.visibility =
if (hasDescription && showingContent) View.VISIBLE else View.GONE if (hasDescription && showingContent) View.VISIBLE else View.GONE
sensitiveMediaShow.setOnClickListener { v: View -> sensitiveMediaShow.setOnClickListener { v: View ->
listener.onContentHiddenChange(pachliAccountId, viewData, false) listener.onContentHiddenChange(viewData, false)
v.visibility = View.GONE v.visibility = View.GONE
sensitiveMediaWarning.visibility = View.VISIBLE sensitiveMediaWarning.visibility = View.VISIBLE
descriptionIndicator.visibility = View.GONE descriptionIndicator.visibility = View.GONE
} }
sensitiveMediaWarning.setOnClickListener { v: View -> sensitiveMediaWarning.setOnClickListener { v: View ->
listener.onContentHiddenChange(pachliAccountId, viewData, true) listener.onContentHiddenChange(viewData, true)
v.visibility = View.GONE v.visibility = View.GONE
sensitiveMediaShow.visibility = View.VISIBLE sensitiveMediaShow.visibility = View.VISIBLE
descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
@ -530,7 +525,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
protected fun setMediaLabel( protected fun setMediaLabel(
pachliAccountId: Long,
viewData: T, viewData: T,
attachments: List<Attachment>, attachments: List<Attachment>,
sensitive: Boolean, sensitive: Boolean,
@ -548,7 +542,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
// Set the icon next to the label. // Set the icon next to the label.
val drawableId = attachments[0].iconResource() val drawableId = attachments[0].iconResource()
mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0) mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0)
setAttachmentClickListener(pachliAccountId, viewData, mediaLabel, listener, i, attachment, false) setAttachmentClickListener(viewData, mediaLabel, listener, i, attachment, false)
} else { } else {
mediaLabel.visibility = View.GONE mediaLabel.visibility = View.GONE
} }
@ -556,7 +550,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
private fun setAttachmentClickListener( private fun setAttachmentClickListener(
pachliAccountId: Long,
viewData: T, viewData: T,
view: View, view: View,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
@ -566,7 +559,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
) { ) {
view.setOnClickListener { v: View? -> view.setOnClickListener { v: View? ->
if (sensitiveMediaWarning.visibility == View.VISIBLE) { if (sensitiveMediaWarning.visibility == View.VISIBLE) {
listener.onContentHiddenChange(pachliAccountId, viewData, true) listener.onContentHiddenChange(viewData, true)
} else { } else {
listener.onViewMedia(viewData, index, if (animateTransition) v else null) listener.onViewMedia(viewData, index, if (animateTransition) v else null)
} }
@ -584,7 +577,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
protected fun setupButtons( protected fun setupButtons(
pachliAccountId: Long,
viewData: T, viewData: T,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
accountId: String, accountId: String,
@ -594,7 +586,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
avatar.setOnClickListener(profileButtonClickListener) avatar.setOnClickListener(profileButtonClickListener)
displayName.setOnClickListener(profileButtonClickListener) displayName.setOnClickListener(profileButtonClickListener)
replyButton.setOnClickListener { replyButton.setOnClickListener {
listener.onReply(pachliAccountId, viewData) listener.onReply(viewData)
} }
reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean -> reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean ->
// return true to play animation // return true to play animation
@ -683,7 +675,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
open fun setupWithStatus( open fun setupWithStatus(
pachliAccountId: Long,
viewData: T, viewData: T,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
@ -715,7 +706,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
val sensitive = actionable.sensitive val sensitive = actionable.sensitive
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
setMediaPreviews( setMediaPreviews(
pachliAccountId,
viewData, viewData,
attachments, attachments,
sensitive, sensitive,
@ -731,13 +721,12 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
mediaLabel.visibility = View.GONE mediaLabel.visibility = View.GONE
} }
} else { } else {
setMediaLabel(pachliAccountId, viewData, attachments, sensitive, listener, viewData.isShowingContent) setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent)
// Hide all unused views. // Hide all unused views.
mediaPreview.visibility = View.GONE mediaPreview.visibility = View.GONE
hideSensitiveMediaWarning() hideSensitiveMediaWarning()
} }
setupCard( setupCard(
pachliAccountId,
viewData, viewData,
viewData.isExpanded, viewData.isExpanded,
statusDisplayOptions.cardViewMode, statusDisplayOptions.cardViewMode,
@ -745,14 +734,13 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
listener, listener,
) )
setupButtons( setupButtons(
pachliAccountId,
viewData, viewData,
listener, listener,
actionable.account.id, actionable.account.id,
statusDisplayOptions, statusDisplayOptions,
) )
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility) setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility)
setSpoilerAndContent(pachliAccountId, viewData, statusDisplayOptions, listener) setSpoilerAndContent(viewData, statusDisplayOptions, listener)
setContentDescriptionForStatus(viewData, statusDisplayOptions) setContentDescriptionForStatus(viewData, statusDisplayOptions)
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
@ -876,7 +864,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
} }
protected fun setupCard( protected fun setupCard(
pachliAccountId: Long,
viewData: T, viewData: T,
expanded: Boolean, expanded: Boolean,
cardViewMode: CardViewMode, cardViewMode: CardViewMode,
@ -898,13 +885,13 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions, false) { card, target -> cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions, false) { card, target ->
if (target == PreviewCardView.Target.BYLINE) { if (target == PreviewCardView.Target.BYLINE) {
card.authors?.firstOrNull()?.account?.id?.let { card.authors?.firstOrNull()?.account?.id?.let {
context.startActivity(AccountActivityIntent(context, pachliAccountId, it)) context.startActivity(AccountActivityIntent(context, viewData.pachliAccountId, it))
} }
return@bind return@bind
} }
if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) { 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 return@bind
} }

View File

@ -106,26 +106,24 @@ class StatusDetailedViewHolder(
} }
override fun setupWithStatus( override fun setupWithStatus(
pachliAccountId: Long,
viewData: StatusViewData, viewData: StatusViewData,
listener: StatusActionListener<StatusViewData>, listener: StatusActionListener<StatusViewData>,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
payloads: Any?, payloads: Any?,
) { ) {
// We never collapse statuses in the detail view // We never collapse statuses in the detail view
val uncollapsedStatus = val uncollapsedViewdata =
if (viewData.isCollapsible && viewData.isCollapsed) viewData.copy(isCollapsed = false) else viewData 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( setupCard(
pachliAccountId, uncollapsedViewdata,
uncollapsedStatus,
viewData.isExpanded, viewData.isExpanded,
CardViewMode.FULL_WIDTH, CardViewMode.FULL_WIDTH,
statusDisplayOptions, statusDisplayOptions,
listener, listener,
) // Always show card for detailed status ) // Always show card for detailed status
if (payloads == null) { if (payloads == null) {
val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedStatus.actionable val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedViewdata.actionable
if (!statusDisplayOptions.hideStats) { if (!statusDisplayOptions.hideStats) {
setReblogAndFavCount(viewData, reblogsCount, favouritesCount, listener) setReblogAndFavCount(viewData, reblogsCount, favouritesCount, listener)
} else { } else {

View File

@ -41,7 +41,6 @@ open class StatusViewHolder<T : IStatusViewData>(
) : StatusBaseViewHolder<T>(root ?: binding.root) { ) : StatusBaseViewHolder<T>(root ?: binding.root) {
override fun setupWithStatus( override fun setupWithStatus(
pachliAccountId: Long,
viewData: T, viewData: T,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
@ -50,7 +49,7 @@ open class StatusViewHolder<T : IStatusViewData>(
if (payloads == null) { if (payloads == null) {
val sensitive = !TextUtils.isEmpty(viewData.actionable.spoilerText) val sensitive = !TextUtils.isEmpty(viewData.actionable.spoilerText)
val expanded = viewData.isExpanded val expanded = viewData.isExpanded
setupCollapsedState(pachliAccountId, viewData, sensitive, expanded, listener) setupCollapsedState(viewData, sensitive, expanded, listener)
val reblogging = viewData.rebloggingStatus val reblogging = viewData.rebloggingStatus
if (reblogging == null || viewData.contentFilterAction === FilterAction.WARN) { if (reblogging == null || viewData.contentFilterAction === FilterAction.WARN) {
statusInfo.hide() statusInfo.hide()
@ -70,7 +69,7 @@ open class StatusViewHolder<T : IStatusViewData>(
statusFavouritesCount.visible(statusDisplayOptions.showStatsInline) statusFavouritesCount.visible(statusDisplayOptions.showStatsInline)
setFavouritedCount(viewData.actionable.favouritesCount) setFavouritedCount(viewData.actionable.favouritesCount)
setReblogsCount(viewData.actionable.reblogsCount) setReblogsCount(viewData.actionable.reblogsCount)
super.setupWithStatus(pachliAccountId, viewData, listener, statusDisplayOptions, payloads) super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads)
} }
private fun setRebloggedByDisplayName( private fun setRebloggedByDisplayName(
@ -108,7 +107,6 @@ open class StatusViewHolder<T : IStatusViewData>(
} }
private fun setupCollapsedState( private fun setupCollapsedState(
pachliAccountId: Long,
viewData: T, viewData: T,
sensitive: Boolean, sensitive: Boolean,
expanded: Boolean, expanded: Boolean,
@ -117,7 +115,7 @@ open class StatusViewHolder<T : IStatusViewData>(
/* input filter for TextViews have to be set before text */ /* input filter for TextViews have to be set before text */
if (viewData.isCollapsible && (!sensitive || expanded)) { if (viewData.isCollapsible && (!sensitive || expanded)) {
buttonToggleContent.setOnClickListener { buttonToggleContent.setOnClickListener {
listener.onContentCollapsedChange(pachliAccountId, viewData, !viewData.isCollapsed) listener.onContentCollapsedChange(viewData, !viewData.isCollapsed)
} }
buttonToggleContent.show() buttonToggleContent.show()
if (viewData.isCollapsed) { if (viewData.isCollapsed) {
@ -139,15 +137,14 @@ open class StatusViewHolder<T : IStatusViewData>(
} }
override fun toggleExpandedState( override fun toggleExpandedState(
pachliAccountId: Long,
viewData: T, viewData: T,
sensitive: Boolean, sensitive: Boolean,
expanded: Boolean, expanded: Boolean,
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener<T>, listener: StatusActionListener<T>,
) { ) {
setupCollapsedState(pachliAccountId, viewData, sensitive, expanded, listener) setupCollapsedState(viewData, sensitive, expanded, listener)
super.toggleExpandedState(pachliAccountId, viewData, sensitive, expanded, statusDisplayOptions, listener) super.toggleExpandedState(viewData, sensitive, expanded, statusDisplayOptions, listener)
} }
companion object { companion object {

View File

@ -26,7 +26,6 @@ import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
class ConversationAdapter( class ConversationAdapter(
private val pachliAccountId: Long,
private var statusDisplayOptions: StatusDisplayOptions, private var statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener<ConversationViewData>, private val listener: StatusActionListener<ConversationViewData>,
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) { ) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
@ -54,7 +53,7 @@ class ConversationAdapter(
payloads: List<Any>, payloads: List<Any>,
) { ) {
getItem(position)?.let { conversationViewData -> getItem(position)?.let { conversationViewData ->
holder.setupWithConversation(pachliAccountId, conversationViewData, payloads.firstOrNull()) holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
} }
} }

View File

@ -35,12 +35,12 @@ data class ConversationViewData(
val lastStatus: StatusViewData, val lastStatus: StatusViewData,
) : IStatusViewData by lastStatus { ) : IStatusViewData by lastStatus {
companion object { companion object {
fun from(conversationEntity: ConversationEntity) = ConversationViewData( fun from(pachliAccountId: Long, conversationEntity: ConversationEntity) = ConversationViewData(
id = conversationEntity.id, id = conversationEntity.id,
order = conversationEntity.order, order = conversationEntity.order,
accounts = conversationEntity.accounts, accounts = conversationEntity.accounts,
unread = conversationEntity.unread, unread = conversationEntity.unread,
lastStatus = StatusViewData.from(conversationEntity.lastStatus), lastStatus = StatusViewData.from(pachliAccountId, conversationEntity.lastStatus),
) )
} }
} }

View File

@ -46,13 +46,12 @@ class ConversationViewHolder internal constructor(
) )
fun setupWithConversation( fun setupWithConversation(
pachliAccountId: Long,
viewData: ConversationViewData, viewData: ConversationViewData,
payloads: Any?, payloads: Any?,
) { ) {
val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = viewData.status val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = viewData.status
if (payloads == null) { if (payloads == null) {
setupCollapsedState(pachliAccountId, viewData, listener) setupCollapsedState(viewData, listener)
setDisplayName(account.name, account.emojis, statusDisplayOptions) setDisplayName(account.name, account.emojis, statusDisplayOptions)
setUsername(account.username) setUsername(account.username)
setMetaData(viewData, statusDisplayOptions, listener) setMetaData(viewData, statusDisplayOptions, listener)
@ -61,7 +60,6 @@ class ConversationViewHolder internal constructor(
setBookmarked(bookmarked) setBookmarked(bookmarked)
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
setMediaPreviews( setMediaPreviews(
pachliAccountId,
viewData, viewData,
attachments, attachments,
sensitive, sensitive,
@ -77,19 +75,18 @@ class ConversationViewHolder internal constructor(
mediaLabel.visibility = View.GONE mediaLabel.visibility = View.GONE
} }
} else { } else {
setMediaLabel(pachliAccountId, viewData, attachments, sensitive, listener, viewData.isShowingContent) setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent)
// Hide all unused views. // Hide all unused views.
mediaPreview.visibility = View.GONE mediaPreview.visibility = View.GONE
hideSensitiveMediaWarning() hideSensitiveMediaWarning()
} }
setupButtons( setupButtons(
pachliAccountId,
viewData, viewData,
listener, listener,
account.id, account.id,
statusDisplayOptions, statusDisplayOptions,
) )
setSpoilerAndContent(pachliAccountId, viewData, statusDisplayOptions, listener) setSpoilerAndContent(viewData, statusDisplayOptions, listener)
setConversationName(viewData.accounts) setConversationName(viewData.accounts)
setAvatars(viewData.accounts) setAvatars(viewData.accounts)
} else { } else {
@ -139,14 +136,13 @@ class ConversationViewHolder internal constructor(
} }
private fun setupCollapsedState( private fun setupCollapsedState(
pachliAccountId: Long,
viewData: ConversationViewData, viewData: ConversationViewData,
listener: StatusActionListener<ConversationViewData>, listener: StatusActionListener<ConversationViewData>,
) { ) {
/* input filter for TextViews have to be set before text */ /* input filter for TextViews have to be set before text */
if (viewData.isCollapsible && (viewData.isExpanded || TextUtils.isEmpty(viewData.spoilerText))) { if (viewData.isCollapsible && (viewData.isExpanded || TextUtils.isEmpty(viewData.spoilerText))) {
contentCollapseButton.setOnClickListener { contentCollapseButton.setOnClickListener {
listener.onContentCollapsedChange(pachliAccountId, viewData, !viewData.isCollapsed) listener.onContentCollapsedChange(viewData, !viewData.isCollapsed)
} }
contentCollapseButton.show() contentCollapseButton.show()
if (viewData.isCollapsed) { if (viewData.isCollapsed) {

View File

@ -114,7 +114,7 @@ class ConversationsFragment :
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val statusDisplayOptions = statusDisplayOptionsRepository.flow.value val statusDisplayOptions = statusDisplayOptionsRepository.flow.value
adapter = ConversationAdapter(pachliAccountId, statusDisplayOptions, this@ConversationsFragment) adapter = ConversationAdapter(statusDisplayOptions, this@ConversationsFragment)
setupRecyclerView() setupRecyclerView()
@ -322,16 +322,16 @@ class ConversationsFragment :
// there are no reblogs in conversations // there are no reblogs in conversations
} }
override fun onExpandedChange(pachliAccountId: Long, viewData: ConversationViewData, expanded: Boolean) { override fun onExpandedChange(viewData: ConversationViewData, expanded: Boolean) {
viewModel.expandHiddenStatus(pachliAccountId, expanded, viewData.lastStatus.id) viewModel.expandHiddenStatus(viewData.pachliAccountId, expanded, viewData.lastStatus.id)
} }
override fun onContentHiddenChange(pachliAccountId: Long, viewData: ConversationViewData, isShowingContent: Boolean) { override fun onContentHiddenChange(viewData: ConversationViewData, isShowingContent: Boolean) {
viewModel.showContent(pachliAccountId, isShowingContent, viewData.lastStatus.id) viewModel.showContent(viewData.pachliAccountId, isShowingContent, viewData.lastStatus.id)
} }
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: ConversationViewData, isCollapsed: Boolean) { override fun onContentCollapsedChange(viewData: ConversationViewData, isCollapsed: Boolean) {
viewModel.collapseLongStatus(pachliAccountId, isCollapsed, viewData.lastStatus.id) viewModel.collapseLongStatus(viewData.pachliAccountId, isCollapsed, viewData.lastStatus.id)
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
@ -348,15 +348,15 @@ class ConversationsFragment :
// not needed // not needed
} }
override fun onReply(pachliAccountId: Long, viewData: ConversationViewData) { override fun onReply(viewData: ConversationViewData) {
reply(pachliAccountId, viewData.lastStatus.actionable) reply(viewData.pachliAccountId, viewData.lastStatus.actionable)
} }
override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List<Int>) { override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List<Int>) {
viewModel.voteInPoll(choices, viewData.lastStatus.actionableId, poll.id) 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 // Filters don't apply in conversations

View File

@ -24,20 +24,24 @@ import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map import androidx.paging.map
import app.pachli.core.data.repository.AccountManager 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.Converters
import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.dao.ConversationsDao
import app.pachli.core.database.di.TransactionProvider 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.network.retrofit.MastodonApi
import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.EmptyPagingSource
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -55,7 +59,11 @@ class ConversationsViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager( val conversationFlow = accountManager.activeAccountFlow
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
.mapNotNull { it.data }
.flatMapLatest { account ->
Pager(
config = PagingConfig(pageSize = 30), config = PagingConfig(pageSize = 30),
remoteMediator = ConversationsRemoteMediator( remoteMediator = ConversationsRemoteMediator(
api, api,
@ -64,17 +72,14 @@ class ConversationsViewModel @Inject constructor(
accountManager, accountManager,
), ),
pagingSourceFactory = { pagingSourceFactory = {
val activeAccount = accountManager.activeAccount conversationsDao.conversationsForAccount(account.id)
if (activeAccount == null) {
EmptyPagingSource()
} else {
conversationsDao.conversationsForAccount(activeAccount.id)
}
}, },
) ).flow
.flow
.map { pagingData -> .map { pagingData ->
pagingData.map { conversation -> ConversationViewData.from(conversation) } pagingData.map { conversation ->
ConversationViewData.from(account.id, conversation)
}
}
} }
.cachedIn(viewModelScope) .cachedIn(viewModelScope)

View File

@ -37,7 +37,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -92,9 +91,9 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import postPrepend
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@ -117,7 +116,12 @@ class NotificationsFragment :
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) 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 private lateinit var layoutManager: LinearLayoutManager
@ -191,14 +195,6 @@ class NotificationsFragment :
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
false false
adapter = NotificationsPagingAdapter(
notificationDiffCallback,
statusActionListener = this@NotificationsFragment,
notificationActionListener = this@NotificationsFragment,
accountActionListener = this@NotificationsFragment,
statusDisplayOptions = viewModel.statusDisplayOptions.value,
)
binding.recyclerView.setAccessibilityDelegateCompat( binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(pachliAccountId, binding.recyclerView, this@NotificationsFragment) { pos: Int -> ListStatusAccessibilityDelegate(pachliAccountId, binding.recyclerView, this@NotificationsFragment) { pos: Int ->
if (pos in 0 until adapter.itemCount) { if (pos in 0 until adapter.itemCount) {
@ -211,11 +207,7 @@ class NotificationsFragment :
val saveIdListener = object : RecyclerView.OnScrollListener() { val saveIdListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState != SCROLL_STATE_IDLE) return if (newState == SCROLL_STATE_IDLE) saveVisibleId()
// Save the ID of the first notification visible in the list, so the user's
// reading position is always restorable.
saveVisibleId()
} }
} }
binding.recyclerView.addOnScrollListener(saveIdListener) binding.recyclerView.addOnScrollListener(saveIdListener)
@ -239,8 +231,7 @@ class NotificationsFragment :
// Update status display from statusDisplayOptions. If the new options request // 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. // relative time display collect the flow to periodically update the timestamp in the list gui elements.
launch { launch {
viewModel.statusDisplayOptions viewModel.statusDisplayOptions.collectLatest {
.collectLatest {
// NOTE this this also triggered (emitted?) on resume. // NOTE this this also triggered (emitted?) on resume.
adapter.statusDisplayOptions = it adapter.statusDisplayOptions = it
@ -253,8 +244,7 @@ class NotificationsFragment :
} }
// Update the UI from the loadState // Update the UI from the loadState
adapter.loadStateFlow adapter.loadStateFlow.collect { loadState ->
.collect { loadState ->
when (loadState.refresh) { when (loadState.refresh) {
is LoadState.Error -> { is LoadState.Error -> {
binding.progressIndicator.hide() binding.progressIndicator.hide()
@ -294,7 +284,7 @@ class NotificationsFragment :
} }
} }
private suspend fun bindUiResult(uiResult: Result<UiSuccess, UiError>) { private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
// Show errors from the view model as snack bars. // Show errors from the view model as snack bars.
// //
// Errors are shown: // Errors are shown:
@ -345,9 +335,7 @@ class NotificationsFragment :
} }
when (uiSuccess) { when (uiSuccess) {
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> viewLifecycleOwner.lifecycleScope.launch { is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> refreshAdapterAndScrollToVisibleId()
refreshAdapterAndScrollToVisibleId()
}
is UiSuccess.LoadNewest -> { is UiSuccess.LoadNewest -> {
// Scroll to the top when prepending completes. // 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 * 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. * This ensures the user's position is not lost during adapter refreshes.
*/ */
@ -385,23 +373,6 @@ class NotificationsFragment :
adapter.refresh() 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 <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.postPrepend(
action: () -> Unit,
) {
val initial: Pair<LoadState?, LoadState?> = 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) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_notifications, menu) menuInflater.inflate(R.menu.fragment_notifications, menu)
menu.findItem(R.id.action_refresh)?.apply { menu.findItem(R.id.action_refresh)?.apply {
@ -450,9 +421,7 @@ class NotificationsFragment :
} }
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
viewLifecycleOwner.lifecycleScope.launch {
refreshAdapterAndScrollToVisibleId() refreshAdapterAndScrollToVisibleId()
}
clearNotificationsForAccount(requireContext(), pachliAccountId) clearNotificationsForAccount(requireContext(), pachliAccountId)
} }
@ -483,20 +452,18 @@ class NotificationsFragment :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (::adapter.isInitialized) {
val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
val wasEnabled = talkBackWasEnabled val wasEnabled = talkBackWasEnabled
talkBackWasEnabled = a11yManager?.isEnabled == true talkBackWasEnabled = a11yManager?.isEnabled == true
if (talkBackWasEnabled && !wasEnabled) { if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyItemRangeChanged(0, adapter.itemCount) adapter.notifyItemRangeChanged(0, adapter.itemCount)
} }
}
clearNotificationsForAccount(requireContext(), pachliAccountId) clearNotificationsForAccount(requireContext(), pachliAccountId)
} }
override fun onReply(pachliAccountId: Long, viewData: NotificationViewData) { override fun onReply(viewData: NotificationViewData) {
super.reply(pachliAccountId, viewData.statusViewData!!.actionable) super.reply(viewData.pachliAccountId, viewData.statusViewData!!.actionable)
} }
override fun onReblog(viewData: NotificationViewData, reblog: Boolean) { 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( override fun onContentHiddenChange(
pachliAccountId: Long,
viewData: NotificationViewData, viewData: NotificationViewData,
isShowingContent: Boolean, isShowingContent: Boolean,
) { ) {
viewModel.accept( viewModel.accept(
InfallibleUiAction.SetShowingContent( InfallibleUiAction.SetShowingContent(
pachliAccountId, viewData.pachliAccountId,
viewData.statusViewData!!, viewData.statusViewData!!,
isShowingContent, isShowingContent,
), ),
) )
} }
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: NotificationViewData, isCollapsed: Boolean) { override fun onContentCollapsedChange(viewData: NotificationViewData, isCollapsed: Boolean) {
viewModel.accept( viewModel.accept(
InfallibleUiAction.SetContentCollapsed( InfallibleUiAction.SetContentCollapsed(
pachliAccountId, viewData.pachliAccountId,
viewData.statusViewData!!, viewData.statusViewData!!,
isCollapsed, isCollapsed,
), ),
@ -585,17 +544,17 @@ class NotificationsFragment :
} }
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, viewData: NotificationViewData) { override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, viewData: NotificationViewData) {
onContentCollapsedChange(viewData.pachliAccountId, viewData, isCollapsed) onContentCollapsedChange(viewData, isCollapsed)
} }
override fun clearContentFilter(pachliAccountId: Long, viewData: NotificationViewData) { override fun clearContentFilter(viewData: NotificationViewData) {
viewModel.accept(InfallibleUiAction.ClearContentFilter(pachliAccountId, viewData.id)) viewModel.accept(InfallibleUiAction.ClearContentFilter(viewData.pachliAccountId, viewData.id))
} }
override fun clearAccountFilter(viewData: NotificationViewData) { override fun clearAccountFilter(viewData: NotificationViewData) {
viewModel.accept( viewModel.accept(
InfallibleUiAction.OverrideAccountFilter( InfallibleUiAction.OverrideAccountFilter(
pachliAccountId, viewData.pachliAccountId,
viewData.id, viewData.id,
viewData.accountFilterDecision, viewData.accountFilterDecision,
), ),
@ -629,16 +588,12 @@ class NotificationsFragment :
} }
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { 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) { override fun onBlock(block: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
refreshAdapterAndScrollToVisibleId() refreshAdapterAndScrollToVisibleId()
} }
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
if (accept) { if (accept) {

View File

@ -148,7 +148,7 @@ class NotificationsPagingAdapter(
private val statusActionListener: StatusActionListener<NotificationViewData>, private val statusActionListener: StatusActionListener<NotificationViewData>,
private val notificationActionListener: NotificationActionListener, private val notificationActionListener: NotificationActionListener,
private val accountActionListener: AccountActionListener, private val accountActionListener: AccountActionListener,
var statusDisplayOptions: StatusDisplayOptions, var statusDisplayOptions: StatusDisplayOptions = StatusDisplayOptions(),
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) { ) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
private val absoluteTimeFormatter = AbsoluteTimeFormatter() private val absoluteTimeFormatter = AbsoluteTimeFormatter()

View File

@ -367,7 +367,7 @@ class NotificationsViewModel @AssistedInject constructor(
private val sharedPreferencesRepository: SharedPreferencesRepository, private val sharedPreferencesRepository: SharedPreferencesRepository,
@Assisted val pachliAccountId: Long, @Assisted val pachliAccountId: Long,
) : ViewModel() { ) : ViewModel() {
val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId) private val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId)
.filterNotNull() .filterNotNull()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)

View File

@ -46,7 +46,6 @@ internal class StatusViewHolder(
showStatusContent(true) showStatusContent(true)
} }
setupWithStatus( setupWithStatus(
viewData.pachliAccountId,
viewData, viewData,
statusActionListener, statusActionListener,
statusDisplayOptions, statusDisplayOptions,
@ -81,7 +80,6 @@ class FilterableStatusViewHolder(
showStatusContent(true) showStatusContent(true)
} }
setupWithStatus( setupWithStatus(
viewData.pachliAccountId,
viewData, viewData,
statusActionListener, statusActionListener,
statusDisplayOptions, statusDisplayOptions,

View File

@ -27,7 +27,10 @@ import androidx.paging.map
import app.pachli.components.report.adapter.StatusesPagingSource import app.pachli.components.report.adapter.StatusesPagingSource
import app.pachli.components.report.model.StatusViewState import app.pachli.components.report.model.StatusViewState
import app.pachli.core.data.model.StatusViewData 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.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.database.model.AccountEntity
import app.pachli.core.eventhub.BlockEvent import app.pachli.core.eventhub.BlockEvent
import app.pachli.core.eventhub.EventHub import app.pachli.core.eventhub.EventHub
import app.pachli.core.eventhub.MuteEvent import app.pachli.core.eventhub.MuteEvent
@ -45,12 +48,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class ReportViewModel @Inject constructor( class ReportViewModel @Inject constructor(
private val accountManager: AccountManager,
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
private val eventHub: EventHub, private val eventHub: EventHub,
@ -78,19 +85,24 @@ class ReportViewModel @Inject constructor(
val statusDisplayOptions = statusDisplayOptionsRepository.flow val statusDisplayOptions = statusDisplayOptionsRepository.flow
val statusesFlow = accountIdFlow.flatMapLatest { accountId -> val activeAccountFlow = accountManager.accountsFlow
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
.mapNotNull { it.data }
val statusesFlow = activeAccountFlow.combine(accountIdFlow) { activeAccount, reportedAccountId ->
Pair(activeAccount, reportedAccountId)
}.flatMapLatest { (activeAccount, reportedAccountId) ->
Pager( Pager(
initialKey = statusId, initialKey = statusId,
config = PagingConfig(pageSize = 20, initialLoadSize = 20), config = PagingConfig(pageSize = 20, initialLoadSize = 20),
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }, pagingSourceFactory = { StatusesPagingSource(reportedAccountId, mastodonApi) },
).flow ).flow
}
.map { pagingData -> .map { pagingData ->
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData
instead of StatusViewState */ instead of StatusViewState */
pagingData.map { status -> StatusViewData.from(status, false, false, false) } pagingData.map { status -> StatusViewData.from(activeAccount.id, status, false, false, false) }
} }
.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
private val selectedIds = HashSet<String>() private val selectedIds = HashSet<String>()
val statusViewState = StatusViewState() val statusViewState = StatusViewState()

View File

@ -83,7 +83,7 @@ class SearchViewModel @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val serverRepository: ServerRepository, serverRepository: ServerRepository,
) : ViewModel() { ) : ViewModel() {
var currentQuery: String = "" var currentQuery: String = ""
@ -204,6 +204,7 @@ class SearchViewModel @Inject constructor(
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status -> it.statuses.map { status ->
StatusViewData.from( StatusViewData.from(
pachliAccountId = activeAccount!!.id,
status, status,
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler, isExpanded = alwaysOpenSpoiler,

View File

@ -27,7 +27,6 @@ import app.pachli.databinding.ItemStatusBinding
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
class SearchStatusesAdapter( class SearchStatusesAdapter(
private val pachliAccountId: Long,
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener<StatusViewData>, private val statusListener: StatusActionListener<StatusViewData>,
) : PagingDataAdapter<StatusViewData, StatusViewHolder<StatusViewData>>(STATUS_COMPARATOR) { ) : PagingDataAdapter<StatusViewData, StatusViewHolder<StatusViewData>>(STATUS_COMPARATOR) {
@ -40,7 +39,7 @@ class SearchStatusesAdapter(
override fun onBindViewHolder(holder: StatusViewHolder<StatusViewData>, position: Int) { override fun onBindViewHolder(holder: StatusViewHolder<StatusViewData>, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
holder.setupWithStatus(pachliAccountId, item, statusListener, statusDisplayOptions) holder.setupWithStatus(item, statusListener, statusDisplayOptions)
} }
} }

View File

@ -85,15 +85,15 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
MaterialDividerItemDecoration(requireContext(), MaterialDividerItemDecoration.VERTICAL), MaterialDividerItemDecoration(requireContext(), MaterialDividerItemDecoration.VERTICAL),
) )
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) 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) viewModel.contentHiddenChange(viewData, isShowingContent)
} }
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { override fun onReply(viewData: StatusViewData) {
reply(pachliAccountId, viewData) reply(viewData)
} }
override fun onFavourite(viewData: StatusViewData, favourite: Boolean) { override fun onFavourite(viewData: StatusViewData, favourite: Boolean) {
@ -148,11 +148,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
bottomSheetActivity?.viewAccount(pachliAccountId, status.account.id) 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) viewModel.expandedChange(viewData, expanded)
} }
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) { override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.collapsedChange(viewData, isCollapsed) viewModel.collapsedChange(viewData, isCollapsed)
} }
@ -160,7 +160,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
viewModel.voteInPoll(viewData, poll, choices) viewModel.voteInPoll(viewData, poll, choices)
} }
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {} override fun clearContentFilter(viewData: StatusViewData) {}
override fun onReblog(viewData: StatusViewData, reblog: Boolean) { override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
viewModel.reblog(viewData, reblog) viewModel.reblog(viewData, reblog)
@ -173,7 +173,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
) )
} }
private fun reply(pachliAccountId: Long, status: StatusViewData) { private fun reply(status: StatusViewData) {
val actionableStatus = status.actionable val actionableStatus = status.actionable
val mentionedUsernames = actionableStatus.mentions.map { it.username } val mentionedUsernames = actionableStatus.mentions.map { it.username }
.toMutableSet() .toMutableSet()
@ -184,7 +184,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
val intent = ComposeActivityIntent( val intent = ComposeActivityIntent(
requireContext(), requireContext(),
pachliAccountId, status.pachliAccountId,
ComposeOptions( ComposeOptions(
inReplyToId = status.actionableId, inReplyToId = status.actionableId,
replyVisibility = actionableStatus.visibility, replyVisibility = actionableStatus.visibility,

View File

@ -22,7 +22,9 @@ import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData 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
import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator.Companion.RKE_TIMELINE_ID
import app.pachli.core.common.di.ApplicationScope import app.pachli.core.common.di.ApplicationScope
import app.pachli.core.data.model.StatusViewData import app.pachli.core.data.model.StatusViewData
import app.pachli.core.database.dao.RemoteKeyDao 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.dao.TranslatedStatusDao
import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.di.TransactionProvider
import app.pachli.core.database.model.AccountEntity 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.StatusViewDataEntity
import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TimelineStatusWithAccount
import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.database.model.TranslatedStatusEntity
@ -42,6 +46,7 @@ import at.connyduck.calladapter.networkresult.fold
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -62,47 +67,33 @@ class CachedTimelineRepository @Inject constructor(
private val remoteKeyDao: RemoteKeyDao, private val remoteKeyDao: RemoteKeyDao,
private val translatedStatusDao: TranslatedStatusDao, private val translatedStatusDao: TranslatedStatusDao,
@ApplicationScope private val externalScope: CoroutineScope, @ApplicationScope private val externalScope: CoroutineScope,
) { ) : TimelineRepository<TimelineStatusWithAccount> {
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
/** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ /** @return flow of Mastodon [TimelineStatusWithAccount. */
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
fun getStatusStream( override suspend fun getStatusStream(
account: AccountEntity, account: AccountEntity,
kind: Timeline, kind: Timeline,
pageSize: Int = PAGE_SIZE,
initialKey: String? = null,
): Flow<PagingData<TimelineStatusWithAccount>> { ): Flow<PagingData<TimelineStatusWithAccount>> {
Timber.d("getStatusStream(): key: %s", initialKey)
Timber.d("getStatusStream, account is %s", account.fullName) Timber.d("getStatusStream, account is %s", account.fullName)
factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) } factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) }
val row = initialKey?.let { key -> val initialKey = remoteKeyDao.remoteKeyForKind(account.id, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH)
// Room is row-keyed (by Int), not item-keyed, so the status ID string that was val row = initialKey?.key?.let { timelineDao.getStatusRowNumber(account.id, it) }
// 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 }
}
Timber.d("initialKey: %s is row: %d", initialKey, row) Timber.d("initialKey: %s is row: %d", initialKey, row)
return Pager( return Pager(
config = PagingConfig(
pageSize = pageSize,
jumpThreshold = PAGE_SIZE * 3,
enablePlaceholders = true,
),
initialKey = row, initialKey = row,
config = PagingConfig(
pageSize = PAGE_SIZE,
enablePlaceholders = false,
),
remoteMediator = CachedTimelineRemoteMediator( remoteMediator = CachedTimelineRemoteMediator(
initialKey,
mastodonApi, mastodonApi,
account.id, account.id,
factory!!,
transactionProvider, transactionProvider,
timelineDao, timelineDao,
remoteKeyDao, remoteKeyDao,
@ -112,7 +103,7 @@ class CachedTimelineRepository @Inject constructor(
} }
/** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */ /** 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 // Invalidating when no statuses have been loaded can cause empty timelines because it
// cancels the network load. // cancels the network load.
if (timelineDao.getStatusCount(pachliAccountId) < 1) { if (timelineDao.getStatusCount(pachliAccountId) < 1) {
@ -122,11 +113,11 @@ class CachedTimelineRepository @Inject constructor(
factory?.invalidate() factory?.invalidate()
} }
suspend fun saveStatusViewData(pachliAccountId: Long, statusViewData: StatusViewData) = externalScope.launch { suspend fun saveStatusViewData(statusViewData: StatusViewData) = externalScope.launch {
timelineDao.upsertStatusViewData( timelineDao.upsertStatusViewData(
StatusViewDataEntity( StatusViewDataEntity(
serverId = statusViewData.actionableId, serverId = statusViewData.actionableId,
timelineUserId = pachliAccountId, timelineUserId = statusViewData.pachliAccountId,
expanded = statusViewData.isExpanded, expanded = statusViewData.isExpanded,
contentShowing = statusViewData.isShowingContent, contentShowing = statusViewData.isShowingContent,
contentCollapsed = statusViewData.isCollapsed, contentCollapsed = statusViewData.isCollapsed,
@ -164,28 +155,15 @@ class CachedTimelineRepository @Inject constructor(
timelineDao.clearWarning(pachliAccountId, statusId) timelineDao.clearWarning(pachliAccountId, statusId)
}.join() }.join()
/** Remove all statuses and invalidate the pager, for the active account */ suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
suspend fun clearAndReload(pachliAccountId: Long) = externalScope.launch { saveStatusViewData(statusViewData.copy(translationState = TranslationState.TRANSLATING))
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<Translation> {
saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.TRANSLATING))
val translation = mastodonApi.translate(statusViewData.actionableId) val translation = mastodonApi.translate(statusViewData.actionableId)
translation.fold( translation.fold(
{ {
translatedStatusDao.upsert( translatedStatusDao.upsert(
TranslatedStatusEntity( TranslatedStatusEntity(
serverId = statusViewData.actionableId, serverId = statusViewData.actionableId,
timelineUserId = pachliAccountId, timelineUserId = statusViewData.pachliAccountId,
// TODO: Should this embed the network type instead of copying data // TODO: Should this embed the network type instead of copying data
// from one type to another? // from one type to another?
content = it.content, content = it.content,
@ -195,21 +173,31 @@ class CachedTimelineRepository @Inject constructor(
provider = it.provider, provider = it.provider,
), ),
) )
saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION)) saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION))
}, },
{ {
// Reset the translation state // Reset the translation state
saveStatusViewData(pachliAccountId, statusViewData) saveStatusViewData(statusViewData)
}, },
) )
return translation return translation
} }
suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) { suspend fun translateUndo(statusViewData: StatusViewData) {
saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL)) 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()
} }

View File

@ -22,7 +22,7 @@ import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData 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.NetworkTimelinePagingSource
import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator
import app.pachli.components.timeline.viewmodel.PageCache 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. */ /** Timeline repository where the timeline information is backed by an in-memory cache. */
class NetworkTimelineRepository @Inject constructor( class NetworkTimelineRepository @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
) { ) : TimelineRepository<Status> {
private val pageCache = PageCache() private val pageCache = PageCache()
private var factory: InvalidatingPagingSourceFactory<String, Status>? = null private var factory: InvalidatingPagingSourceFactory<String, Status>? = null
// TODO: This should use assisted injection, and inject the account. /** @return flow of Mastodon [Status]. */
private var activeAccount: AccountEntity? = null
/** @return flow of Mastodon [Status], loaded in [pageSize] increments */
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
fun getStatusStream( override suspend fun getStatusStream(
account: AccountEntity, account: AccountEntity,
kind: Timeline, kind: Timeline,
pageSize: Int = PAGE_SIZE,
initialKey: String? = null,
): Flow<PagingData<Status>> { ): Flow<PagingData<Status>> {
Timber.d("getStatusStream(): key: %s", initialKey) Timber.d("getStatusStream()")
factory = InvalidatingPagingSourceFactory { factory = InvalidatingPagingSourceFactory {
NetworkTimelinePagingSource(pageCache) NetworkTimelinePagingSource(pageCache)
} }
return Pager( return Pager(
config = PagingConfig(pageSize = pageSize), config = PagingConfig(pageSize = PAGE_SIZE),
remoteMediator = NetworkTimelineRemoteMediator( remoteMediator = NetworkTimelineRemoteMediator(
mastodonApi, mastodonApi,
account, account,
@ -105,10 +100,9 @@ class NetworkTimelineRepository @Inject constructor(
).flow ).flow
} }
/** Invalidate the active paging source, see [PagingSource.invalidate] */ override suspend fun invalidate(pachliAccountId: Long) = factory?.invalidate() ?: Unit
fun invalidate() {
factory?.invalidate() fun invalidate() = factory?.invalidate()
}
fun removeAllByAccountId(accountId: String) { fun removeAllByAccountId(accountId: String) {
synchronized(pageCache) { synchronized(pageCache) {
@ -171,8 +165,4 @@ class NetworkTimelineRepository @Inject constructor(
} }
invalidate() invalidate()
} }
companion object {
private const val PAGE_SIZE = 30
}
} }

View File

@ -44,13 +44,13 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import app.pachli.BuildConfig import app.pachli.BuildConfig
import app.pachli.R import app.pachli.R
import app.pachli.adapter.StatusBaseViewHolder 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.CachedTimelineViewModel
import app.pachli.components.timeline.viewmodel.InfallibleUiAction import app.pachli.components.timeline.viewmodel.InfallibleUiAction
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusAction
import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.StatusActionSuccess
import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel
import app.pachli.components.timeline.viewmodel.UiError
import app.pachli.components.timeline.viewmodel.UiSuccess import app.pachli.components.timeline.viewmodel.UiSuccess
import app.pachli.core.activity.RefreshableFragment import app.pachli.core.activity.RefreshableFragment
import app.pachli.core.activity.ReselectableFragment import app.pachli.core.activity.ReselectableFragment
@ -78,11 +78,10 @@ import app.pachli.interfaces.ActionButtonActivity
import app.pachli.interfaces.AppBarLayoutHost import app.pachli.interfaces.AppBarLayoutHost
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
import app.pachli.util.ListStatusAccessibilityDelegate 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 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.color.MaterialColors
import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.divider.MaterialDividerItemDecoration
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -96,12 +95,13 @@ import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import postPrepend
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@ -120,7 +120,7 @@ class TimelineFragment :
// //
// If the navigation library was being used this would happen automatically, so this // If the navigation library was being used this would happen automatically, so this
// workaround can be removed when that change happens. // workaround can be removed when that change happens.
private val viewModel: TimelineViewModel by lazy { private val viewModel: TimelineViewModel<out Any> by lazy {
if (timeline == Timeline.Home) { if (timeline == Timeline.Home) {
viewModels<CachedTimelineViewModel>( viewModels<CachedTimelineViewModel>(
extrasProducer = { extrasProducer = {
@ -160,6 +160,19 @@ class TimelineFragment :
override var pachliAccountId by Delegates.notNull<Long>() override var pachliAccountId by Delegates.notNull<Long>()
/**
* 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -171,7 +184,7 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) 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( override fun onCreateView(
@ -179,16 +192,6 @@ class TimelineFragment :
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
): View? { ): 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) 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.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Show errors from the view model as snack bars. launch { viewModel.statuses.collectLatest { adapter.submitData(it) } }
//
// 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 launch { viewModel.uiResult.collect(::bindUiResult) }
// 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() // Collect the uiState.
.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<StatusActionSuccess>()
.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
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 */ }
}
}
}
// 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.
launch { launch {
viewModel.uiState.collect { uiState -> viewModel.uiState.collect { uiState ->
if (layoutManager.reverseLayout != uiState.reverseTimeline) { if (layoutManager.reverseLayout != uiState.reverseTimeline) {
@ -348,18 +241,9 @@ class TimelineFragment :
// Update status display from statusDisplayOptions. If the new options request // Update status display from statusDisplayOptions. If the new options request
// relative time display collect the flow to periodically re-bind the UI. // relative time display collect the flow to periodically re-bind the UI.
launch { launch {
viewModel.statusDisplayOptions viewModel.statusDisplayOptions.collectLatest {
.collectLatest {
adapter.statusDisplayOptions = it adapter.statusDisplayOptions = it
layoutManager.findFirstVisibleItemPosition().let { first -> adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
first == RecyclerView.NO_POSITION && return@let
val count = layoutManager.findLastVisibleItemPosition() - first
adapter.notifyItemRangeChanged(
first,
count,
null,
)
}
if (!it.useAbsoluteTime) { if (!it.useAbsoluteTime) {
updateTimestampFlow.collect() updateTimestampFlow.collect()
@ -367,143 +251,178 @@ class TimelineFragment :
} }
} }
/** StateFlow (to allow multiple consumers) of UserRefreshState */ adapter.loadStateFlow.collect { loadState ->
val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) when (loadState.refresh) {
is LoadState.Error -> {
// Scroll the list down (peek) if a refresh has completely finished. A refresh is binding.progressIndicator.hide()
// finished when both the initial refresh is complete and any prepends have binding.statusView.setup((loadState.refresh as LoadState.Error).error) {
// 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
/** ID of the item that was first in the adapter before the refresh */
var previousFirstId: String? = null
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
}
if (userRefreshState == UserRefreshState.COMPLETE) {
// Refresh has finished, pages are being prepended.
// There might be multiple prepends after a refresh, only continue
// if one them has not already caused a peek.
if (peeked) return@collect
// 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
// 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
}
}
}
// 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
}
else -> { /* nothing to do */ }
}
}
}
// 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() adapter.retry()
} }
} else {
null
}
binding.statusView.setup(error, callback)
binding.statusView.show()
binding.recyclerView.hide() binding.recyclerView.hide()
} binding.statusView.show()
binding.swipeRefreshLayout.isRefreshing = false
} }
PresentationState.PRESENTED -> { 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.
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) { if (adapter.itemCount == 0) {
binding.statusView.setup(BackgroundMessage.Empty()) binding.statusView.setup(BackgroundMessage.Empty())
if (timeline == Timeline.Home) { if (timeline == Timeline.Home) {
binding.statusView.showHelp(R.string.help_empty_home) binding.statusView.showHelp(R.string.help_empty_home)
} }
binding.statusView.show()
binding.recyclerView.hide() binding.recyclerView.hide()
binding.statusView.show()
} else { } else {
binding.recyclerView.show()
binding.statusView.hide() binding.statusView.hide()
binding.recyclerView.show()
}
}
}
}
}
}
} }
} }
else -> { private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
// Nothing to do -- show/hiding the progress bars in non-error states // Show errors from the view model as snack bars.
// is handled via refreshState. //
// 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()
// 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
adapter.snapshot()
.indexOfFirst { it?.id == action.statusViewData.id }
.takeIf { it != RecyclerView.NO_POSITION }
?.let { adapter.notifyItemChanged(it) }
}
}
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
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)
} }
} }
} }
adapter.refresh()
}
else -> { /* nothing to do */ }
} }
} }
} }
/**
* 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) { 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 * 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. * 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 * In this case the best status is the last partially visible status, as we can assume the
* user has read this far. * user has read this far.
*/ */
fun saveVisibleId(statusId: String? = null) { fun saveVisibleId() {
val id = statusId ?: ( val id = getFirstVisibleStatus()?.id
layoutManager.findFirstCompletelyVisibleItemPosition()
.takeIf { it != RecyclerView.NO_POSITION }
?: layoutManager.findLastVisibleItemPosition()
.takeIf { it != RecyclerView.NO_POSITION }
)
?.let { adapter.snapshot().getOrNull(it)?.id }
if (BuildConfig.DEBUG && id == null) { if (BuildConfig.DEBUG && id == null) {
Toast.makeText(requireActivity(), "Could not find ID of item to save", LENGTH_LONG).show() Toast.makeText(requireActivity(), "Could not find ID of item to save", LENGTH_LONG).show()
} }
id?.let { id?.let {
Timber.d("saveVisibleId: Saving ID: %s", it) viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, id))
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = it)) }
} ?: Timber.d("saveVisibleId: Not saving, as no ID was visible")
} }
private fun setupSwipeRefreshLayout() { private fun setupSwipeRefreshLayout() {
@ -615,7 +534,6 @@ class TimelineFragment :
/** Refresh the displayed content, as if the user had swiped on the SwipeRefreshLayout */ /** Refresh the displayed content, as if the user had swiped on the SwipeRefreshLayout */
override fun refreshContent() { override fun refreshContent() {
Timber.d("Reloading via refreshContent") Timber.d("Reloading via refreshContent")
binding.swipeRefreshLayout.isRefreshing = true
onRefresh() onRefresh()
} }
@ -625,13 +543,26 @@ class TimelineFragment :
*/ */
override fun onRefresh() { override fun onRefresh() {
Timber.d("Reloading via onRefresh") Timber.d("Reloading via onRefresh")
binding.statusView.hide()
snackbar?.dismiss() // Peek the list when refreshing completes.
adapter.refresh() viewLifecycleOwner.lifecycleScope.launch {
adapter.postPrepend {
binding.recyclerView.post {
view ?: return@post
binding.recyclerView.smoothScrollBy(
0,
Utils.dpToPx(requireContext(), -30),
)
}
}
} }
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { binding.swipeRefreshLayout.isRefreshing = false
super.reply(pachliAccountId, viewData.actionable) refreshAdapterAndScrollToVisibleId()
}
override fun onReply(viewData: StatusViewData) {
super.reply(viewData.pachliAccountId, viewData.actionable)
} }
override fun onReblog(viewData: StatusViewData, reblog: Boolean) { override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
@ -650,8 +581,8 @@ class TimelineFragment :
viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData)) viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData))
} }
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) { override fun clearContentFilter(viewData: StatusViewData) {
viewModel.clearWarning(pachliAccountId, viewData) viewModel.clearWarning(viewData)
} }
override fun onEditFilterById(pachliAccountId: Long, filterId: String) { override fun onEditFilterById(pachliAccountId: Long, filterId: String) {
@ -669,12 +600,12 @@ class TimelineFragment :
super.openReblog(status) super.openReblog(status)
} }
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) { override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
viewModel.changeExpanded(pachliAccountId, expanded, viewData) viewModel.changeExpanded(expanded, viewData)
} }
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) { override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
viewModel.changeContentShowing(pachliAccountId, isShowingContent, viewData) viewModel.changeContentShowing(isShowingContent, viewData)
} }
override fun onShowReblogs(statusId: String) { override fun onShowReblogs(statusId: String) {
@ -687,8 +618,8 @@ class TimelineFragment :
activity?.startActivityWithDefaultTransition(intent) activity?.startActivityWithDefaultTransition(intent)
} }
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) { override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.changeContentCollapsed(pachliAccountId, isCollapsed, viewData) viewModel.changeContentCollapsed(isCollapsed, viewData)
} }
// Can only translate the home timeline at the moment // Can only translate the home timeline at the moment

View File

@ -33,7 +33,6 @@ import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
class TimelinePagingAdapter( class TimelinePagingAdapter(
private val pachliAccountId: Long,
private val statusListener: StatusActionListener<StatusViewData>, private val statusListener: StatusActionListener<StatusViewData>,
var statusDisplayOptions: StatusDisplayOptions, var statusDisplayOptions: StatusDisplayOptions,
) : PagingDataAdapter<StatusViewData, RecyclerView.ViewHolder>(TimelineDifferCallback) { ) : PagingDataAdapter<StatusViewData, RecyclerView.ViewHolder>(TimelineDifferCallback) {
@ -73,7 +72,6 @@ class TimelinePagingAdapter(
null null
}?.let { }?.let {
(viewHolder as StatusViewHolder<StatusViewData>).setupWithStatus( (viewHolder as StatusViewHolder<StatusViewData>).setupWithStatus(
pachliAccountId,
it, it,
statusListener, statusListener,
statusDisplayOptions, statusDisplayOptions,

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<T : Any> {
/** @return Flow of [T] for [account] and [kind]. */
suspend fun getStatusStream(account: AccountEntity, kind: Timeline): Flow<PagingData<T>>
/** 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
}
}

View File

@ -18,7 +18,6 @@
package app.pachli.components.timeline.viewmodel package app.pachli.components.timeline.viewmodel
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.LoadType import androidx.paging.LoadType
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
@ -42,10 +41,8 @@ import timber.log.Timber
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
class CachedTimelineRemoteMediator( class CachedTimelineRemoteMediator(
private val initialKey: String?, private val mastodonApi: MastodonApi,
private val api: MastodonApi,
private val pachliAccountId: Long, private val pachliAccountId: Long,
private val factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>,
private val transactionProvider: TransactionProvider, private val transactionProvider: TransactionProvider,
private val timelineDao: TimelineDao, private val timelineDao: TimelineDao,
private val remoteKeyDao: RemoteKeyDao, private val remoteKeyDao: RemoteKeyDao,
@ -59,22 +56,15 @@ class CachedTimelineRemoteMediator(
return try { return try {
val response = when (loadType) { val response = when (loadType) {
LoadType.REFRESH -> { LoadType.REFRESH -> {
val closestItem = state.anchorPosition?.let { // Ignore the provided state, always try and fetch from the remote
state.closestItemToPosition(maxOf(0, it - (state.config.pageSize / 2))) // REFRESH key.
}?.status?.serverId val statusId = remoteKeyDao.remoteKeyForKind(
val statusId = closestItem ?: initialKey
Timber.d("Loading from item: %s", statusId)
getInitialPage(statusId, state.config.pageSize)
}
LoadType.APPEND -> {
val rke = remoteKeyDao.remoteKeyForKind(
pachliAccountId, pachliAccountId,
RKE_TIMELINE_ID, RKE_TIMELINE_ID,
RemoteKeyKind.NEXT, RemoteKeyKind.REFRESH,
) ?: return MediatorResult.Success(endOfPaginationReached = true) )?.key
Timber.d("Loading from remoteKey: %s", rke) Timber.d("Loading from item: %s", statusId)
api.homeTimeline(maxId = rke.key, limit = state.config.pageSize) getInitialPage(statusId, state.config.pageSize)
} }
LoadType.PREPEND -> { LoadType.PREPEND -> {
@ -84,7 +74,17 @@ class CachedTimelineRemoteMediator(
RemoteKeyKind.PREV, RemoteKeyKind.PREV,
) ?: return MediatorResult.Success(endOfPaginationReached = true) ) ?: return MediatorResult.Success(endOfPaginationReached = true)
Timber.d("Loading from remoteKey: %s", rke) Timber.d("Loading from remoteKey: %s", rke)
api.homeTimeline(minId = rke.key, limit = state.config.pageSize) mastodonApi.homeTimeline(minId = rke.key, limit = state.config.pageSize)
}
LoadType.APPEND -> {
val rke = remoteKeyDao.remoteKeyForKind(
pachliAccountId,
RKE_TIMELINE_ID,
RemoteKeyKind.NEXT,
) ?: return MediatorResult.Success(endOfPaginationReached = true)
Timber.d("Loading from remoteKey: %s", rke)
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 // This request succeeded with no new data, and pagination ends (unless this is a
// REFRESH, which must always set endOfPaginationReached to false). // REFRESH, which must always set endOfPaginationReached to false).
if (statuses.isEmpty()) { if (statuses.isEmpty()) {
factory.invalidate()
return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH) return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH)
} }
@ -109,8 +108,8 @@ class CachedTimelineRemoteMediator(
transactionProvider { transactionProvider {
when (loadType) { when (loadType) {
LoadType.REFRESH -> { LoadType.REFRESH -> {
remoteKeyDao.delete(pachliAccountId, RKE_TIMELINE_ID) remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID)
timelineDao.removeAllStatuses(pachliAccountId) timelineDao.deleteAllStatusesForAccount(pachliAccountId)
remoteKeyDao.upsert( remoteKeyDao.upsert(
RemoteKeyEntity( RemoteKeyEntity(
@ -120,6 +119,7 @@ class CachedTimelineRemoteMediator(
links.next, links.next,
), ),
) )
remoteKeyDao.upsert( remoteKeyDao.upsert(
RemoteKeyEntity( RemoteKeyEntity(
pachliAccountId, pachliAccountId,
@ -179,7 +179,7 @@ class CachedTimelineRemoteMediator(
*/ */
private suspend fun getInitialPage(statusId: String?, pageSize: Int): Response<List<Status>> = coroutineScope { private suspend fun getInitialPage(statusId: String?, pageSize: Int): Response<List<Status>> = coroutineScope {
// If the key is null this is straightforward, just return the most recent statuses. // 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 // 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. // (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. // you can not fetch the page itself.
// Fetch the requested status, and the page immediately after (next) // 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 { val deferredNextPage = async {
api.homeTimeline(maxId = statusId, limit = pageSize) mastodonApi.homeTimeline(maxId = statusId, limit = pageSize * 3)
} }
deferredStatus.await().getOrNull()?.let { status -> deferredStatus.await().getOrNull()?.let { status ->
val statuses = buildList { val statuses = buildList {
deferredPrevPage.await().body()?.let { this.addAll(it) }
this.add(status) this.add(status)
deferredNextPage.await().body()?.let { this.addAll(it) } 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 // 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, // than their desired status. This page must *not* be empty (as noted earlier, if it is,
// paging stops). // paging stops).
deferredNextPage.await().let { response -> deferredNextPage.await().apply {
if (response.isSuccessful) { if (isSuccessful && !body().isNullOrEmpty()) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response return@coroutineScope this
} }
} }
// There were no statuses older than the user's desired status. Return the page // 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 // of statuses immediately newer than their desired status. This page must
// *not* be empty (as noted earlier, if it is, paging stops). // *not* be empty (as noted earlier, if it is, paging stops).
api.homeTimeline(minId = statusId, limit = pageSize).let { response -> deferredPrevPage.await().apply {
if (response.isSuccessful) { if (isSuccessful && !body().isNullOrEmpty()) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response return@coroutineScope this
} }
} }
// Everything failed -- fallback to fetching the most recent statuses // 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. * Inserts `statuses` and the accounts referenced by those statuses in to the cache.
*/ */
private suspend fun insertStatuses(pachliAccountId: Long, statuses: List<Status>) { private suspend fun insertStatuses(pachliAccountId: Long, statuses: List<Status>) {
for (status in statuses) { check(transactionProvider.inTransaction())
timelineDao.insertAccount(TimelineAccountEntity.from(status.account, pachliAccountId))
status.reblog?.account?.let { /** Unique accounts referenced in this batch of statuses. */
val account = TimelineAccountEntity.from(it, pachliAccountId) val accounts = buildSet {
timelineDao.insertAccount(account) statuses.forEach { status ->
add(status.account)
status.reblog?.account?.let { add(it) }
}
} }
timelineDao.insertStatus( timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) })
TimelineStatusEntity.from( timelineDao.upsertStatuses(statuses.map { TimelineStatusEntity.from(it, pachliAccountId) })
status,
timelineUserId = pachliAccountId,
),
)
}
} }
companion object { companion object {

View File

@ -28,6 +28,7 @@ import app.pachli.core.data.model.StatusViewData
import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.database.model.AccountEntity 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.BookmarkEvent
import app.pachli.core.eventhub.EventHub import app.pachli.core.eventhub.EventHub
import app.pachli.core.eventhub.FavoriteEvent import app.pachli.core.eventhub.FavoriteEvent
@ -59,35 +60,30 @@ class CachedTimelineViewModel @Inject constructor(
accountManager: AccountManager, accountManager: AccountManager,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
sharedPreferencesRepository: SharedPreferencesRepository, sharedPreferencesRepository: SharedPreferencesRepository,
) : TimelineViewModel( ) : TimelineViewModel<TimelineStatusWithAccount>(
savedStateHandle, savedStateHandle,
timelineCases, timelineCases,
eventHub, eventHub,
accountManager, accountManager,
repository,
statusDisplayOptionsRepository, statusDisplayOptionsRepository,
sharedPreferencesRepository, sharedPreferencesRepository,
) { ) {
override var statuses: Flow<PagingData<StatusViewData>> override var statuses = accountFlow.flatMapLatest {
getStatuses(it.data!!)
init {
readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId
statuses = refreshFlow.flatMapLatest {
getStatuses(it.second, initialKey = getInitialKey())
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
}
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
private fun getStatuses( private suspend fun getStatuses(
account: AccountEntity, account: AccountEntity,
initialKey: String? = null,
): Flow<PagingData<StatusViewData>> { ): Flow<PagingData<StatusViewData>> {
Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey) Timber.d("getStatuses: kind: %s", timeline)
return repository.getStatusStream(account, kind = timeline, initialKey = initialKey) return repository.getStatusStream(account, timeline)
.map { pagingData -> .map { pagingData ->
pagingData pagingData
.map { .map {
StatusViewData.from( StatusViewData.from(
pachliAccountId = account.id,
it, it,
isExpanded = activeAccount.alwaysOpenSpoiler, isExpanded = activeAccount.alwaysOpenSpoiler,
isShowingContent = activeAccount.alwaysShowSensitiveMedia, isShowingContent = activeAccount.alwaysShowSensitiveMedia,
@ -102,21 +98,21 @@ class CachedTimelineViewModel @Inject constructor(
// handled by CacheUpdater // handled by CacheUpdater
} }
override fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) { override fun changeExpanded(expanded: Boolean, status: StatusViewData) {
viewModelScope.launch { 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 { 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 { 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 { viewModelScope.launch {
repository.clearStatusWarning(pachliAccountId, statusViewData.actionableId) repository.clearStatusWarning(statusViewData.pachliAccountId, statusViewData.actionableId)
} }
} }
@ -158,20 +154,6 @@ class CachedTimelineViewModel @Inject constructor(
// handled by CacheUpdater // 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) { override suspend fun invalidate(pachliAccountId: Long) {
repository.invalidate(pachliAccountId) repository.invalidate(pachliAccountId)
} }

View File

@ -35,6 +35,7 @@ import app.pachli.core.eventhub.PinEvent
import app.pachli.core.eventhub.ReblogEvent import app.pachli.core.eventhub.ReblogEvent
import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -59,11 +60,12 @@ class NetworkTimelineViewModel @Inject constructor(
accountManager: AccountManager, accountManager: AccountManager,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
sharedPreferencesRepository: SharedPreferencesRepository, sharedPreferencesRepository: SharedPreferencesRepository,
) : TimelineViewModel( ) : TimelineViewModel<Status>(
savedStateHandle, savedStateHandle,
timelineCases, timelineCases,
eventHub, eventHub,
accountManager, accountManager,
repository,
statusDisplayOptionsRepository, statusDisplayOptionsRepository,
sharedPreferencesRepository, sharedPreferencesRepository,
) { ) {
@ -72,22 +74,20 @@ class NetworkTimelineViewModel @Inject constructor(
override var statuses: Flow<PagingData<StatusViewData>> override var statuses: Flow<PagingData<StatusViewData>>
init { init {
statuses = refreshFlow statuses = accountFlow
.flatMapLatest { .flatMapLatest { getStatuses(it.data!!) }.cachedIn(viewModelScope)
getStatuses(it.second, initialKey = getInitialKey())
}.cachedIn(viewModelScope)
} }
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
private fun getStatuses( private suspend fun getStatuses(
account: AccountEntity, account: AccountEntity,
initialKey: String? = null,
): Flow<PagingData<StatusViewData>> { ): Flow<PagingData<StatusViewData>> {
Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey) Timber.d("getStatuses: kind: %s", timeline)
return repository.getStatusStream(account, kind = timeline, initialKey = initialKey) return repository.getStatusStream(account, kind = timeline)
.map { pagingData -> .map { pagingData ->
pagingData.map { pagingData.map {
modifiedViewData[it.id] ?: StatusViewData.from( modifiedViewData[it.id] ?: StatusViewData.from(
pachliAccountId = account.id,
it, it,
isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive, isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive,
isExpanded = statusDisplayOptions.value.openSpoiler, isExpanded = statusDisplayOptions.value.openSpoiler,
@ -105,21 +105,21 @@ class NetworkTimelineViewModel @Inject constructor(
repository.invalidate() repository.invalidate()
} }
override fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) { override fun changeExpanded(expanded: Boolean, status: StatusViewData) {
modifiedViewData[status.id] = status.copy( modifiedViewData[status.id] = status.copy(
isExpanded = expanded, isExpanded = expanded,
) )
repository.invalidate() repository.invalidate()
} }
override fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData) { override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) {
modifiedViewData[status.id] = status.copy( modifiedViewData[status.id] = status.copy(
isShowingContent = isShowing, isShowingContent = isShowing,
) )
repository.invalidate() 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("changeContentCollapsed: %s", isCollapsed)
Timber.d(" %s", status.content) Timber.d(" %s", status.content)
modifiedViewData[status.id] = status.copy( modifiedViewData[status.id] = status.copy(
@ -181,19 +181,7 @@ class NetworkTimelineViewModel @Inject constructor(
repository.invalidate() repository.invalidate()
} }
override fun reloadKeepingReadingPosition(pachliAccountId: Long) { override fun clearWarning(statusViewData: StatusViewData) {
super.reloadKeepingReadingPosition(pachliAccountId)
viewModelScope.launch {
repository.reload()
}
}
override fun reloadFromNewest(pachliAccountId: Long) {
super.reloadFromNewest(pachliAccountId)
reloadKeepingReadingPosition(pachliAccountId)
}
override fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData) {
viewModelScope.launch { viewModelScope.launch {
repository.updateActionableStatusById(statusViewData.actionableId) { repository.updateActionableStatusById(statusViewData.actionableId) {
it.copy(filtered = null) it.copy(filtered = null)

View File

@ -17,7 +17,6 @@
package app.pachli.components.timeline.viewmodel package app.pachli.components.timeline.viewmodel
import androidx.annotation.CallSuper
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
@ -26,6 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import app.pachli.R import app.pachli.R
import app.pachli.components.timeline.TimelineRepository
import app.pachli.core.common.extensions.throttleFirst import app.pachli.core.common.extensions.throttleFirst
import app.pachli.core.data.model.StatusViewData import app.pachli.core.data.model.StatusViewData
import app.pachli.core.data.repository.AccountManager 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.network.ContentFilterModel
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import at.connyduck.calladapter.networkresult.getOrThrow 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.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn 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 * Infallible because if it fails there's nowhere to show the error, and nothing the user
* can do. * 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 */ /** 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 // 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 */ /** A status the user wrote was successfully edited */
data class StatusEdited(val status: Status) : UiSuccess 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 */ /** Actions the user can trigger on an individual status */
@ -268,11 +277,12 @@ sealed interface UiError {
} }
} }
abstract class TimelineViewModel( abstract class TimelineViewModel<T : Any>(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
private val eventHub: EventHub, private val eventHub: EventHub,
protected val accountManager: AccountManager, protected val accountManager: AccountManager,
private val repository: TimelineRepository<T>,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
private val sharedPreferencesRepository: SharedPreferencesRepository, private val sharedPreferencesRepository: SharedPreferencesRepository,
) : ViewModel() { ) : ViewModel() {
@ -286,26 +296,8 @@ abstract class TimelineViewModel(
/** Flow of user actions received from the UI */ /** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>() private val uiAction = MutableSharedFlow<UiAction>()
/** Flow that can be used to trigger a full reload */ private val _uiResult = Channel<Result<UiSuccess, UiError>>()
protected val reload = MutableStateFlow(0) val uiResult = _uiResult.receiveAsFlow()
/** 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<UiSuccess>()
@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<UiError>()
/** Expose UI errors as a flow */
val uiError = _uiErrorChannel.receiveAsFlow()
/** Accept UI actions in to actionStateFlow */ /** Accept UI actions in to actionStateFlow */
val accept: (UiAction) -> Unit = { action -> val accept: (UiAction) -> Unit = { action ->
@ -323,18 +315,10 @@ abstract class TimelineViewModel(
return accountManager.activeAccount!! return accountManager.activeAccount!!
} }
protected val refreshFlow = reload.combine( protected val accountFlow = accountManager.activeAccountFlow
accountManager.activeAccountFlow
.filterIsInstance<Loadable.Loaded<AccountEntity?>>() .filterIsInstance<Loadable.Loaded<AccountEntity?>>()
.filter { it.data != null } .filter { it.data != null }
.distinctUntilChangedBy { it.data?.id!! }, .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
private var contentFilterModel: ContentFilterModel? = null private var contentFilterModel: ContentFilterModel? = null
@ -348,9 +332,7 @@ abstract class TimelineViewModel(
ContentFilterVersion.V2 -> ContentFilterModel(filterContext) ContentFilterVersion.V2 -> ContentFilterModel(filterContext)
ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters) ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters)
} }
if (reload) { if (reload) repository.invalidate(account.id)
reloadKeepingReadingPosition(account.id)
}
true true
} }
} }
@ -385,12 +367,15 @@ abstract class TimelineViewModel(
action.choices, action.choices,
) )
is StatusAction.Translate -> { is StatusAction.Translate -> {
timelineCases.translate(activeAccount.id, action.statusViewData) timelineCases.translate(action.statusViewData)
} }
}.getOrThrow() }.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) { } 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 { viewModelScope.launch {
eventHub.events.collectLatest { eventHub.events.collectLatest {
when (it) { when (it) {
is BlockEvent -> uiSuccess.emit(UiSuccess.Block) is BlockEvent -> _uiResult.send(Ok(UiSuccess.Block))
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) is MuteEvent -> _uiResult.send(Ok(UiSuccess.Mute))
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) is MuteConversationEvent -> _uiResult.send(Ok(UiSuccess.MuteConversation))
is StatusComposedEvent -> uiSuccess.emit(UiSuccess.StatusSent(it.status)) is StatusComposedEvent -> _uiResult.send(Ok(UiSuccess.StatusSent(it.status)))
is StatusEditedEvent -> uiSuccess.emit(UiSuccess.StatusEdited(it.status)) is StatusEditedEvent -> _uiResult.send(Ok(UiSuccess.StatusEdited(it.status)))
} }
} }
} }
@ -455,8 +440,7 @@ abstract class TimelineViewModel(
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { action -> .collectLatest { action ->
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId) Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId)
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, action.visibleId) timelineCases.saveRefreshKey(activeAccount.id, action.visibleId)
readingPositionId = action.visibleId
} }
} }
} }
@ -467,10 +451,10 @@ abstract class TimelineViewModel(
.filterIsInstance<InfallibleUiAction.LoadNewest>() .filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest { .collectLatest {
if (timeline == Timeline.Home) { if (timeline == Timeline.Home) {
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, null) timelineCases.saveRefreshKey(activeAccount.id, null)
} }
Timber.d("Reload because InfallibleUiAction.LoadNewest") Timber.d("Reload because InfallibleUiAction.LoadNewest")
reloadFromNewest(activeAccount.id) _uiResult.send(Ok(UiSuccess.LoadNewest))
} }
} }
@ -481,27 +465,16 @@ abstract class TimelineViewModel(
} }
} }
viewModelScope.launch { viewModelScope.launch { eventHub.events.collect { handleEvent(it) } }
eventHub.events
.collect { event -> handleEvent(event) }
}
}
fun getInitialKey(): String? {
if (timeline != Timeline.Home) {
return null
}
return activeAccount.lastVisibleHomeTimelineStatusId
} }
abstract fun updatePoll(newPoll: Poll, status: StatusViewData) 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) abstract fun removeAllByAccountId(pachliAccountId: Long, accountId: String)
@ -517,27 +490,7 @@ abstract class TimelineViewModel(
abstract fun handlePinEvent(pinEvent: PinEvent) abstract fun handlePinEvent(pinEvent: PinEvent)
/** abstract fun clearWarning(statusViewData: StatusViewData)
* 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)
/** Triggered when currently displayed data must be reloaded. */ /** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate(pachliAccountId: Long) 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 // TODO: Update this so that the list of UIPrefs is correct
private fun onPreferenceChanged(key: String) { private suspend fun onPreferenceChanged(key: String) {
when (key) { when (key) {
PrefKeys.TAB_FILTER_HOME_REPLIES -> { PrefKeys.TAB_FILTER_HOME_REPLIES -> {
val filter = sharedPreferencesRepository.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) val filter = sharedPreferencesRepository.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
@ -564,7 +517,7 @@ abstract class TimelineViewModel(
filterRemoveReplies = timeline is Timeline.Home && !filter filterRemoveReplies = timeline is Timeline.Home && !filter
if (oldRemoveReplies != filterRemoveReplies) { if (oldRemoveReplies != filterRemoveReplies) {
Timber.d("Reload because TAB_FILTER_HOME_REPLIES changed") Timber.d("Reload because TAB_FILTER_HOME_REPLIES changed")
reloadKeepingReadingPosition(activeAccount.id) repository.invalidate(activeAccount.id)
} }
} }
PrefKeys.TAB_FILTER_HOME_BOOSTS -> { PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
@ -573,7 +526,7 @@ abstract class TimelineViewModel(
filterRemoveReblogs = timeline is Timeline.Home && !filter filterRemoveReblogs = timeline is Timeline.Home && !filter
if (oldRemoveReblogs != filterRemoveReblogs) { if (oldRemoveReblogs != filterRemoveReblogs) {
Timber.d("Reload because TAB_FILTER_HOME_BOOSTS changed") Timber.d("Reload because TAB_FILTER_HOME_BOOSTS changed")
reloadKeepingReadingPosition(activeAccount.id) repository.invalidate(activeAccount.id)
} }
} }
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> {
@ -582,13 +535,13 @@ abstract class TimelineViewModel(
filterRemoveSelfReblogs = timeline is Timeline.Home && !filter filterRemoveSelfReblogs = timeline is Timeline.Home && !filter
if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) {
Timber.d("Reload because TAB_SHOW_SOME_SELF_BOOSTS changed") 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) { when (event) {
is FavoriteEvent -> handleFavEvent(event) is FavoriteEvent -> handleFavEvent(event)
is ReblogEvent -> handleReblogEvent(event) is ReblogEvent -> handleReblogEvent(event)
@ -596,7 +549,7 @@ abstract class TimelineViewModel(
is PinEvent -> handlePinEvent(event) is PinEvent -> handlePinEvent(event)
is MuteConversationEvent -> { is MuteConversationEvent -> {
Timber.d("Reload because MuteConversationEvent") Timber.d("Reload because MuteConversationEvent")
reloadKeepingReadingPosition(activeAccount.id) repository.invalidate(activeAccount.id)
} }
is UnfollowEvent -> { is UnfollowEvent -> {
if (timeline is Timeline.Home) { if (timeline is Timeline.Home) {

View File

@ -33,7 +33,6 @@ import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
class ThreadAdapter( class ThreadAdapter(
private val pachliAccountId: Long,
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val statusActionListener: StatusActionListener<StatusViewData>, private val statusActionListener: StatusActionListener<StatusViewData>,
) : ListAdapter<StatusViewData, StatusBaseViewHolder<StatusViewData>>(ThreadDifferCallback) { ) : ListAdapter<StatusViewData, StatusBaseViewHolder<StatusViewData>>(ThreadDifferCallback) {
@ -56,7 +55,7 @@ class ThreadAdapter(
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder<StatusViewData>, position: Int) { override fun onBindViewHolder(viewHolder: StatusBaseViewHolder<StatusViewData>, position: Int) {
val status = getItem(position) val status = getItem(position)
viewHolder.setupWithStatus(pachliAccountId, status, statusActionListener, statusDisplayOptions) viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {

View File

@ -96,7 +96,7 @@ class ViewThreadFragment :
lifecycleScope.launch { lifecycleScope.launch {
val statusDisplayOptions = viewModel.statusDisplayOptions.value 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) viewModel.refresh(thisThreadsStatusId)
} }
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { override fun onReply(viewData: StatusViewData) {
super.reply(pachliAccountId, viewData.actionable) super.reply(viewData.pachliAccountId, viewData.actionable)
} }
override fun onReblog(viewData: StatusViewData, reblog: Boolean) { 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) viewModel.changeExpanded(expanded, viewData)
} }
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) { override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
viewModel.changeContentShowing(isShowingContent, viewData) viewModel.changeContentShowing(isShowingContent, viewData)
} }
@ -357,7 +357,7 @@ class ViewThreadFragment :
activity?.startActivityWithDefaultTransition(intent) activity?.startActivityWithDefaultTransition(intent)
} }
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) { override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.changeContentCollapsed(isCollapsed, viewData) 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) viewModel.clearWarning(viewData)
} }

View File

@ -147,6 +147,7 @@ class ViewThreadViewModel @Inject constructor(
// status content is the same. Then the status flickers as it is drawn twice. // status content is the same. Then the status flickers as it is drawn twice.
if (status.actionableId == id) { if (status.actionableId == id) {
StatusViewData.from( StatusViewData.from(
pachliAccountId = account.id,
status = status.actionableStatus, status = status.actionableStatus,
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler, isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler,
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
@ -157,6 +158,7 @@ class ViewThreadViewModel @Inject constructor(
) )
} else { } else {
StatusViewData.from( StatusViewData.from(
pachliAccountId = account.id,
timelineStatusWithAccount, timelineStatusWithAccount,
isExpanded = account.alwaysOpenSpoiler, isExpanded = account.alwaysOpenSpoiler,
isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
@ -186,6 +188,7 @@ class ViewThreadViewModel @Inject constructor(
if (timelineStatusWithAccount != null) { if (timelineStatusWithAccount != null) {
api.status(id).getOrNull()?.let { api.status(id).getOrNull()?.let {
detailedStatus = StatusViewData.from( detailedStatus = StatusViewData.from(
pachliAccountId = account.id,
it, it,
isShowingContent = detailedStatus.isShowingContent, isShowingContent = detailedStatus.isShowingContent,
isExpanded = detailedStatus.isExpanded, isExpanded = detailedStatus.isExpanded,
@ -207,6 +210,7 @@ class ViewThreadViewModel @Inject constructor(
val ancestors = statusContext.ancestors.map { status -> val ancestors = statusContext.ancestors.map { status ->
val svd = cachedViewData[status.id] val svd = cachedViewData[status.id]
StatusViewData.from( StatusViewData.from(
pachliAccountId = account.id,
status, status,
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler, isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
@ -219,6 +223,7 @@ class ViewThreadViewModel @Inject constructor(
val descendants = statusContext.descendants.map { status -> val descendants = statusContext.descendants.map { status ->
val svd = cachedViewData[status.id] val svd = cachedViewData[status.id]
StatusViewData.from( StatusViewData.from(
pachliAccountId = account.id,
status, status,
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler, isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
@ -336,7 +341,7 @@ class ViewThreadViewModel @Inject constructor(
) )
} }
viewModelScope.launch { 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) viewData.copy(isShowingContent = isShowing)
} }
viewModelScope.launch { 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) viewData.copy(isCollapsed = isCollapsed)
} }
viewModelScope.launch { 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) { fun translate(statusViewData: StatusViewData) {
viewModelScope.launch { viewModelScope.launch {
repository.translate(activeAccount.id, statusViewData).fold( repository.translate(statusViewData).fold(
{ {
val translatedEntity = TranslatedStatusEntity( val translatedEntity = TranslatedStatusEntity(
serverId = statusViewData.actionableId, serverId = statusViewData.actionableId,
timelineUserId = activeAccount.id, timelineUserId = statusViewData.pachliAccountId,
content = it.content, content = it.content,
spoilerText = it.spoilerText, spoilerText = it.spoilerText,
poll = it.poll, poll = it.poll,
@ -490,7 +495,6 @@ class ViewThreadViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
repository.saveStatusViewData( repository.saveStatusViewData(
activeAccount.id,
statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL), 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 { 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 } val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id }
return from( return from(
pachliAccountId = account.id,
status, status,
isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler, isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler,

View File

@ -24,7 +24,7 @@ import app.pachli.core.network.model.Status
import app.pachli.core.ui.LinkListener import app.pachli.core.ui.LinkListener
interface StatusActionListener<T : IStatusViewData> : LinkListener { interface StatusActionListener<T : IStatusViewData> : LinkListener {
fun onReply(pachliAccountId: Long, viewData: T) fun onReply(viewData: T)
fun onReblog(viewData: T, reblog: Boolean) fun onReblog(viewData: T, reblog: Boolean)
fun onFavourite(viewData: T, favourite: Boolean) fun onFavourite(viewData: T, favourite: Boolean)
fun onBookmark(viewData: T, bookmark: Boolean) fun onBookmark(viewData: T, bookmark: Boolean)
@ -36,8 +36,8 @@ interface StatusActionListener<T : IStatusViewData> : LinkListener {
* Open reblog author for the status. * Open reblog author for the status.
*/ */
fun onOpenReblog(status: Status) fun onOpenReblog(status: Status)
fun onExpandedChange(pachliAccountId: Long, viewData: T, expanded: Boolean) fun onExpandedChange(viewData: T, expanded: Boolean)
fun onContentHiddenChange(pachliAccountId: Long, viewData: T, isShowingContent: Boolean) fun onContentHiddenChange(viewData: T, isShowingContent: Boolean)
/** /**
* Called when the status [android.widget.ToggleButton] responsible for collapsing long * Called when the status [android.widget.ToggleButton] responsible for collapsing long
@ -45,7 +45,7 @@ interface StatusActionListener<T : IStatusViewData> : LinkListener {
* *
* @param isCollapsed Whether the status content is shown in a collapsed state or fully. * @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 * called when the reblog count has been clicked
@ -60,7 +60,7 @@ interface StatusActionListener<T : IStatusViewData> : LinkListener {
fun onShowEdits(statusId: String) {} fun onShowEdits(statusId: String) {}
/** Remove the content filter from the status. */ /** Remove the content filter from the status. */
fun clearContentFilter(pachliAccountId: Long, viewData: T) fun clearContentFilter(viewData: T)
/** Edit the filter that matched this status. */ /** Edit the filter that matched this status. */
fun onEditFilterById(pachliAccountId: Long, filterId: String) fun onEditFilterById(pachliAccountId: Long, filterId: String)

View File

@ -33,7 +33,7 @@ class DeveloperToolsUseCase @Inject constructor(
* Clear the home timeline cache. * Clear the home timeline cache.
*/ */
suspend fun clearHomeTimelineCache(accountId: Long) { suspend fun clearHomeTimelineCache(accountId: Long) {
timelineDao.removeAllStatuses(accountId) timelineDao.deleteAllStatusesForAccount(accountId)
} }
/** /**

View File

@ -143,11 +143,15 @@ class TimelineCases @Inject constructor(
return mastodonApi.rejectFollowRequest(accountId) return mastodonApi.rejectFollowRequest(accountId)
} }
suspend fun translate(pachliAccountId: Long, statusViewData: StatusViewData): NetworkResult<Translation> { suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
return cachedTimelineRepository.translate(pachliAccountId, statusViewData) return cachedTimelineRepository.translate(statusViewData)
} }
suspend fun translateUndo(pachliAccountId: Long, statusViewData: 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)
} }
} }

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<CombinedLoadStates>.withPresentationState(): Flow<Pair<CombinedLoadStates, PresentationState>> {
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<CombinedLoadStates>.asRefreshState(): Flow<UserRefreshState> {
// 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<String>()
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")
}

View File

@ -113,7 +113,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
when (action) { when (action) {
app.pachli.core.ui.R.id.action_reply -> { app.pachli.core.ui.R.id.action_reply -> {
interrupt() 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_favourite -> statusActionListener.onFavourite(status, true)
app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false) app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false)
@ -152,7 +152,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
forceFocus(host) forceFocus(host)
} }
app.pachli.core.ui.R.id.action_collapse_cw -> { app.pachli.core.ui.R.id.action_collapse_cw -> {
statusActionListener.onExpandedChange(pachliAccountId, status, false) statusActionListener.onExpandedChange(status, false)
interrupt() interrupt()
} }
@ -201,7 +201,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
app.pachli.core.ui.R.id.action_more -> { app.pachli.core.ui.R.id.action_more -> {
statusActionListener.onMore(host, status) 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 -> { app.pachli.core.ui.R.id.action_edit_filter -> {
(recyclerView.findContainingViewHolder(host) as? FilterableStatusViewHolder<*>)?.matchedFilter?.let { (recyclerView.findContainingViewHolder(host) as? FilterableStatusViewHolder<*>)?.matchedFilter?.let {
statusActionListener.onEditFilterById(pachliAccountId, it.id) statusActionListener.onEditFilterById(pachliAccountId, it.id)

View File

@ -55,7 +55,7 @@ import app.pachli.core.network.model.TimelineAccount
* because of the account that sent it, and why. * because of the account that sent it, and why.
*/ */
data class NotificationViewData( data class NotificationViewData(
val pachliAccountId: Long, override val pachliAccountId: Long,
val localDomain: String, val localDomain: String,
val type: NotificationEntity.Type, val type: NotificationEntity.Type,
val id: String, val id: String,
@ -93,6 +93,7 @@ data class NotificationViewData(
account = data.account.toTimelineAccount(), account = data.account.toTimelineAccount(),
statusViewData = data.status?.let { statusViewData = data.status?.let {
StatusViewData.from( StatusViewData.from(
pachliAccountId = pachliAccountEntity.id,
it, it,
isExpanded = isExpanded, isExpanded = isExpanded,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,

View File

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@ -9,6 +11,17 @@
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:background="?android:attr/colorBackground"> android:background="?android:attr/colorBackground">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressIndicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:contentDescription=""
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout" android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -33,7 +33,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:contentDescription="" android:contentDescription=""
android:indeterminate="true" android:indeterminate="true"
android:visibility="visible" android:visibility="gone"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />

View File

@ -1,8 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressIndicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:contentDescription=""
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout" android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -35,7 +35,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:contentDescription="" android:contentDescription=""
android:indeterminate="true" android:indeterminate="true"
android:visibility="visible" android:visibility="gone"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />

View File

@ -54,6 +54,7 @@ class StatusComparisonTest {
@Test @Test
fun `two equal status view data - should be equal`() { fun `two equal status view data - should be equal`() {
val viewdata1 = StatusViewData( val viewdata1 = StatusViewData(
pachliAccountId = 1L,
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
@ -61,6 +62,7 @@ class StatusComparisonTest {
translationState = TranslationState.SHOW_ORIGINAL, translationState = TranslationState.SHOW_ORIGINAL,
) )
val viewdata2 = StatusViewData( val viewdata2 = StatusViewData(
pachliAccountId = 1L,
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
@ -73,6 +75,7 @@ class StatusComparisonTest {
@Test @Test
fun `status view data with different isExpanded - should not be equal`() { fun `status view data with different isExpanded - should not be equal`() {
val viewdata1 = StatusViewData( val viewdata1 = StatusViewData(
pachliAccountId = 1L,
status = createStatus(), status = createStatus(),
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
@ -80,6 +83,7 @@ class StatusComparisonTest {
translationState = TranslationState.SHOW_ORIGINAL, translationState = TranslationState.SHOW_ORIGINAL,
) )
val viewdata2 = StatusViewData( val viewdata2 = StatusViewData(
pachliAccountId = 1L,
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
@ -92,6 +96,7 @@ class StatusComparisonTest {
@Test @Test
fun `status view data with different statuses- should not be equal`() { fun `status view data with different statuses- should not be equal`() {
val viewdata1 = StatusViewData( val viewdata1 = StatusViewData(
pachliAccountId = 1L,
status = createStatus(content = "whatever"), status = createStatus(content = "whatever"),
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
@ -99,6 +104,7 @@ class StatusComparisonTest {
translationState = TranslationState.SHOW_ORIGINAL, translationState = TranslationState.SHOW_ORIGINAL,
) )
val viewdata2 = StatusViewData( val viewdata2 = StatusViewData(
pachliAccountId = 1L,
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,

View File

@ -31,7 +31,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub import org.mockito.kotlin.stub
@ -47,6 +46,7 @@ import org.mockito.kotlin.stub
class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() { class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() {
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
private val statusViewData = StatusViewData( private val statusViewData = StatusViewData(
pachliAccountId = 1L,
status = status, status = status,
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
@ -70,10 +70,6 @@ class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestB
statusViewData, statusViewData,
) )
/** Captors for status ID and state arguments */
private val id = argumentCaptor<String>()
private val state = argumentCaptor<Boolean>()
@Test @Test
fun `bookmark succeeds && emits UiSuccess`() = runTest { fun `bookmark succeeds && emits UiSuccess`() = runTest {
// Given // Given

View File

@ -96,12 +96,10 @@ class CachedTimelineRemoteMediatorTest {
@ExperimentalPagingApi @ExperimentalPagingApi
fun `should return error when network call returns error code`() { fun `should return error when network call returns error code`() {
val remoteMediator = CachedTimelineRemoteMediator( val remoteMediator = CachedTimelineRemoteMediator(
initialKey = null, mastodonApi = mock {
api = mock {
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
}, },
pachliAccountId = activeAccount.id, pachliAccountId = activeAccount.id,
factory = pagingSourceFactory,
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
@ -118,12 +116,10 @@ class CachedTimelineRemoteMediatorTest {
@ExperimentalPagingApi @ExperimentalPagingApi
fun `should return error when network call fails`() { fun `should return error when network call fails`() {
val remoteMediator = CachedTimelineRemoteMediator( val remoteMediator = CachedTimelineRemoteMediator(
initialKey = null, mastodonApi = mock {
api = mock {
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
}, },
pachliAccountId = activeAccount.id, pachliAccountId = activeAccount.id,
factory = pagingSourceFactory,
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
@ -139,10 +135,8 @@ class CachedTimelineRemoteMediatorTest {
@ExperimentalPagingApi @ExperimentalPagingApi
fun `should not prepend statuses`() { fun `should not prepend statuses`() {
val remoteMediator = CachedTimelineRemoteMediator( val remoteMediator = CachedTimelineRemoteMediator(
initialKey = null, mastodonApi = mock(),
api = mock(),
pachliAccountId = activeAccount.id, pachliAccountId = activeAccount.id,
factory = pagingSourceFactory,
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
@ -170,8 +164,7 @@ class CachedTimelineRemoteMediatorTest {
@ExperimentalPagingApi @ExperimentalPagingApi
fun `should not try to refresh already cached statuses when db is empty`() { fun `should not try to refresh already cached statuses when db is empty`() {
val remoteMediator = CachedTimelineRemoteMediator( val remoteMediator = CachedTimelineRemoteMediator(
initialKey = null, mastodonApi = mock {
api = mock {
onBlocking { homeTimeline(limit = 20) } doReturn Response.success( onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
listOf( listOf(
mockStatus("5"), mockStatus("5"),
@ -181,7 +174,6 @@ class CachedTimelineRemoteMediatorTest {
) )
}, },
pachliAccountId = activeAccount.id, pachliAccountId = activeAccount.id,
factory = pagingSourceFactory,
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
@ -224,8 +216,7 @@ class CachedTimelineRemoteMediatorTest {
db.insert(statusesAlreadyInDb) db.insert(statusesAlreadyInDb)
val remoteMediator = CachedTimelineRemoteMediator( val remoteMediator = CachedTimelineRemoteMediator(
initialKey = null, mastodonApi = mock {
api = mock {
onBlocking { homeTimeline(limit = 20) } doReturn Response.success( onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
listOf( listOf(
mockStatus("3"), mockStatus("3"),
@ -234,7 +225,6 @@ class CachedTimelineRemoteMediatorTest {
) )
}, },
pachliAccountId = activeAccount.id, pachliAccountId = activeAccount.id,
factory = pagingSourceFactory,
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
@ -280,8 +270,7 @@ class CachedTimelineRemoteMediatorTest {
db.remoteKeyDao().upsert(RemoteKeyEntity(1, RKE_TIMELINE_ID, RemoteKeyKind.NEXT, "5")) db.remoteKeyDao().upsert(RemoteKeyEntity(1, RKE_TIMELINE_ID, RemoteKeyKind.NEXT, "5"))
val remoteMediator = CachedTimelineRemoteMediator( val remoteMediator = CachedTimelineRemoteMediator(
initialKey = null, mastodonApi = mock {
api = mock {
onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success( onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success(
listOf( listOf(
mockStatus("3"), mockStatus("3"),
@ -294,7 +283,6 @@ class CachedTimelineRemoteMediatorTest {
) )
}, },
pachliAccountId = activeAccount.id, pachliAccountId = activeAccount.id,
factory = pagingSourceFactory,
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),

View File

@ -103,7 +103,7 @@ abstract class CachedTimelineViewModelTestBase {
lateinit var moshi: Moshi lateinit var moshi: Moshi
protected lateinit var timelineCases: TimelineCases protected lateinit var timelineCases: TimelineCases
protected lateinit var viewModel: TimelineViewModel protected lateinit var viewModel: CachedTimelineViewModel
private val eventHub = EventHub() private val eventHub = EventHub()

View File

@ -25,6 +25,8 @@ import app.pachli.components.timeline.viewmodel.UiError
import app.pachli.core.data.model.StatusViewData import app.pachli.core.data.model.StatusViewData
import app.pachli.core.database.model.TranslationState import app.pachli.core.database.model.TranslationState
import at.connyduck.calladapter.networkresult.NetworkResult 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 com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -50,6 +52,7 @@ import org.mockito.kotlin.verify
class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTestBase() { class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTestBase() {
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
private val statusViewData = StatusViewData( private val statusViewData = StatusViewData(
pachliAccountId = 1L,
status = status, status = status,
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
@ -82,14 +85,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
// Given // Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(bookmarkAction) viewModel.accept(bookmarkAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.Bookmark
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) assertThat(item?.action).isEqualTo(bookmarkAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
} }
// Then // Then
@ -103,14 +105,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
// Given // Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(bookmarkAction) viewModel.accept(bookmarkAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.Bookmark
assertThat(item).isInstanceOf(UiError.Bookmark::class.java) assertThat(item?.action).isEqualTo(bookmarkAction)
assertThat(item.action).isEqualTo(bookmarkAction)
} }
} }
@ -121,14 +122,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
} }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(favouriteAction) viewModel.accept(favouriteAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.Favourite
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) assertThat(item?.action).isEqualTo(favouriteAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
} }
// Then // Then
@ -142,14 +142,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
// Given // Given
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(favouriteAction) viewModel.accept(favouriteAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.Favourite
assertThat(item).isInstanceOf(UiError.Favourite::class.java) assertThat(item?.action).isEqualTo(favouriteAction)
assertThat(item.action).isEqualTo(favouriteAction)
} }
} }
@ -158,14 +157,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
// Given // Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(reblogAction) viewModel.accept(reblogAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.Reblog
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) assertThat(item?.action).isEqualTo(reblogAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
} }
// Then // Then
@ -179,14 +177,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
// Given // Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(reblogAction) viewModel.accept(reblogAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.Reblog
assertThat(item).isInstanceOf(UiError.Reblog::class.java) assertThat(item?.action).isEqualTo(reblogAction)
assertThat(item.action).isEqualTo(reblogAction)
} }
} }
@ -197,14 +194,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
} }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(voteInPollAction) viewModel.accept(voteInPollAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.VoteInPoll
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) assertThat(item?.action).isEqualTo(voteInPollAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
} }
// Then // Then
@ -221,14 +217,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
// Given // Given
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(voteInPollAction) viewModel.accept(voteInPollAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.VoteInPoll
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) assertThat(item?.action).isEqualTo(voteInPollAction)
assertThat(item.action).isEqualTo(voteInPollAction)
} }
} }
} }

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Loadable.Loaded<AccountEntity?>>()
.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")
}
}
}

View File

@ -93,7 +93,7 @@ abstract class NetworkTimelineViewModelTestBase {
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
protected lateinit var timelineCases: TimelineCases protected lateinit var timelineCases: TimelineCases
protected lateinit var viewModel: TimelineViewModel protected lateinit var viewModel: NetworkTimelineViewModel
private val eventHub = EventHub() private val eventHub = EventHub()

View File

@ -22,9 +22,12 @@ import app.pachli.ContentFilterV1Test.Companion.mockStatus
import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusAction
import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.StatusActionSuccess
import app.pachli.components.timeline.viewmodel.UiError 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.data.model.StatusViewData
import app.pachli.core.database.model.TranslationState import app.pachli.core.database.model.TranslationState
import at.connyduck.calladapter.networkresult.NetworkResult 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 com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -50,6 +53,7 @@ import org.mockito.kotlin.verify
class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelTestBase() { class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelTestBase() {
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
private val statusViewData = StatusViewData( private val statusViewData = StatusViewData(
pachliAccountId = 1L,
status = status, status = status,
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
@ -78,18 +82,17 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
private val state = argumentCaptor<Boolean>() private val state = argumentCaptor<Boolean>()
@Test @Test
fun `bookmark succeeds && emits UiSuccess`() = runTest { fun `bookmark succeeds && emits Ok uiResult`() = runTest {
// Given // Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(bookmarkAction) viewModel.accept(bookmarkAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.Bookmark
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) assertThat(item?.action).isEqualTo(bookmarkAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
} }
// Then // Then
@ -99,36 +102,34 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
} }
@Test @Test
fun `bookmark fails && emits UiError`() = runTest { fun `bookmark fails && emits Err uiResult`() = runTest {
// Given // Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(bookmarkAction) viewModel.accept(bookmarkAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.Bookmark
assertThat(item).isInstanceOf(UiError.Bookmark::class.java) assertThat(item?.action).isEqualTo(bookmarkAction)
assertThat(item.action).isEqualTo(bookmarkAction)
} }
} }
@Test @Test
fun `favourite succeeds && emits UiSuccess`() = runTest { fun `favourite succeeds && emits Ok uiResult`() = runTest {
// Given // Given
timelineCases.stub { timelineCases.stub {
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
} }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(favouriteAction) viewModel.accept(favouriteAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.Favourite
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) assertThat(item?.action).isEqualTo(favouriteAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
} }
// Then // Then
@ -138,34 +139,32 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
} }
@Test @Test
fun `favourite fails && emits UiError`() = runTest { fun `favourite fails && emits Err uiResult`() = runTest {
// Given // Given
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(favouriteAction) viewModel.accept(favouriteAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.Favourite
assertThat(item).isInstanceOf(UiError.Favourite::class.java) assertThat(item?.action).isEqualTo(favouriteAction)
assertThat(item.action).isEqualTo(favouriteAction)
} }
} }
@Test @Test
fun `reblog succeeds && emits UiSuccess`() = runTest { fun `reblog succeeds && emits Ok uiResult`() = runTest {
// Given // Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(reblogAction) viewModel.accept(reblogAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.Reblog
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) assertThat(item?.action).isEqualTo(reblogAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
} }
// Then // Then
@ -175,36 +174,34 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
} }
@Test @Test
fun `reblog fails && emits UiError`() = runTest { fun `reblog fails && emits Err uiResult`() = runTest {
// Given // Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(reblogAction) viewModel.accept(reblogAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.Reblog
assertThat(item).isInstanceOf(UiError.Reblog::class.java) assertThat(item?.action).isEqualTo(reblogAction)
assertThat(item.action).isEqualTo(reblogAction)
} }
} }
@Test @Test
fun `voteinpoll succeeds && emits UiSuccess`() = runTest { fun `voteinpoll succeeds && emits Ok uiResult`() = runTest {
// Given // Given
timelineCases.stub { timelineCases.stub {
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
} }
viewModel.uiSuccess.test { viewModel.uiResult.test {
// When // When
viewModel.accept(voteInPollAction) viewModel.accept(voteInPollAction)
// Then // Then
val item = awaitItem() val item = awaitItem().get() as? StatusActionSuccess.VoteInPoll
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) assertThat(item?.action).isEqualTo(voteInPollAction)
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
} }
// Then // Then
@ -217,18 +214,17 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
} }
@Test @Test
fun `voteinpoll fails && emits UiError`() = runTest { fun `voteinpoll fails && emits Err uiResult`() = runTest {
// Given // Given
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
viewModel.uiError.test { viewModel.uiResult.test {
// When // When
viewModel.accept(voteInPollAction) viewModel.accept(voteInPollAction)
// Then // Then
val item = awaitItem() val item = awaitItem().getError() as? UiError.VoteInPoll
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) assertThat(item?.action).isEqualTo(voteInPollAction)
assertThat(item.action).isEqualTo(voteInPollAction)
} }
} }
} }

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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()
}
}

View File

@ -6,15 +6,8 @@ import app.pachli.core.database.model.TimelineAccountEntity
import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusEntity
import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TimelineStatusWithAccount
import app.pachli.core.database.model.TranslationState 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.Status
import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.TimelineAccount
import com.squareup.moshi.Moshi
import java.time.Instant
import java.util.Date import java.util.Date
private val fixedDate = Date(1638889052000) private val fixedDate = Date(1638889052000)
@ -81,6 +74,7 @@ fun mockStatusViewData(
favourited: Boolean = true, favourited: Boolean = true,
bookmarked: Boolean = true, bookmarked: Boolean = true,
) = StatusViewData( ) = StatusViewData(
pachliAccountId = 1L,
status = mockStatus( status = mockStatus(
id = id, id = id,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
@ -103,13 +97,6 @@ fun mockStatusEntityWithAccount(
expanded: Boolean = false, expanded: Boolean = false,
): TimelineStatusWithAccount { ): TimelineStatusWithAccount {
val mockedStatus = mockStatus(id) 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( return TimelineStatusWithAccount(
status = TimelineStatusEntity.from( status = TimelineStatusEntity.from(

View File

@ -17,6 +17,7 @@
package app.pachli.core.activity package app.pachli.core.activity
import android.database.sqlite.SQLiteException
import android.util.Log import android.util.Log
import app.pachli.core.common.di.ApplicationScope import app.pachli.core.common.di.ApplicationScope
import app.pachli.core.database.dao.LogEntryDao import app.pachli.core.database.dao.LogEntryDao
@ -58,7 +59,8 @@ class LogEntryTree @Inject constructor(
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
externalScope.launch { externalScope.launch {
logEntryDao.upsert( try {
logEntryDao.insert(
LogEntryEntity( LogEntryEntity(
instant = Instant.now(), instant = Instant.now(),
priority = priority, priority = priority,
@ -67,6 +69,12 @@ class LogEntryTree @Inject constructor(
t = t, 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.
}
} }
} }
} }

View File

@ -35,6 +35,8 @@ import app.pachli.core.network.replaceCrashingCharacters
* [app.pachli.components.conversation.ConversationViewData]. * [app.pachli.components.conversation.ConversationViewData].
*/ */
interface IStatusViewData { interface IStatusViewData {
/** ID of the Pachli account that loaded this status. */
val pachliAccountId: Long
val username: String val username: String
val rebloggedAvatar: String? val rebloggedAvatar: String?
@ -117,6 +119,7 @@ interface IStatusViewData {
* Data required to display a status. * Data required to display a status.
*/ */
data class StatusViewData( data class StatusViewData(
override val pachliAccountId: Long,
override var status: Status, override var status: Status,
override var translation: TranslatedStatusEntity? = null, override var translation: TranslatedStatusEntity? = null,
override val isExpanded: Boolean, override val isExpanded: Boolean,
@ -196,6 +199,7 @@ data class StatusViewData(
companion object { companion object {
fun from( fun from(
pachliAccountId: Long,
status: Status, status: Status,
isShowingContent: Boolean, isShowingContent: Boolean,
isExpanded: Boolean, isExpanded: Boolean,
@ -217,6 +221,7 @@ data class StatusViewData(
} }
return StatusViewData( return StatusViewData(
pachliAccountId = pachliAccountId,
status = status, status = status,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,
isCollapsed = isCollapsed, 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( status = Status(
id = conversationStatusEntity.id, id = conversationStatusEntity.id,
url = conversationStatusEntity.url, url = conversationStatusEntity.url,
@ -281,6 +287,7 @@ data class StatusViewData(
* the status viewdata is null. * the status viewdata is null.
*/ */
fun from( fun from(
pachliAccountId: Long,
timelineStatusWithAccount: TimelineStatusWithAccount, timelineStatusWithAccount: TimelineStatusWithAccount,
isExpanded: Boolean, isExpanded: Boolean,
isShowingContent: Boolean, isShowingContent: Boolean,
@ -290,6 +297,7 @@ data class StatusViewData(
): StatusViewData { ): StatusViewData {
val status = timelineStatusWithAccount.toStatus() val status = timelineStatusWithAccount.toStatus()
return StatusViewData( return StatusViewData(
pachliAccountId = pachliAccountId,
status = status, status = status,
translation = timelineStatusWithAccount.translatedStatus, translation = timelineStatusWithAccount.translatedStatus,
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded, isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded,

View File

@ -786,11 +786,6 @@ class AccountManager @Inject constructor(
accountDao.setNotificationAccountFilterLimitedByServer(accountId, action) accountDao.setNotificationAccountFilterLimitedByServer(accountId, action)
} }
suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?) {
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", accountId, value)
accountDao.setLastVisibleHomeTimelineStatusId(accountId, value)
}
// -- Announcements // -- Announcements
suspend fun deleteAnnouncement(accountId: Long, announcementId: String) { suspend fun deleteAnnouncement(accountId: Long, announcementId: String) {
announcementsDao.deleteForAccount(accountId, announcementId) announcementsDao.deleteForAccount(accountId, announcementId)

View File

@ -221,18 +221,18 @@ class NotificationsRemoteMediator(
// The user's last read notification was missing. Use the page of notifications // The user's last read notification was missing. Use the page of notifications
// chronologically older than their desired notification. This page must *not* be // chronologically older than their desired notification. This page must *not* be
// empty (as noted earlier, if it is, paging stops). // empty (as noted earlier, if it is, paging stops).
deferredNextPage.await().let { response -> deferredNextPage.await().apply {
if (response.isSuccessful) { if (isSuccessful && !body().isNullOrEmpty()) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response return@coroutineScope this
} }
} }
// There were no notifications older than the user's desired notification. Return the page // 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 // of notifications immediately newer than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops). // *not* be empty (as noted earlier, if it is, paging stops).
mastodonApi.notifications(minId = notificationId, limit = pageSize).let { response -> deferredPrevPage.await().apply {
if (response.isSuccessful) { if (isSuccessful && !body().isNullOrEmpty()) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response return@coroutineScope this
} }
} }

View File

@ -99,7 +99,6 @@ class NotificationsRepository @Inject constructor(
private val remoteKeyDao: RemoteKeyDao, private val remoteKeyDao: RemoteKeyDao,
private val eventHub: EventHub, private val eventHub: EventHub,
) { ) {
private var factory: InvalidatingPagingSourceFactory<Int, NotificationData>? = null private var factory: InvalidatingPagingSourceFactory<Int, NotificationData>? = null
/** /**
@ -144,12 +143,7 @@ class NotificationsRepository @Inject constructor(
*/ */
suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async { suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async {
remoteKeyDao.upsert( remoteKeyDao.upsert(
RemoteKeyEntity( RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key),
pachliAccountId,
RKE_TIMELINE_ID,
RemoteKeyKind.REFRESH,
key,
),
) )
}.await() }.await()

File diff suppressed because it is too large Load Diff

View File

@ -92,7 +92,7 @@ import java.util.TimeZone
NotificationViewDataEntity::class, NotificationViewDataEntity::class,
NotificationRelationshipSeveranceEventEntity::class, NotificationRelationshipSeveranceEventEntity::class,
], ],
version = 13, version = 14,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
AutoMigration(from = 2, to = 3), AutoMigration(from = 2, to = 3),
@ -105,6 +105,7 @@ import java.util.TimeZone
AutoMigration(from = 10, to = 11), AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12, spec = AppDatabase.MIGRATE_11_12::class), AutoMigration(from = 11, to = 12, spec = AppDatabase.MIGRATE_11_12::class),
AutoMigration(from = 12, to = 13), AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14, spec = AppDatabase.MIGRATE_13_14::class),
], ],
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -180,6 +181,10 @@ abstract class AppDatabase : RoomDatabase() {
// lastNotificationId removed in favour of the REFRESH key in RemoteKeyEntity. // lastNotificationId removed in favour of the REFRESH key in RemoteKeyEntity.
@DeleteColumn("AccountEntity", "lastNotificationId") @DeleteColumn("AccountEntity", "lastNotificationId")
class MIGRATE_11_12 : AutoMigrationSpec 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) { val MIGRATE_8_9 = object : Migration(8, 9) {

View File

@ -394,15 +394,6 @@ interface AccountDao {
) )
fun setNotificationLight(accountId: Long, value: Boolean) fun setNotificationLight(accountId: Long, value: Boolean)
@Query(
"""
UPDATE AccountEntity
SET lastVisibleHomeTimelineStatusId = :value
WHERE id = :accountId
""",
)
suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?)
@Query( @Query(
""" """
UPDATE AccountEntity UPDATE AccountEntity

View File

@ -18,9 +18,9 @@
package app.pachli.core.database.dao package app.pachli.core.database.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.Upsert
import app.pachli.core.database.Converters import app.pachli.core.database.Converters
import app.pachli.core.database.model.LogEntryEntity import app.pachli.core.database.model.LogEntryEntity
import java.time.Instant import java.time.Instant
@ -30,9 +30,8 @@ import java.time.Instant
*/ */
@Dao @Dao
interface LogEntryDao { interface LogEntryDao {
/** Upsert [logEntry] */ @Insert
@Upsert suspend fun insert(logEntry: LogEntryEntity): Long
suspend fun upsert(logEntry: LogEntryEntity): Long
/** Load all [LogEntryEntity], ordered oldest first */ /** Load all [LogEntryEntity], ordered oldest first */
@Query( @Query(

View File

@ -152,18 +152,22 @@ ORDER BY LENGTH(n.serverId) DESC, n.serverId DESC
) )
fun pagingSource(pachliAccountId: Long): PagingSource<Int, NotificationData> fun pagingSource(pachliAccountId: Long): PagingSource<Int, NotificationData>
/** @return The database row number of the row for [notificationId]. */ /**
* @return Row number (0-based) of the notification with ID [notificationId]
* for [pachliAccountId]
*/
@Query( @Query(
""" """
SELECT RowNum SELECT rownum
FROM FROM (
(SELECT pachliAccountId, serverId, SELECT t1.pachliAccountId AS pachliAccountId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum
(SELECT count(*) + 1 FROM NotificationEntity t1
FROM notificationentity JOIN NotificationEntity t2 ON t1.pachliAccountId = t2.pachliAccountId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId)
WHERE rowid < t.rowid WHERE t1.pachliAccountId = :pachliAccountId
ORDER BY length(serverId) DESC, serverId DESC) AS RowNum GROUP BY t1.serverId
FROM notificationentity t) ORDER BY length(t1.serverId) DESC, t1.serverId DESC
WHERE pachliAccountId = :pachliAccountId AND serverId = :notificationId; )
WHERE serverId = :notificationId
""", """,
) )
suspend fun getNotificationRowNumber(pachliAccountId: Long, notificationId: String): Int suspend fun getNotificationRowNumber(pachliAccountId: Long, notificationId: String): Int

View File

@ -83,19 +83,25 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""",
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount> abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
/** /**
* All statuses for [account] in timeline ID. Used to find the correct initialKey to restore * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId].
* the user's reading position.
* *
* @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses] * @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses]
*/ */
@Query( @Query(
""" """
SELECT serverId SELECT rownum
FROM TimelineStatusEntity FROM (
WHERE timelineUserId = :account SELECT t1.timelineUserId AS timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum
ORDER BY LENGTH(serverId) DESC, serverId DESC""", 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
) )
abstract fun getStatusRowNumber(account: Long): List<String> WHERE serverId = :statusId
""",
)
abstract suspend fun getStatusRowNumber(pachliAccountId: Long, statusId: String): Int
@Query( @Query(
""" """
@ -163,23 +169,6 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe
) )
abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String) 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. * 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 * Deletes [TimelineAccountEntity] that are not referenced by a

View File

@ -99,12 +99,6 @@ data class AccountEntity(
val pushAuth: String = "", val pushAuth: String = "",
val pushServerKey: 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 **/ /** True if the connected Mastodon account is locked (has to manually approve all follow requests **/
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val locked: Boolean = false, val locked: Boolean = false,

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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 <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.postPrepend(
action: () -> Unit,
) {
val initial: Pair<LoadState?, LoadState?> = 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() }
}