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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,6 @@ import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.interfaces.StatusActionListener
class ConversationAdapter(
private val pachliAccountId: Long,
private var statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener<ConversationViewData>,
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
@ -54,7 +53,7 @@ class ConversationAdapter(
payloads: List<Any>,
) {
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,
) : IStatusViewData by lastStatus {
companion object {
fun from(conversationEntity: ConversationEntity) = ConversationViewData(
fun from(pachliAccountId: Long, conversationEntity: ConversationEntity) = ConversationViewData(
id = conversationEntity.id,
order = conversationEntity.order,
accounts = conversationEntity.accounts,
unread = conversationEntity.unread,
lastStatus = StatusViewData.from(conversationEntity.lastStatus),
lastStatus = StatusViewData.from(pachliAccountId, conversationEntity.lastStatus),
)
}
}

View File

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

View File

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

View File

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

View File

@ -37,7 +37,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -92,9 +91,9 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import postPrepend
import timber.log.Timber
@AndroidEntryPoint
@ -117,7 +116,12 @@ class NotificationsFragment :
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
private lateinit var adapter: NotificationsPagingAdapter
private val adapter = NotificationsPagingAdapter(
notificationDiffCallback,
statusActionListener = this,
notificationActionListener = this,
accountActionListener = this,
)
private lateinit var layoutManager: LinearLayoutManager
@ -191,14 +195,6 @@ class NotificationsFragment :
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
false
adapter = NotificationsPagingAdapter(
notificationDiffCallback,
statusActionListener = this@NotificationsFragment,
notificationActionListener = this@NotificationsFragment,
accountActionListener = this@NotificationsFragment,
statusDisplayOptions = viewModel.statusDisplayOptions.value,
)
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(pachliAccountId, binding.recyclerView, this@NotificationsFragment) { pos: Int ->
if (pos in 0 until adapter.itemCount) {
@ -211,11 +207,7 @@ class NotificationsFragment :
val saveIdListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState != SCROLL_STATE_IDLE) return
// Save the ID of the first notification visible in the list, so the user's
// reading position is always restorable.
saveVisibleId()
if (newState == SCROLL_STATE_IDLE) saveVisibleId()
}
}
binding.recyclerView.addOnScrollListener(saveIdListener)
@ -239,62 +231,60 @@ class NotificationsFragment :
// Update status display from statusDisplayOptions. If the new options request
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
launch {
viewModel.statusDisplayOptions
.collectLatest {
// NOTE this this also triggered (emitted?) on resume.
viewModel.statusDisplayOptions.collectLatest {
// NOTE this this also triggered (emitted?) on resume.
adapter.statusDisplayOptions = it
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
adapter.statusDisplayOptions = it
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
if (!it.useAbsoluteTime) {
updateTimestampFlow.collect()
}
if (!it.useAbsoluteTime) {
updateTimestampFlow.collect()
}
}
}
// Update the UI from the loadState
adapter.loadStateFlow
.collect { loadState ->
when (loadState.refresh) {
is LoadState.Error -> {
adapter.loadStateFlow.collect { loadState ->
when (loadState.refresh) {
is LoadState.Error -> {
binding.progressIndicator.hide()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) {
adapter.retry()
}
binding.recyclerView.hide()
binding.statusView.show()
binding.swipeRefreshLayout.isRefreshing = false
}
LoadState.Loading -> {
/* nothing */
binding.statusView.hide()
binding.progressIndicator.show()
}
is LoadState.NotLoading -> {
// Might still be loading if source.refresh is Loading, so only update
// the UI when loading is completely quiet.
if (loadState.source.refresh !is LoadState.Loading) {
binding.progressIndicator.hide()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) {
adapter.retry()
}
binding.recyclerView.hide()
binding.statusView.show()
binding.swipeRefreshLayout.isRefreshing = false
}
LoadState.Loading -> {
/* nothing */
binding.statusView.hide()
binding.progressIndicator.show()
}
is LoadState.NotLoading -> {
// Might still be loading if source.refresh is Loading, so only update
// the UI when loading is completely quiet.
if (loadState.source.refresh !is LoadState.Loading) {
binding.progressIndicator.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (adapter.itemCount == 0) {
binding.statusView.setup(BackgroundMessage.Empty())
binding.recyclerView.hide()
binding.statusView.show()
} else {
binding.statusView.hide()
binding.recyclerView.show()
}
if (adapter.itemCount == 0) {
binding.statusView.setup(BackgroundMessage.Empty())
binding.recyclerView.hide()
binding.statusView.show()
} else {
binding.statusView.hide()
binding.recyclerView.show()
}
}
}
}
}
}
}
}
private suspend fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
// Show errors from the view model as snack bars.
//
// Errors are shown:
@ -345,9 +335,7 @@ class NotificationsFragment :
}
when (uiSuccess) {
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> viewLifecycleOwner.lifecycleScope.launch {
refreshAdapterAndScrollToVisibleId()
}
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> refreshAdapterAndScrollToVisibleId()
is UiSuccess.LoadNewest -> {
// Scroll to the top when prepending completes.
@ -368,7 +356,7 @@ class NotificationsFragment :
/**
* Refreshes the adapter, waits for the first page to be updated, and scrolls the
* the recyclerview to the first notification that was visible before the refresh.
* recyclerview to the first notification that was visible before the refresh.
*
* This ensures the user's position is not lost during adapter refreshes.
*/
@ -385,23 +373,6 @@ class NotificationsFragment :
adapter.refresh()
}
/**
* Performs [action] after the next prepend operation completes on the adapter.
*
* A prepend operation is complete when the adapter's prepend [LoadState] transitions
* from [LoadState.Loading] to [LoadState.NotLoading].
*/
private suspend fun <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) {
menuInflater.inflate(R.menu.fragment_notifications, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -450,9 +421,7 @@ class NotificationsFragment :
}
binding.swipeRefreshLayout.isRefreshing = false
viewLifecycleOwner.lifecycleScope.launch {
refreshAdapterAndScrollToVisibleId()
}
refreshAdapterAndScrollToVisibleId()
clearNotificationsForAccount(requireContext(), pachliAccountId)
}
@ -483,20 +452,18 @@ class NotificationsFragment :
override fun onResume() {
super.onResume()
if (::adapter.isInitialized) {
val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
val wasEnabled = talkBackWasEnabled
talkBackWasEnabled = a11yManager?.isEnabled == true
if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
val wasEnabled = talkBackWasEnabled
talkBackWasEnabled = a11yManager?.isEnabled == true
if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
clearNotificationsForAccount(requireContext(), pachliAccountId)
}
override fun onReply(pachliAccountId: Long, viewData: NotificationViewData) {
super.reply(pachliAccountId, viewData.statusViewData!!.actionable)
override fun onReply(viewData: NotificationViewData) {
super.reply(viewData.pachliAccountId, viewData.statusViewData!!.actionable)
}
override fun onReblog(viewData: NotificationViewData, reblog: Boolean) {
@ -546,31 +513,23 @@ class NotificationsFragment :
)
}
// This is required by the interface StatusActionListener; the interface's ViewData T
// doesn't include pachliAccountId as a property.
// TODO: Update StatusActionListener.onExpandedChange to include the account ID.
override fun onExpandedChange(pachliAccountId: Long, viewData: NotificationViewData, expanded: Boolean) {
onExpandedChange(viewData, expanded)
}
override fun onContentHiddenChange(
pachliAccountId: Long,
viewData: NotificationViewData,
isShowingContent: Boolean,
) {
viewModel.accept(
InfallibleUiAction.SetShowingContent(
pachliAccountId,
viewData.pachliAccountId,
viewData.statusViewData!!,
isShowingContent,
),
)
}
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: NotificationViewData, isCollapsed: Boolean) {
override fun onContentCollapsedChange(viewData: NotificationViewData, isCollapsed: Boolean) {
viewModel.accept(
InfallibleUiAction.SetContentCollapsed(
pachliAccountId,
viewData.pachliAccountId,
viewData.statusViewData!!,
isCollapsed,
),
@ -585,17 +544,17 @@ class NotificationsFragment :
}
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, viewData: NotificationViewData) {
onContentCollapsedChange(viewData.pachliAccountId, viewData, isCollapsed)
onContentCollapsedChange(viewData, isCollapsed)
}
override fun clearContentFilter(pachliAccountId: Long, viewData: NotificationViewData) {
viewModel.accept(InfallibleUiAction.ClearContentFilter(pachliAccountId, viewData.id))
override fun clearContentFilter(viewData: NotificationViewData) {
viewModel.accept(InfallibleUiAction.ClearContentFilter(viewData.pachliAccountId, viewData.id))
}
override fun clearAccountFilter(viewData: NotificationViewData) {
viewModel.accept(
InfallibleUiAction.OverrideAccountFilter(
pachliAccountId,
viewData.pachliAccountId,
viewData.id,
viewData.accountFilterDecision,
),
@ -629,15 +588,11 @@ class NotificationsFragment :
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
viewLifecycleOwner.lifecycleScope.launch {
refreshAdapterAndScrollToVisibleId()
}
refreshAdapterAndScrollToVisibleId()
}
override fun onBlock(block: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
refreshAdapterAndScrollToVisibleId()
}
refreshAdapterAndScrollToVisibleId()
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,6 @@ import app.pachli.databinding.ItemStatusBinding
import app.pachli.interfaces.StatusActionListener
class SearchStatusesAdapter(
private val pachliAccountId: Long,
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener<StatusViewData>,
) : PagingDataAdapter<StatusViewData, StatusViewHolder<StatusViewData>>(STATUS_COMPARATOR) {
@ -40,7 +39,7 @@ class SearchStatusesAdapter(
override fun onBindViewHolder(holder: StatusViewHolder<StatusViewData>, position: Int) {
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),
)
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
return SearchStatusesAdapter(viewModel.activeAccount!!.id, statusDisplayOptions, this)
return SearchStatusesAdapter(statusDisplayOptions, this)
}
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) {
override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
viewModel.contentHiddenChange(viewData, isShowingContent)
}
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
reply(pachliAccountId, viewData)
override fun onReply(viewData: StatusViewData) {
reply(viewData)
}
override fun onFavourite(viewData: StatusViewData, favourite: Boolean) {
@ -148,11 +148,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
bottomSheetActivity?.viewAccount(pachliAccountId, status.account.id)
}
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) {
override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
viewModel.expandedChange(viewData, expanded)
}
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) {
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.collapsedChange(viewData, isCollapsed)
}
@ -160,7 +160,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
viewModel.voteInPoll(viewData, poll, choices)
}
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {}
override fun clearContentFilter(viewData: StatusViewData) {}
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
viewModel.reblog(viewData, reblog)
@ -173,7 +173,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
)
}
private fun reply(pachliAccountId: Long, status: StatusViewData) {
private fun reply(status: StatusViewData) {
val actionableStatus = status.actionable
val mentionedUsernames = actionableStatus.mentions.map { it.username }
.toMutableSet()
@ -184,7 +184,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
val intent = ComposeActivityIntent(
requireContext(),
pachliAccountId,
status.pachliAccountId,
ComposeOptions(
inReplyToId = status.actionableId,
replyVisibility = actionableStatus.visibility,

View File

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

View File

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

View File

@ -44,13 +44,13 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import app.pachli.BuildConfig
import app.pachli.R
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.components.timeline.util.isExpected
import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel
import app.pachli.components.timeline.viewmodel.InfallibleUiAction
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
import app.pachli.components.timeline.viewmodel.StatusAction
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
import app.pachli.components.timeline.viewmodel.TimelineViewModel
import app.pachli.components.timeline.viewmodel.UiError
import app.pachli.components.timeline.viewmodel.UiSuccess
import app.pachli.core.activity.RefreshableFragment
import app.pachli.core.activity.ReselectableFragment
@ -78,11 +78,10 @@ import app.pachli.interfaces.ActionButtonActivity
import app.pachli.interfaces.AppBarLayoutHost
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.ListStatusAccessibilityDelegate
import app.pachli.util.PresentationState
import app.pachli.util.UserRefreshState
import app.pachli.util.asRefreshState
import app.pachli.util.withPresentationState
import at.connyduck.sparkbutton.helpers.Utils
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.google.android.material.color.MaterialColors
import com.google.android.material.divider.MaterialDividerItemDecoration
import com.google.android.material.snackbar.Snackbar
@ -96,12 +95,13 @@ import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import postPrepend
import timber.log.Timber
@AndroidEntryPoint
@ -120,7 +120,7 @@ class TimelineFragment :
//
// If the navigation library was being used this would happen automatically, so this
// workaround can be removed when that change happens.
private val viewModel: TimelineViewModel by lazy {
private val viewModel: TimelineViewModel<out Any> by lazy {
if (timeline == Timeline.Home) {
viewModels<CachedTimelineViewModel>(
extrasProducer = {
@ -160,6 +160,19 @@ class TimelineFragment :
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?) {
super.onCreate(savedInstanceState)
@ -171,7 +184,7 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
adapter = TimelinePagingAdapter(pachliAccountId, this, viewModel.statusDisplayOptions.value)
adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value)
}
override fun onCreateView(
@ -179,16 +192,6 @@ class TimelineFragment :
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
viewModel.statuses.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
}
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
@ -216,123 +219,13 @@ class TimelineFragment :
}
}
/**
* Collect this flow to notify the adapter that the timestamps of the visible items have
* changed
*/
// TODO: Copied from NotificationsFragment
val updateTimestampFlow = flow {
while (true) {
delay(60.seconds)
emit(Unit)
}
}.onEach {
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Show errors from the view model as snack bars.
//
// Errors are shown:
// - Indefinitely, so the user has a chance to read and understand
// the message
// - With a max of 5 text lines, to allow space for longer errors.
// E.g., on a typical device, an error message like "Bookmarking
// post failed: Unable to resolve host 'mastodon.social': No
// address associated with hostname" is 3 lines.
// - With a "Retry" option if the error included a UiAction to retry.
// TODO: Very similar to same code in NotificationsFragment
launch {
viewModel.uiError.collect { error ->
val message = getString(
error.message,
error.throwable.getErrorString(requireContext()),
)
Timber.d(error.throwable, message)
snackbar?.dismiss()
snackbar = Snackbar.make(
// Without this the FAB will not move out of the way
(activity as? ActionButtonActivity)?.actionButton ?: binding.root,
message,
Snackbar.LENGTH_INDEFINITE,
)
error.action?.let { action ->
snackbar!!.setAction(app.pachli.core.ui.R.string.action_retry) {
viewModel.accept(action)
}
}
snackbar!!.show()
launch { viewModel.statuses.collectLatest { adapter.submitData(it) } }
// The status view has pre-emptively updated its state to show
// that the action succeeded. Since it hasn't, re-bind the view
// to show the correct data.
error.action?.let { action ->
if (action !is StatusAction) return@let
launch { viewModel.uiResult.collect(::bindUiResult) }
adapter.snapshot()
.indexOfFirst { it?.id == action.statusViewData.id }
.takeIf { it != RecyclerView.NO_POSITION }
?.let { adapter.notifyItemChanged(it) }
}
}
}
// Update adapter data when status actions are successful, and re-bind to update
// the UI.
launch {
viewModel.uiSuccess
.filterIsInstance<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.
// Collect the uiState.
launch {
viewModel.uiState.collect { uiState ->
if (layoutManager.reverseLayout != uiState.reverseTimeline) {
@ -348,164 +241,190 @@ class TimelineFragment :
// Update status display from statusDisplayOptions. If the new options request
// relative time display collect the flow to periodically re-bind the UI.
launch {
viewModel.statusDisplayOptions
.collectLatest {
adapter.statusDisplayOptions = it
layoutManager.findFirstVisibleItemPosition().let { first ->
first == RecyclerView.NO_POSITION && return@let
val count = layoutManager.findLastVisibleItemPosition() - first
adapter.notifyItemRangeChanged(
first,
count,
null,
)
}
viewModel.statusDisplayOptions.collectLatest {
adapter.statusDisplayOptions = it
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
if (!it.useAbsoluteTime) {
updateTimestampFlow.collect()
}
}
}
/** StateFlow (to allow multiple consumers) of UserRefreshState */
val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope)
// Scroll the list down (peek) if a refresh has completely finished. A refresh is
// finished when both the initial refresh is complete and any prepends have
// finished (so that DiffUtil has had a chance to process the data).
launch {
if (!isSwipeToRefreshEnabled) return@launch
/** True if the previous prepend resulted in a peek, false otherwise */
var peeked = false
/** 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
if (!it.useAbsoluteTime) {
updateTimestampFlow.collect()
}
}
}
// 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 -> {
adapter.loadStateFlow.collect { loadState ->
when (loadState.refresh) {
is LoadState.Error -> {
binding.progressIndicator.hide()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) {
adapter.retry()
}
binding.recyclerView.hide()
binding.statusView.show()
binding.swipeRefreshLayout.isRefreshing = false
}
LoadState.Loading -> {
/* nothing */
binding.statusView.hide()
binding.progressIndicator.show()
}
is LoadState.NotLoading -> {
// Might still be loading if source.refresh is Loading, so only update
// the UI when loading is completely quiet.
Timber.d("NotLoading .refresh: ${loadState.refresh}")
Timber.d(" NotLoading .source.refresh: ${loadState.source.refresh}")
Timber.d(" NotLoading .mediator.refresh: ${loadState.mediator?.refresh}")
if (loadState.source.refresh !is LoadState.Loading) {
binding.progressIndicator.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (adapter.itemCount == 0) {
binding.statusView.setup(BackgroundMessage.Empty())
if (timeline == Timeline.Home) {
binding.statusView.showHelp(R.string.help_empty_home)
}
binding.recyclerView.hide()
binding.statusView.show()
} else {
binding.statusView.hide()
binding.recyclerView.show()
}
}
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()
}
} else {
null
}
binding.statusView.setup(error, callback)
binding.statusView.show()
binding.recyclerView.hide()
}
}
PresentationState.PRESENTED -> {
if (adapter.itemCount == 0) {
binding.statusView.setup(BackgroundMessage.Empty())
if (timeline == Timeline.Home) {
binding.statusView.showHelp(R.string.help_empty_home)
}
binding.statusView.show()
binding.recyclerView.hide()
} else {
binding.recyclerView.show()
binding.statusView.hide()
}
}
else -> {
// Nothing to do -- show/hiding the progress bars in non-error states
// is handled via refreshState.
}
}
}
}
}
}
}
private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
// Show errors from the view model as snack bars.
//
// Errors are shown:
// - Indefinitely, so the user has a chance to read and understand
// the message
// - With a max of 5 text lines, to allow space for longer errors.
// E.g., on a typical device, an error message like "Bookmarking
// post failed: Unable to resolve host 'mastodon.social': No
// address associated with hostname" is 3 lines.
// - With a "Retry" option if the error included a UiAction to retry.
uiResult.onFailure { uiError ->
val message = getString(
uiError.message,
uiError.throwable.getErrorString(requireContext()),
)
Timber.d(uiError.throwable, message)
snackbar?.dismiss()
snackbar = Snackbar.make(
// Without this the FAB will not move out of the way
(activity as? ActionButtonActivity)?.actionButton ?: binding.root,
message,
Snackbar.LENGTH_INDEFINITE,
)
uiError.action?.let { action ->
snackbar!!.setAction(app.pachli.core.ui.R.string.action_retry) {
viewModel.accept(action)
}
}
snackbar!!.show()
// 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) {
menuInflater.inflate(R.menu.fragment_timeline, menu)
@ -541,8 +460,17 @@ class TimelineFragment :
}
}
private fun getFirstVisibleStatus() = (
layoutManager.findFirstCompletelyVisibleItemPosition()
.takeIf { it != RecyclerView.NO_POSITION }
?: layoutManager.findLastVisibleItemPosition()
.takeIf { it != RecyclerView.NO_POSITION }
)?.let { adapter.snapshot().getOrNull(it) }
/**
* Save [statusId] as the reading position. If null then the ID of the best status is used.
* Saves the ID of the first visible status as the reading position.
*
* If null then the ID of the best status is used.
*
* The best status is the first completely visible status, if available. We assume the user
* has read this far, or will recognise it on return.
@ -554,23 +482,14 @@ class TimelineFragment :
* In this case the best status is the last partially visible status, as we can assume the
* user has read this far.
*/
fun saveVisibleId(statusId: String? = null) {
val id = statusId ?: (
layoutManager.findFirstCompletelyVisibleItemPosition()
.takeIf { it != RecyclerView.NO_POSITION }
?: layoutManager.findLastVisibleItemPosition()
.takeIf { it != RecyclerView.NO_POSITION }
)
?.let { adapter.snapshot().getOrNull(it)?.id }
fun saveVisibleId() {
val id = getFirstVisibleStatus()?.id
if (BuildConfig.DEBUG && id == null) {
Toast.makeText(requireActivity(), "Could not find ID of item to save", LENGTH_LONG).show()
}
id?.let {
Timber.d("saveVisibleId: Saving ID: %s", it)
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = it))
} ?: Timber.d("saveVisibleId: Not saving, as no ID was visible")
viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, id))
}
}
private fun setupSwipeRefreshLayout() {
@ -615,7 +534,6 @@ class TimelineFragment :
/** Refresh the displayed content, as if the user had swiped on the SwipeRefreshLayout */
override fun refreshContent() {
Timber.d("Reloading via refreshContent")
binding.swipeRefreshLayout.isRefreshing = true
onRefresh()
}
@ -625,13 +543,26 @@ class TimelineFragment :
*/
override fun onRefresh() {
Timber.d("Reloading via onRefresh")
binding.statusView.hide()
snackbar?.dismiss()
adapter.refresh()
// Peek the list when refreshing completes.
viewLifecycleOwner.lifecycleScope.launch {
adapter.postPrepend {
binding.recyclerView.post {
view ?: return@post
binding.recyclerView.smoothScrollBy(
0,
Utils.dpToPx(requireContext(), -30),
)
}
}
}
binding.swipeRefreshLayout.isRefreshing = false
refreshAdapterAndScrollToVisibleId()
}
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
super.reply(pachliAccountId, viewData.actionable)
override fun onReply(viewData: StatusViewData) {
super.reply(viewData.pachliAccountId, viewData.actionable)
}
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
@ -650,8 +581,8 @@ class TimelineFragment :
viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData))
}
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {
viewModel.clearWarning(pachliAccountId, viewData)
override fun clearContentFilter(viewData: StatusViewData) {
viewModel.clearWarning(viewData)
}
override fun onEditFilterById(pachliAccountId: Long, filterId: String) {
@ -669,12 +600,12 @@ class TimelineFragment :
super.openReblog(status)
}
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) {
viewModel.changeExpanded(pachliAccountId, expanded, viewData)
override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
viewModel.changeExpanded(expanded, viewData)
}
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) {
viewModel.changeContentShowing(pachliAccountId, isShowingContent, viewData)
override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
viewModel.changeContentShowing(isShowingContent, viewData)
}
override fun onShowReblogs(statusId: String) {
@ -687,8 +618,8 @@ class TimelineFragment :
activity?.startActivityWithDefaultTransition(intent)
}
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.changeContentCollapsed(pachliAccountId, isCollapsed, viewData)
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.changeContentCollapsed(isCollapsed, viewData)
}
// Can only translate the home timeline at the moment

View File

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

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

View File

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

View File

@ -17,7 +17,6 @@
package app.pachli.components.timeline.viewmodel
import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.core.os.bundleOf
@ -26,6 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import app.pachli.R
import app.pachli.components.timeline.TimelineRepository
import app.pachli.core.common.extensions.throttleFirst
import app.pachli.core.data.model.StatusViewData
import app.pachli.core.data.repository.AccountManager
@ -58,21 +58,21 @@ import app.pachli.core.preferences.TabTapBehaviour
import app.pachli.network.ContentFilterModel
import app.pachli.usecase.TimelineCases
import at.connyduck.calladapter.networkresult.getOrThrow
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
@ -114,7 +114,10 @@ sealed interface InfallibleUiAction : UiAction {
* Infallible because if it fails there's nowhere to show the error, and nothing the user
* can do.
*/
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction
data class SaveVisibleId(
val pachliAccountId: Long,
val visibleId: String,
) : InfallibleUiAction
/** Ignore the saved reading position, load the page with the newest items */
// Resets the account's reading position, which can't fail, which is why this is
@ -145,6 +148,12 @@ sealed interface UiSuccess {
/** A status the user wrote was successfully edited */
data class StatusEdited(val status: Status) : UiSuccess
/**
* Resetting the reading position completed, the UI should refresh the adapter
* to load content at the new position.
*/
data object LoadNewest : UiSuccess
}
/** Actions the user can trigger on an individual status */
@ -268,11 +277,12 @@ sealed interface UiError {
}
}
abstract class TimelineViewModel(
abstract class TimelineViewModel<T : Any>(
savedStateHandle: SavedStateHandle,
private val timelineCases: TimelineCases,
private val eventHub: EventHub,
protected val accountManager: AccountManager,
private val repository: TimelineRepository<T>,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
private val sharedPreferencesRepository: SharedPreferencesRepository,
) : ViewModel() {
@ -286,26 +296,8 @@ abstract class TimelineViewModel(
/** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>()
/** Flow that can be used to trigger a full reload */
protected val reload = MutableStateFlow(0)
/** Flow of successful action results */
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
// (e.g., after a device orientation change) should not re-show the most recent success
// message, as it will be confusing to the user.
val uiSuccess = MutableSharedFlow<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()
private val _uiResult = Channel<Result<UiSuccess, UiError>>()
val uiResult = _uiResult.receiveAsFlow()
/** Accept UI actions in to actionStateFlow */
val accept: (UiAction) -> Unit = { action ->
@ -323,18 +315,10 @@ abstract class TimelineViewModel(
return accountManager.activeAccount!!
}
protected val refreshFlow = reload.combine(
accountManager.activeAccountFlow
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
.filter { it.data != null }
.distinctUntilChangedBy { it.data?.id!! },
) { refresh, account -> Pair(refresh, account.data!!) }
/** The ID of the status to which the user's reading position should be restored */
// Not part of the UiState as it's only used once in the lifespan of the fragment.
// Subclasses should set this if they support restoring the reading position.
open var readingPositionId: String? = null
protected set
protected val accountFlow = accountManager.activeAccountFlow
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
.filter { it.data != null }
.distinctUntilChangedBy { it.data?.id!! }
private var contentFilterModel: ContentFilterModel? = null
@ -348,9 +332,7 @@ abstract class TimelineViewModel(
ContentFilterVersion.V2 -> ContentFilterModel(filterContext)
ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters)
}
if (reload) {
reloadKeepingReadingPosition(account.id)
}
if (reload) repository.invalidate(account.id)
true
}
}
@ -385,12 +367,15 @@ abstract class TimelineViewModel(
action.choices,
)
is StatusAction.Translate -> {
timelineCases.translate(activeAccount.id, action.statusViewData)
timelineCases.translate(action.statusViewData)
}
}.getOrThrow()
uiSuccess.emit(StatusActionSuccess.from(action))
// TODO: This should look like the equivalent code in
// NotificationsViewModel when timelineCases returns
// Result<_, _> instead of NetworkResult.
_uiResult.send(Ok(StatusActionSuccess.from(action)))
} catch (e: Exception) {
_uiErrorChannel.send(UiError.make(e, action))
_uiResult.send(Err(UiError.make(e, action)))
}
}
}
@ -399,11 +384,11 @@ abstract class TimelineViewModel(
viewModelScope.launch {
eventHub.events.collectLatest {
when (it) {
is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
is StatusComposedEvent -> uiSuccess.emit(UiSuccess.StatusSent(it.status))
is StatusEditedEvent -> uiSuccess.emit(UiSuccess.StatusEdited(it.status))
is BlockEvent -> _uiResult.send(Ok(UiSuccess.Block))
is MuteEvent -> _uiResult.send(Ok(UiSuccess.Mute))
is MuteConversationEvent -> _uiResult.send(Ok(UiSuccess.MuteConversation))
is StatusComposedEvent -> _uiResult.send(Ok(UiSuccess.StatusSent(it.status)))
is StatusEditedEvent -> _uiResult.send(Ok(UiSuccess.StatusEdited(it.status)))
}
}
}
@ -455,8 +440,7 @@ abstract class TimelineViewModel(
.distinctUntilChanged()
.collectLatest { action ->
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId)
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, action.visibleId)
readingPositionId = action.visibleId
timelineCases.saveRefreshKey(activeAccount.id, action.visibleId)
}
}
}
@ -467,10 +451,10 @@ abstract class TimelineViewModel(
.filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest {
if (timeline == Timeline.Home) {
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, null)
timelineCases.saveRefreshKey(activeAccount.id, null)
}
Timber.d("Reload because InfallibleUiAction.LoadNewest")
reloadFromNewest(activeAccount.id)
_uiResult.send(Ok(UiSuccess.LoadNewest))
}
}
@ -481,27 +465,16 @@ abstract class TimelineViewModel(
}
}
viewModelScope.launch {
eventHub.events
.collect { event -> handleEvent(event) }
}
}
fun getInitialKey(): String? {
if (timeline != Timeline.Home) {
return null
}
return activeAccount.lastVisibleHomeTimelineStatusId
viewModelScope.launch { eventHub.events.collect { handleEvent(it) } }
}
abstract fun updatePoll(newPoll: Poll, status: StatusViewData)
abstract fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData)
abstract fun changeExpanded(expanded: Boolean, status: StatusViewData)
abstract fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData)
abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData)
abstract fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData)
abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData)
abstract fun removeAllByAccountId(pachliAccountId: Long, accountId: String)
@ -517,27 +490,7 @@ abstract class TimelineViewModel(
abstract fun handlePinEvent(pinEvent: PinEvent)
/**
* Reload data for this timeline while preserving the user's reading position.
*
* Subclasses should call this, then start loading data.
*/
@CallSuper
open fun reloadKeepingReadingPosition(pachliAccountId: Long) {
reload.getAndUpdate { it + 1 }
}
/**
* Load the most recent data for this timeline, ignoring the user's reading position.
*
* Subclasses should call this, then start loading data.
*/
@CallSuper
open fun reloadFromNewest(pachliAccountId: Long) {
reload.getAndUpdate { it + 1 }
}
abstract fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData)
abstract fun clearWarning(statusViewData: StatusViewData)
/** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate(pachliAccountId: Long)
@ -556,7 +509,7 @@ abstract class TimelineViewModel(
}
// TODO: Update this so that the list of UIPrefs is correct
private fun onPreferenceChanged(key: String) {
private suspend fun onPreferenceChanged(key: String) {
when (key) {
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
val filter = sharedPreferencesRepository.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
@ -564,7 +517,7 @@ abstract class TimelineViewModel(
filterRemoveReplies = timeline is Timeline.Home && !filter
if (oldRemoveReplies != filterRemoveReplies) {
Timber.d("Reload because TAB_FILTER_HOME_REPLIES changed")
reloadKeepingReadingPosition(activeAccount.id)
repository.invalidate(activeAccount.id)
}
}
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
@ -573,7 +526,7 @@ abstract class TimelineViewModel(
filterRemoveReblogs = timeline is Timeline.Home && !filter
if (oldRemoveReblogs != filterRemoveReblogs) {
Timber.d("Reload because TAB_FILTER_HOME_BOOSTS changed")
reloadKeepingReadingPosition(activeAccount.id)
repository.invalidate(activeAccount.id)
}
}
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> {
@ -582,13 +535,13 @@ abstract class TimelineViewModel(
filterRemoveSelfReblogs = timeline is Timeline.Home && !filter
if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) {
Timber.d("Reload because TAB_SHOW_SOME_SELF_BOOSTS changed")
reloadKeepingReadingPosition(activeAccount.id)
repository.invalidate(activeAccount.id)
}
}
}
}
private fun handleEvent(event: Event) {
private suspend fun handleEvent(event: Event) {
when (event) {
is FavoriteEvent -> handleFavEvent(event)
is ReblogEvent -> handleReblogEvent(event)
@ -596,7 +549,7 @@ abstract class TimelineViewModel(
is PinEvent -> handlePinEvent(event)
is MuteConversationEvent -> {
Timber.d("Reload because MuteConversationEvent")
reloadKeepingReadingPosition(activeAccount.id)
repository.invalidate(activeAccount.id)
}
is UnfollowEvent -> {
if (timeline is Timeline.Home) {

View File

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

View File

@ -96,7 +96,7 @@ class ViewThreadFragment :
lifecycleScope.launch {
val statusDisplayOptions = viewModel.statusDisplayOptions.value
adapter = ThreadAdapter(pachliAccountId, statusDisplayOptions, this@ViewThreadFragment)
adapter = ThreadAdapter(statusDisplayOptions, this@ViewThreadFragment)
}
}
@ -279,8 +279,8 @@ class ViewThreadFragment :
viewModel.refresh(thisThreadsStatusId)
}
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
super.reply(pachliAccountId, viewData.actionable)
override fun onReply(viewData: StatusViewData) {
super.reply(viewData.pachliAccountId, viewData.actionable)
}
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
@ -339,11 +339,11 @@ class ViewThreadFragment :
)
}
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) {
override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
viewModel.changeExpanded(expanded, viewData)
}
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) {
override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
viewModel.changeContentShowing(isShowingContent, viewData)
}
@ -357,7 +357,7 @@ class ViewThreadFragment :
activity?.startActivityWithDefaultTransition(intent)
}
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) {
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
viewModel.changeContentCollapsed(isCollapsed, viewData)
}
@ -397,7 +397,7 @@ class ViewThreadFragment :
}
}
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {
override fun clearContentFilter(viewData: StatusViewData) {
viewModel.clearWarning(viewData)
}

View File

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

View File

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

View File

@ -33,7 +33,7 @@ class DeveloperToolsUseCase @Inject constructor(
* Clear the home timeline cache.
*/
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)
}
suspend fun translate(pachliAccountId: Long, statusViewData: StatusViewData): NetworkResult<Translation> {
return cachedTimelineRepository.translate(pachliAccountId, statusViewData)
suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
return cachedTimelineRepository.translate(statusViewData)
}
suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) {
cachedTimelineRepository.translateUndo(pachliAccountId, statusViewData)
cachedTimelineRepository.translateUndo(statusViewData)
}
suspend fun saveRefreshKey(pachliAccountId: Long, statusId: String?) {
cachedTimelineRepository.saveRefreshKey(pachliAccountId, statusId)
}
}

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

View File

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

View File

@ -1,5 +1,7 @@
<?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_height="match_parent">
@ -9,6 +11,17 @@
android:layout_gravity="center_horizontal"
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
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"

View File

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

View File

@ -1,8 +1,21 @@
<?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_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
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -17,6 +17,7 @@
package app.pachli.core.activity
import android.database.sqlite.SQLiteException
import android.util.Log
import app.pachli.core.common.di.ApplicationScope
import app.pachli.core.database.dao.LogEntryDao
@ -58,15 +59,22 @@ class LogEntryTree @Inject constructor(
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
externalScope.launch {
logEntryDao.upsert(
LogEntryEntity(
instant = Instant.now(),
priority = priority,
tag = tag,
message = message,
t = t,
),
)
try {
logEntryDao.insert(
LogEntryEntity(
instant = Instant.now(),
priority = priority,
tag = tag,
message = message,
t = t,
),
)
} catch (e: SQLiteException) {
// Might trigger a "cannot start a transaction within a transaction"
// exception here if the log is being written inside another
// transaction. Nothing to do except swallow the exception and
// continue.
}
}
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -152,18 +152,22 @@ ORDER BY LENGTH(n.serverId) DESC, n.serverId DESC
)
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(
"""
SELECT RowNum
FROM
(SELECT pachliAccountId, serverId,
(SELECT count(*) + 1
FROM notificationentity
WHERE rowid < t.rowid
ORDER BY length(serverId) DESC, serverId DESC) AS RowNum
FROM notificationentity t)
WHERE pachliAccountId = :pachliAccountId AND serverId = :notificationId;
SELECT rownum
FROM (
SELECT t1.pachliAccountId AS pachliAccountId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum
FROM NotificationEntity t1
JOIN NotificationEntity t2 ON t1.pachliAccountId = t2.pachliAccountId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId)
WHERE t1.pachliAccountId = :pachliAccountId
GROUP BY t1.serverId
ORDER BY length(t1.serverId) DESC, t1.serverId DESC
)
WHERE serverId = :notificationId
""",
)
suspend fun getNotificationRowNumber(pachliAccountId: Long, notificationId: String): Int

View File

@ -83,19 +83,25 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""",
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
/**
* All statuses for [account] in timeline ID. Used to find the correct initialKey to restore
* the user's reading position.
* @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId].
*
* @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses]
*/
@Query(
"""
SELECT serverId
FROM TimelineStatusEntity
WHERE timelineUserId = :account
ORDER BY LENGTH(serverId) DESC, serverId DESC""",
SELECT rownum
FROM (
SELECT t1.timelineUserId AS timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum
FROM TimelineStatusEntity t1
JOIN TimelineStatusEntity t2 ON t1.timelineUserId = t2.timelineUserId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId)
WHERE t1.timelineUserId = :pachliAccountId
GROUP BY t1.serverId
ORDER BY length(t1.serverId) DESC, t1.serverId DESC
)
WHERE serverId = :statusId
""",
)
abstract fun getStatusRowNumber(account: Long): List<String>
abstract suspend fun getStatusRowNumber(pachliAccountId: Long, statusId: String): Int
@Query(
"""
@ -163,23 +169,6 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe
)
abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String)
/**
* Removes everything for one account in the following tables:
*
* - TimelineStatusEntity
* - TimelineAccountEntity
* - StatusViewDataEntity
* - TranslatedStatusEntity
*
* @param accountId id of the account for which to clean tables
*/
suspend fun removeAll(accountId: Long) {
removeAllStatuses(accountId)
removeAllAccounts(accountId)
removeAllStatusViewData(accountId)
removeAllTranslatedStatus(accountId)
}
/**
* Removes all statuses from the cached **home** timeline.
*
@ -197,7 +186,7 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe
)
""",
)
abstract suspend fun removeAllStatuses(accountId: Long)
abstract suspend fun deleteAllStatusesForAccount(accountId: Long)
/**
* Deletes [TimelineAccountEntity] that are not referenced by a

View File

@ -99,12 +99,6 @@ data class AccountEntity(
val pushAuth: String = "",
val pushServerKey: String = "",
/**
* ID of the status at the top of the visible list in the home timeline when the
* user navigated away.
*/
val lastVisibleHomeTimelineStatusId: String? = null,
/** True if the connected Mastodon account is locked (has to manually approve all follow requests **/
@ColumnInfo(defaultValue = "0")
val locked: Boolean = false,

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() }
}