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:
parent
05c68f6df9
commit
7bf322c4f3
@ -35,28 +35,26 @@ open class FilterableStatusViewHolder<T : IStatusViewData>(
|
|||||||
var matchedFilter: Filter? = null
|
var matchedFilter: Filter? = null
|
||||||
|
|
||||||
override fun setupWithStatus(
|
override fun setupWithStatus(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
payloads: Any?,
|
payloads: Any?,
|
||||||
) {
|
) {
|
||||||
super.setupWithStatus(pachliAccountId, viewData, listener, statusDisplayOptions, payloads)
|
super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads)
|
||||||
setupFilterPlaceholder(pachliAccountId, viewData, listener)
|
setupFilterPlaceholder(viewData, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFilterPlaceholder(
|
private fun setupFilterPlaceholder(
|
||||||
pachliAccountId: Long,
|
viewData: T,
|
||||||
status: T,
|
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
) {
|
) {
|
||||||
if (status.contentFilterAction !== FilterAction.WARN) {
|
if (viewData.contentFilterAction !== FilterAction.WARN) {
|
||||||
matchedFilter = null
|
matchedFilter = null
|
||||||
setPlaceholderVisibility(false)
|
setPlaceholderVisibility(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
status.actionable.filtered?.find { it.filter.filterAction === NetworkFilterAction.WARN }?.let { result ->
|
viewData.actionable.filtered?.find { it.filter.filterAction === NetworkFilterAction.WARN }?.let { result ->
|
||||||
this.matchedFilter = result.filter
|
this.matchedFilter = result.filter
|
||||||
setPlaceholderVisibility(true)
|
setPlaceholderVisibility(true)
|
||||||
|
|
||||||
@ -71,10 +69,10 @@ open class FilterableStatusViewHolder<T : IStatusViewData>(
|
|||||||
binding.statusFilteredPlaceholder.statusFilterLabel.text = label
|
binding.statusFilteredPlaceholder.statusFilterLabel.text = label
|
||||||
|
|
||||||
binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener {
|
binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener {
|
||||||
listener.clearContentFilter(pachliAccountId, status)
|
listener.clearContentFilter(viewData)
|
||||||
}
|
}
|
||||||
binding.statusFilteredPlaceholder.statusFilterEditFilter.setOnClickListener {
|
binding.statusFilteredPlaceholder.statusFilterEditFilter.setOnClickListener {
|
||||||
listener.onEditFilterById(pachliAccountId, result.filter.id)
|
listener.onEditFilterById(viewData.pachliAccountId, result.filter.id)
|
||||||
}
|
}
|
||||||
} ?: {
|
} ?: {
|
||||||
matchedFilter = null
|
matchedFilter = null
|
||||||
|
@ -145,7 +145,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun setSpoilerAndContent(
|
protected fun setSpoilerAndContent(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
@ -165,7 +164,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
setContentWarningButtonText(expanded)
|
setContentWarningButtonText(expanded)
|
||||||
contentWarningButton.setOnClickListener {
|
contentWarningButton.setOnClickListener {
|
||||||
toggleExpandedState(
|
toggleExpandedState(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
true,
|
true,
|
||||||
!expanded,
|
!expanded,
|
||||||
@ -197,7 +195,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected open fun toggleExpandedState(
|
protected open fun toggleExpandedState(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
sensitive: Boolean,
|
sensitive: Boolean,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
@ -205,11 +202,10 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
) {
|
) {
|
||||||
contentWarningDescription.invalidate()
|
contentWarningDescription.invalidate()
|
||||||
listener.onExpandedChange(pachliAccountId, viewData, expanded)
|
listener.onExpandedChange(viewData, expanded)
|
||||||
setContentWarningButtonText(expanded)
|
setContentWarningButtonText(expanded)
|
||||||
setTextVisible(sensitive, expanded, viewData, statusDisplayOptions, listener)
|
setTextVisible(sensitive, expanded, viewData, statusDisplayOptions, listener)
|
||||||
setupCard(
|
setupCard(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
expanded,
|
expanded,
|
||||||
statusDisplayOptions.cardViewMode,
|
statusDisplayOptions.cardViewMode,
|
||||||
@ -466,7 +462,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun setMediaPreviews(
|
protected fun setMediaPreviews(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
attachments: List<Attachment>,
|
attachments: List<Attachment>,
|
||||||
sensitive: Boolean,
|
sensitive: Boolean,
|
||||||
@ -498,7 +493,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
} else {
|
} else {
|
||||||
imageView.foreground = null
|
imageView.foreground = null
|
||||||
}
|
}
|
||||||
setAttachmentClickListener(pachliAccountId, viewData, imageView, listener, i, attachment, true)
|
setAttachmentClickListener(viewData, imageView, listener, i, attachment, true)
|
||||||
if (sensitive) {
|
if (sensitive) {
|
||||||
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title)
|
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title)
|
||||||
} else {
|
} else {
|
||||||
@ -509,13 +504,13 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
descriptionIndicator.visibility =
|
descriptionIndicator.visibility =
|
||||||
if (hasDescription && showingContent) View.VISIBLE else View.GONE
|
if (hasDescription && showingContent) View.VISIBLE else View.GONE
|
||||||
sensitiveMediaShow.setOnClickListener { v: View ->
|
sensitiveMediaShow.setOnClickListener { v: View ->
|
||||||
listener.onContentHiddenChange(pachliAccountId, viewData, false)
|
listener.onContentHiddenChange(viewData, false)
|
||||||
v.visibility = View.GONE
|
v.visibility = View.GONE
|
||||||
sensitiveMediaWarning.visibility = View.VISIBLE
|
sensitiveMediaWarning.visibility = View.VISIBLE
|
||||||
descriptionIndicator.visibility = View.GONE
|
descriptionIndicator.visibility = View.GONE
|
||||||
}
|
}
|
||||||
sensitiveMediaWarning.setOnClickListener { v: View ->
|
sensitiveMediaWarning.setOnClickListener { v: View ->
|
||||||
listener.onContentHiddenChange(pachliAccountId, viewData, true)
|
listener.onContentHiddenChange(viewData, true)
|
||||||
v.visibility = View.GONE
|
v.visibility = View.GONE
|
||||||
sensitiveMediaShow.visibility = View.VISIBLE
|
sensitiveMediaShow.visibility = View.VISIBLE
|
||||||
descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
|
descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
|
||||||
@ -530,7 +525,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun setMediaLabel(
|
protected fun setMediaLabel(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
attachments: List<Attachment>,
|
attachments: List<Attachment>,
|
||||||
sensitive: Boolean,
|
sensitive: Boolean,
|
||||||
@ -548,7 +542,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
// Set the icon next to the label.
|
// Set the icon next to the label.
|
||||||
val drawableId = attachments[0].iconResource()
|
val drawableId = attachments[0].iconResource()
|
||||||
mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0)
|
mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0)
|
||||||
setAttachmentClickListener(pachliAccountId, viewData, mediaLabel, listener, i, attachment, false)
|
setAttachmentClickListener(viewData, mediaLabel, listener, i, attachment, false)
|
||||||
} else {
|
} else {
|
||||||
mediaLabel.visibility = View.GONE
|
mediaLabel.visibility = View.GONE
|
||||||
}
|
}
|
||||||
@ -556,7 +550,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setAttachmentClickListener(
|
private fun setAttachmentClickListener(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
view: View,
|
view: View,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
@ -566,7 +559,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
) {
|
) {
|
||||||
view.setOnClickListener { v: View? ->
|
view.setOnClickListener { v: View? ->
|
||||||
if (sensitiveMediaWarning.visibility == View.VISIBLE) {
|
if (sensitiveMediaWarning.visibility == View.VISIBLE) {
|
||||||
listener.onContentHiddenChange(pachliAccountId, viewData, true)
|
listener.onContentHiddenChange(viewData, true)
|
||||||
} else {
|
} else {
|
||||||
listener.onViewMedia(viewData, index, if (animateTransition) v else null)
|
listener.onViewMedia(viewData, index, if (animateTransition) v else null)
|
||||||
}
|
}
|
||||||
@ -584,7 +577,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun setupButtons(
|
protected fun setupButtons(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
accountId: String,
|
accountId: String,
|
||||||
@ -594,7 +586,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
avatar.setOnClickListener(profileButtonClickListener)
|
avatar.setOnClickListener(profileButtonClickListener)
|
||||||
displayName.setOnClickListener(profileButtonClickListener)
|
displayName.setOnClickListener(profileButtonClickListener)
|
||||||
replyButton.setOnClickListener {
|
replyButton.setOnClickListener {
|
||||||
listener.onReply(pachliAccountId, viewData)
|
listener.onReply(viewData)
|
||||||
}
|
}
|
||||||
reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean ->
|
reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean ->
|
||||||
// return true to play animation
|
// return true to play animation
|
||||||
@ -683,7 +675,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
open fun setupWithStatus(
|
open fun setupWithStatus(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
@ -715,7 +706,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
val sensitive = actionable.sensitive
|
val sensitive = actionable.sensitive
|
||||||
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
|
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
|
||||||
setMediaPreviews(
|
setMediaPreviews(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
attachments,
|
attachments,
|
||||||
sensitive,
|
sensitive,
|
||||||
@ -731,13 +721,12 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
mediaLabel.visibility = View.GONE
|
mediaLabel.visibility = View.GONE
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMediaLabel(pachliAccountId, viewData, attachments, sensitive, listener, viewData.isShowingContent)
|
setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent)
|
||||||
// Hide all unused views.
|
// Hide all unused views.
|
||||||
mediaPreview.visibility = View.GONE
|
mediaPreview.visibility = View.GONE
|
||||||
hideSensitiveMediaWarning()
|
hideSensitiveMediaWarning()
|
||||||
}
|
}
|
||||||
setupCard(
|
setupCard(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
viewData.isExpanded,
|
viewData.isExpanded,
|
||||||
statusDisplayOptions.cardViewMode,
|
statusDisplayOptions.cardViewMode,
|
||||||
@ -745,14 +734,13 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
listener,
|
listener,
|
||||||
)
|
)
|
||||||
setupButtons(
|
setupButtons(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
listener,
|
listener,
|
||||||
actionable.account.id,
|
actionable.account.id,
|
||||||
statusDisplayOptions,
|
statusDisplayOptions,
|
||||||
)
|
)
|
||||||
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility)
|
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility)
|
||||||
setSpoilerAndContent(pachliAccountId, viewData, statusDisplayOptions, listener)
|
setSpoilerAndContent(viewData, statusDisplayOptions, listener)
|
||||||
setContentDescriptionForStatus(viewData, statusDisplayOptions)
|
setContentDescriptionForStatus(viewData, statusDisplayOptions)
|
||||||
|
|
||||||
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
|
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
|
||||||
@ -876,7 +864,6 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun setupCard(
|
protected fun setupCard(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
cardViewMode: CardViewMode,
|
cardViewMode: CardViewMode,
|
||||||
@ -898,13 +885,13 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||||||
cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions, false) { card, target ->
|
cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions, false) { card, target ->
|
||||||
if (target == PreviewCardView.Target.BYLINE) {
|
if (target == PreviewCardView.Target.BYLINE) {
|
||||||
card.authors?.firstOrNull()?.account?.id?.let {
|
card.authors?.firstOrNull()?.account?.id?.let {
|
||||||
context.startActivity(AccountActivityIntent(context, pachliAccountId, it))
|
context.startActivity(AccountActivityIntent(context, viewData.pachliAccountId, it))
|
||||||
}
|
}
|
||||||
return@bind
|
return@bind
|
||||||
}
|
}
|
||||||
|
|
||||||
if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) {
|
if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) {
|
||||||
context.startActivity(ViewMediaActivityIntent(context, pachliAccountId, viewData.actionable.account.username, card.embedUrl))
|
context.startActivity(ViewMediaActivityIntent(context, viewData.pachliAccountId, viewData.actionable.account.username, card.embedUrl))
|
||||||
return@bind
|
return@bind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,26 +106,24 @@ class StatusDetailedViewHolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun setupWithStatus(
|
override fun setupWithStatus(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: StatusViewData,
|
viewData: StatusViewData,
|
||||||
listener: StatusActionListener<StatusViewData>,
|
listener: StatusActionListener<StatusViewData>,
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
payloads: Any?,
|
payloads: Any?,
|
||||||
) {
|
) {
|
||||||
// We never collapse statuses in the detail view
|
// We never collapse statuses in the detail view
|
||||||
val uncollapsedStatus =
|
val uncollapsedViewdata =
|
||||||
if (viewData.isCollapsible && viewData.isCollapsed) viewData.copy(isCollapsed = false) else viewData
|
if (viewData.isCollapsible && viewData.isCollapsed) viewData.copy(isCollapsed = false) else viewData
|
||||||
super.setupWithStatus(pachliAccountId, uncollapsedStatus, listener, statusDisplayOptions, payloads)
|
super.setupWithStatus(uncollapsedViewdata, listener, statusDisplayOptions, payloads)
|
||||||
setupCard(
|
setupCard(
|
||||||
pachliAccountId,
|
uncollapsedViewdata,
|
||||||
uncollapsedStatus,
|
|
||||||
viewData.isExpanded,
|
viewData.isExpanded,
|
||||||
CardViewMode.FULL_WIDTH,
|
CardViewMode.FULL_WIDTH,
|
||||||
statusDisplayOptions,
|
statusDisplayOptions,
|
||||||
listener,
|
listener,
|
||||||
) // Always show card for detailed status
|
) // Always show card for detailed status
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedStatus.actionable
|
val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedViewdata.actionable
|
||||||
if (!statusDisplayOptions.hideStats) {
|
if (!statusDisplayOptions.hideStats) {
|
||||||
setReblogAndFavCount(viewData, reblogsCount, favouritesCount, listener)
|
setReblogAndFavCount(viewData, reblogsCount, favouritesCount, listener)
|
||||||
} else {
|
} else {
|
||||||
|
@ -41,7 +41,6 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||||||
) : StatusBaseViewHolder<T>(root ?: binding.root) {
|
) : StatusBaseViewHolder<T>(root ?: binding.root) {
|
||||||
|
|
||||||
override fun setupWithStatus(
|
override fun setupWithStatus(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
@ -50,7 +49,7 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
val sensitive = !TextUtils.isEmpty(viewData.actionable.spoilerText)
|
val sensitive = !TextUtils.isEmpty(viewData.actionable.spoilerText)
|
||||||
val expanded = viewData.isExpanded
|
val expanded = viewData.isExpanded
|
||||||
setupCollapsedState(pachliAccountId, viewData, sensitive, expanded, listener)
|
setupCollapsedState(viewData, sensitive, expanded, listener)
|
||||||
val reblogging = viewData.rebloggingStatus
|
val reblogging = viewData.rebloggingStatus
|
||||||
if (reblogging == null || viewData.contentFilterAction === FilterAction.WARN) {
|
if (reblogging == null || viewData.contentFilterAction === FilterAction.WARN) {
|
||||||
statusInfo.hide()
|
statusInfo.hide()
|
||||||
@ -70,7 +69,7 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||||||
statusFavouritesCount.visible(statusDisplayOptions.showStatsInline)
|
statusFavouritesCount.visible(statusDisplayOptions.showStatsInline)
|
||||||
setFavouritedCount(viewData.actionable.favouritesCount)
|
setFavouritedCount(viewData.actionable.favouritesCount)
|
||||||
setReblogsCount(viewData.actionable.reblogsCount)
|
setReblogsCount(viewData.actionable.reblogsCount)
|
||||||
super.setupWithStatus(pachliAccountId, viewData, listener, statusDisplayOptions, payloads)
|
super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setRebloggedByDisplayName(
|
private fun setRebloggedByDisplayName(
|
||||||
@ -108,7 +107,6 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCollapsedState(
|
private fun setupCollapsedState(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
sensitive: Boolean,
|
sensitive: Boolean,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
@ -117,7 +115,7 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||||||
/* input filter for TextViews have to be set before text */
|
/* input filter for TextViews have to be set before text */
|
||||||
if (viewData.isCollapsible && (!sensitive || expanded)) {
|
if (viewData.isCollapsible && (!sensitive || expanded)) {
|
||||||
buttonToggleContent.setOnClickListener {
|
buttonToggleContent.setOnClickListener {
|
||||||
listener.onContentCollapsedChange(pachliAccountId, viewData, !viewData.isCollapsed)
|
listener.onContentCollapsedChange(viewData, !viewData.isCollapsed)
|
||||||
}
|
}
|
||||||
buttonToggleContent.show()
|
buttonToggleContent.show()
|
||||||
if (viewData.isCollapsed) {
|
if (viewData.isCollapsed) {
|
||||||
@ -139,15 +137,14 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun toggleExpandedState(
|
override fun toggleExpandedState(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: T,
|
viewData: T,
|
||||||
sensitive: Boolean,
|
sensitive: Boolean,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
listener: StatusActionListener<T>,
|
listener: StatusActionListener<T>,
|
||||||
) {
|
) {
|
||||||
setupCollapsedState(pachliAccountId, viewData, sensitive, expanded, listener)
|
setupCollapsedState(viewData, sensitive, expanded, listener)
|
||||||
super.toggleExpandedState(pachliAccountId, viewData, sensitive, expanded, statusDisplayOptions, listener)
|
super.toggleExpandedState(viewData, sensitive, expanded, statusDisplayOptions, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -26,7 +26,6 @@ import app.pachli.core.data.model.StatusDisplayOptions
|
|||||||
import app.pachli.interfaces.StatusActionListener
|
import app.pachli.interfaces.StatusActionListener
|
||||||
|
|
||||||
class ConversationAdapter(
|
class ConversationAdapter(
|
||||||
private val pachliAccountId: Long,
|
|
||||||
private var statusDisplayOptions: StatusDisplayOptions,
|
private var statusDisplayOptions: StatusDisplayOptions,
|
||||||
private val listener: StatusActionListener<ConversationViewData>,
|
private val listener: StatusActionListener<ConversationViewData>,
|
||||||
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
||||||
@ -54,7 +53,7 @@ class ConversationAdapter(
|
|||||||
payloads: List<Any>,
|
payloads: List<Any>,
|
||||||
) {
|
) {
|
||||||
getItem(position)?.let { conversationViewData ->
|
getItem(position)?.let { conversationViewData ->
|
||||||
holder.setupWithConversation(pachliAccountId, conversationViewData, payloads.firstOrNull())
|
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,12 +35,12 @@ data class ConversationViewData(
|
|||||||
val lastStatus: StatusViewData,
|
val lastStatus: StatusViewData,
|
||||||
) : IStatusViewData by lastStatus {
|
) : IStatusViewData by lastStatus {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(conversationEntity: ConversationEntity) = ConversationViewData(
|
fun from(pachliAccountId: Long, conversationEntity: ConversationEntity) = ConversationViewData(
|
||||||
id = conversationEntity.id,
|
id = conversationEntity.id,
|
||||||
order = conversationEntity.order,
|
order = conversationEntity.order,
|
||||||
accounts = conversationEntity.accounts,
|
accounts = conversationEntity.accounts,
|
||||||
unread = conversationEntity.unread,
|
unread = conversationEntity.unread,
|
||||||
lastStatus = StatusViewData.from(conversationEntity.lastStatus),
|
lastStatus = StatusViewData.from(pachliAccountId, conversationEntity.lastStatus),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,13 +46,12 @@ class ConversationViewHolder internal constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun setupWithConversation(
|
fun setupWithConversation(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: ConversationViewData,
|
viewData: ConversationViewData,
|
||||||
payloads: Any?,
|
payloads: Any?,
|
||||||
) {
|
) {
|
||||||
val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = viewData.status
|
val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = viewData.status
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
setupCollapsedState(pachliAccountId, viewData, listener)
|
setupCollapsedState(viewData, listener)
|
||||||
setDisplayName(account.name, account.emojis, statusDisplayOptions)
|
setDisplayName(account.name, account.emojis, statusDisplayOptions)
|
||||||
setUsername(account.username)
|
setUsername(account.username)
|
||||||
setMetaData(viewData, statusDisplayOptions, listener)
|
setMetaData(viewData, statusDisplayOptions, listener)
|
||||||
@ -61,7 +60,6 @@ class ConversationViewHolder internal constructor(
|
|||||||
setBookmarked(bookmarked)
|
setBookmarked(bookmarked)
|
||||||
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
|
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
|
||||||
setMediaPreviews(
|
setMediaPreviews(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
attachments,
|
attachments,
|
||||||
sensitive,
|
sensitive,
|
||||||
@ -77,19 +75,18 @@ class ConversationViewHolder internal constructor(
|
|||||||
mediaLabel.visibility = View.GONE
|
mediaLabel.visibility = View.GONE
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMediaLabel(pachliAccountId, viewData, attachments, sensitive, listener, viewData.isShowingContent)
|
setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent)
|
||||||
// Hide all unused views.
|
// Hide all unused views.
|
||||||
mediaPreview.visibility = View.GONE
|
mediaPreview.visibility = View.GONE
|
||||||
hideSensitiveMediaWarning()
|
hideSensitiveMediaWarning()
|
||||||
}
|
}
|
||||||
setupButtons(
|
setupButtons(
|
||||||
pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
listener,
|
listener,
|
||||||
account.id,
|
account.id,
|
||||||
statusDisplayOptions,
|
statusDisplayOptions,
|
||||||
)
|
)
|
||||||
setSpoilerAndContent(pachliAccountId, viewData, statusDisplayOptions, listener)
|
setSpoilerAndContent(viewData, statusDisplayOptions, listener)
|
||||||
setConversationName(viewData.accounts)
|
setConversationName(viewData.accounts)
|
||||||
setAvatars(viewData.accounts)
|
setAvatars(viewData.accounts)
|
||||||
} else {
|
} else {
|
||||||
@ -139,14 +136,13 @@ class ConversationViewHolder internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCollapsedState(
|
private fun setupCollapsedState(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: ConversationViewData,
|
viewData: ConversationViewData,
|
||||||
listener: StatusActionListener<ConversationViewData>,
|
listener: StatusActionListener<ConversationViewData>,
|
||||||
) {
|
) {
|
||||||
/* input filter for TextViews have to be set before text */
|
/* input filter for TextViews have to be set before text */
|
||||||
if (viewData.isCollapsible && (viewData.isExpanded || TextUtils.isEmpty(viewData.spoilerText))) {
|
if (viewData.isCollapsible && (viewData.isExpanded || TextUtils.isEmpty(viewData.spoilerText))) {
|
||||||
contentCollapseButton.setOnClickListener {
|
contentCollapseButton.setOnClickListener {
|
||||||
listener.onContentCollapsedChange(pachliAccountId, viewData, !viewData.isCollapsed)
|
listener.onContentCollapsedChange(viewData, !viewData.isCollapsed)
|
||||||
}
|
}
|
||||||
contentCollapseButton.show()
|
contentCollapseButton.show()
|
||||||
if (viewData.isCollapsed) {
|
if (viewData.isCollapsed) {
|
||||||
|
@ -114,7 +114,7 @@ class ConversationsFragment :
|
|||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val statusDisplayOptions = statusDisplayOptionsRepository.flow.value
|
val statusDisplayOptions = statusDisplayOptionsRepository.flow.value
|
||||||
|
|
||||||
adapter = ConversationAdapter(pachliAccountId, statusDisplayOptions, this@ConversationsFragment)
|
adapter = ConversationAdapter(statusDisplayOptions, this@ConversationsFragment)
|
||||||
|
|
||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
|
|
||||||
@ -322,16 +322,16 @@ class ConversationsFragment :
|
|||||||
// there are no reblogs in conversations
|
// there are no reblogs in conversations
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(pachliAccountId: Long, viewData: ConversationViewData, expanded: Boolean) {
|
override fun onExpandedChange(viewData: ConversationViewData, expanded: Boolean) {
|
||||||
viewModel.expandHiddenStatus(pachliAccountId, expanded, viewData.lastStatus.id)
|
viewModel.expandHiddenStatus(viewData.pachliAccountId, expanded, viewData.lastStatus.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentHiddenChange(pachliAccountId: Long, viewData: ConversationViewData, isShowingContent: Boolean) {
|
override fun onContentHiddenChange(viewData: ConversationViewData, isShowingContent: Boolean) {
|
||||||
viewModel.showContent(pachliAccountId, isShowingContent, viewData.lastStatus.id)
|
viewModel.showContent(viewData.pachliAccountId, isShowingContent, viewData.lastStatus.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: ConversationViewData, isCollapsed: Boolean) {
|
override fun onContentCollapsedChange(viewData: ConversationViewData, isCollapsed: Boolean) {
|
||||||
viewModel.collapseLongStatus(pachliAccountId, isCollapsed, viewData.lastStatus.id)
|
viewModel.collapseLongStatus(viewData.pachliAccountId, isCollapsed, viewData.lastStatus.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewAccount(id: String) {
|
override fun onViewAccount(id: String) {
|
||||||
@ -348,15 +348,15 @@ class ConversationsFragment :
|
|||||||
// not needed
|
// not needed
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(pachliAccountId: Long, viewData: ConversationViewData) {
|
override fun onReply(viewData: ConversationViewData) {
|
||||||
reply(pachliAccountId, viewData.lastStatus.actionable)
|
reply(viewData.pachliAccountId, viewData.lastStatus.actionable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List<Int>) {
|
override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List<Int>) {
|
||||||
viewModel.voteInPoll(choices, viewData.lastStatus.actionableId, poll.id)
|
viewModel.voteInPoll(choices, viewData.lastStatus.actionableId, poll.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearContentFilter(pachliAccountId: Long, viewData: ConversationViewData) {
|
override fun clearContentFilter(viewData: ConversationViewData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters don't apply in conversations
|
// Filters don't apply in conversations
|
||||||
|
@ -24,20 +24,24 @@ import androidx.paging.PagingConfig
|
|||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import androidx.paging.map
|
import androidx.paging.map
|
||||||
import app.pachli.core.data.repository.AccountManager
|
import app.pachli.core.data.repository.AccountManager
|
||||||
|
import app.pachli.core.data.repository.Loadable
|
||||||
import app.pachli.core.database.Converters
|
import app.pachli.core.database.Converters
|
||||||
import app.pachli.core.database.dao.ConversationsDao
|
import app.pachli.core.database.dao.ConversationsDao
|
||||||
import app.pachli.core.database.di.TransactionProvider
|
import app.pachli.core.database.di.TransactionProvider
|
||||||
|
import app.pachli.core.database.model.AccountEntity
|
||||||
import app.pachli.core.network.retrofit.MastodonApi
|
import app.pachli.core.network.retrofit.MastodonApi
|
||||||
import app.pachli.core.preferences.PrefKeys
|
import app.pachli.core.preferences.PrefKeys
|
||||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||||
import app.pachli.usecase.TimelineCases
|
import app.pachli.usecase.TimelineCases
|
||||||
import app.pachli.util.EmptyPagingSource
|
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -55,26 +59,27 @@ class ConversationsViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
val conversationFlow = Pager(
|
val conversationFlow = accountManager.activeAccountFlow
|
||||||
config = PagingConfig(pageSize = 30),
|
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||||
remoteMediator = ConversationsRemoteMediator(
|
.mapNotNull { it.data }
|
||||||
api,
|
.flatMapLatest { account ->
|
||||||
transactionProvider,
|
Pager(
|
||||||
conversationsDao,
|
config = PagingConfig(pageSize = 30),
|
||||||
accountManager,
|
remoteMediator = ConversationsRemoteMediator(
|
||||||
),
|
api,
|
||||||
pagingSourceFactory = {
|
transactionProvider,
|
||||||
val activeAccount = accountManager.activeAccount
|
conversationsDao,
|
||||||
if (activeAccount == null) {
|
accountManager,
|
||||||
EmptyPagingSource()
|
),
|
||||||
} else {
|
pagingSourceFactory = {
|
||||||
conversationsDao.conversationsForAccount(activeAccount.id)
|
conversationsDao.conversationsForAccount(account.id)
|
||||||
}
|
},
|
||||||
},
|
).flow
|
||||||
)
|
.map { pagingData ->
|
||||||
.flow
|
pagingData.map { conversation ->
|
||||||
.map { pagingData ->
|
ConversationViewData.from(account.id, conversation)
|
||||||
pagingData.map { conversation -> ConversationViewData.from(conversation) }
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
@ -37,7 +37,6 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.PagingDataAdapter
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@ -92,9 +91,9 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
|||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.runningFold
|
|
||||||
import kotlinx.coroutines.flow.take
|
import kotlinx.coroutines.flow.take
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import postPrepend
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@ -117,7 +116,12 @@ class NotificationsFragment :
|
|||||||
|
|
||||||
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
||||||
|
|
||||||
private lateinit var adapter: NotificationsPagingAdapter
|
private val adapter = NotificationsPagingAdapter(
|
||||||
|
notificationDiffCallback,
|
||||||
|
statusActionListener = this,
|
||||||
|
notificationActionListener = this,
|
||||||
|
accountActionListener = this,
|
||||||
|
)
|
||||||
|
|
||||||
private lateinit var layoutManager: LinearLayoutManager
|
private lateinit var layoutManager: LinearLayoutManager
|
||||||
|
|
||||||
@ -191,14 +195,6 @@ class NotificationsFragment :
|
|||||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
|
||||||
false
|
false
|
||||||
|
|
||||||
adapter = NotificationsPagingAdapter(
|
|
||||||
notificationDiffCallback,
|
|
||||||
statusActionListener = this@NotificationsFragment,
|
|
||||||
notificationActionListener = this@NotificationsFragment,
|
|
||||||
accountActionListener = this@NotificationsFragment,
|
|
||||||
statusDisplayOptions = viewModel.statusDisplayOptions.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
binding.recyclerView.setAccessibilityDelegateCompat(
|
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||||
ListStatusAccessibilityDelegate(pachliAccountId, binding.recyclerView, this@NotificationsFragment) { pos: Int ->
|
ListStatusAccessibilityDelegate(pachliAccountId, binding.recyclerView, this@NotificationsFragment) { pos: Int ->
|
||||||
if (pos in 0 until adapter.itemCount) {
|
if (pos in 0 until adapter.itemCount) {
|
||||||
@ -211,11 +207,7 @@ class NotificationsFragment :
|
|||||||
|
|
||||||
val saveIdListener = object : RecyclerView.OnScrollListener() {
|
val saveIdListener = object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||||
if (newState != SCROLL_STATE_IDLE) return
|
if (newState == SCROLL_STATE_IDLE) saveVisibleId()
|
||||||
|
|
||||||
// Save the ID of the first notification visible in the list, so the user's
|
|
||||||
// reading position is always restorable.
|
|
||||||
saveVisibleId()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.recyclerView.addOnScrollListener(saveIdListener)
|
binding.recyclerView.addOnScrollListener(saveIdListener)
|
||||||
@ -239,62 +231,60 @@ class NotificationsFragment :
|
|||||||
// Update status display from statusDisplayOptions. If the new options request
|
// Update status display from statusDisplayOptions. If the new options request
|
||||||
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
|
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
|
||||||
launch {
|
launch {
|
||||||
viewModel.statusDisplayOptions
|
viewModel.statusDisplayOptions.collectLatest {
|
||||||
.collectLatest {
|
// NOTE this this also triggered (emitted?) on resume.
|
||||||
// NOTE this this also triggered (emitted?) on resume.
|
|
||||||
|
|
||||||
adapter.statusDisplayOptions = it
|
adapter.statusDisplayOptions = it
|
||||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
|
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
|
||||||
|
|
||||||
if (!it.useAbsoluteTime) {
|
if (!it.useAbsoluteTime) {
|
||||||
updateTimestampFlow.collect()
|
updateTimestampFlow.collect()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the UI from the loadState
|
// Update the UI from the loadState
|
||||||
adapter.loadStateFlow
|
adapter.loadStateFlow.collect { loadState ->
|
||||||
.collect { loadState ->
|
when (loadState.refresh) {
|
||||||
when (loadState.refresh) {
|
is LoadState.Error -> {
|
||||||
is LoadState.Error -> {
|
binding.progressIndicator.hide()
|
||||||
|
binding.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.progressIndicator.hide()
|
||||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error) {
|
|
||||||
adapter.retry()
|
|
||||||
}
|
|
||||||
binding.recyclerView.hide()
|
|
||||||
binding.statusView.show()
|
|
||||||
binding.swipeRefreshLayout.isRefreshing = false
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
}
|
if (adapter.itemCount == 0) {
|
||||||
|
binding.statusView.setup(BackgroundMessage.Empty())
|
||||||
LoadState.Loading -> {
|
binding.recyclerView.hide()
|
||||||
/* nothing */
|
binding.statusView.show()
|
||||||
binding.statusView.hide()
|
} else {
|
||||||
binding.progressIndicator.show()
|
binding.statusView.hide()
|
||||||
}
|
binding.recyclerView.show()
|
||||||
|
|
||||||
is LoadState.NotLoading -> {
|
|
||||||
// Might still be loading if source.refresh is Loading, so only update
|
|
||||||
// the UI when loading is completely quiet.
|
|
||||||
if (loadState.source.refresh !is LoadState.Loading) {
|
|
||||||
binding.progressIndicator.hide()
|
|
||||||
binding.swipeRefreshLayout.isRefreshing = false
|
|
||||||
if (adapter.itemCount == 0) {
|
|
||||||
binding.statusView.setup(BackgroundMessage.Empty())
|
|
||||||
binding.recyclerView.hide()
|
|
||||||
binding.statusView.show()
|
|
||||||
} else {
|
|
||||||
binding.statusView.hide()
|
|
||||||
binding.recyclerView.show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
|
private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
|
||||||
// Show errors from the view model as snack bars.
|
// Show errors from the view model as snack bars.
|
||||||
//
|
//
|
||||||
// Errors are shown:
|
// Errors are shown:
|
||||||
@ -345,9 +335,7 @@ class NotificationsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (uiSuccess) {
|
when (uiSuccess) {
|
||||||
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> viewLifecycleOwner.lifecycleScope.launch {
|
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> refreshAdapterAndScrollToVisibleId()
|
||||||
refreshAdapterAndScrollToVisibleId()
|
|
||||||
}
|
|
||||||
|
|
||||||
is UiSuccess.LoadNewest -> {
|
is UiSuccess.LoadNewest -> {
|
||||||
// Scroll to the top when prepending completes.
|
// Scroll to the top when prepending completes.
|
||||||
@ -368,7 +356,7 @@ class NotificationsFragment :
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the adapter, waits for the first page to be updated, and scrolls the
|
* Refreshes the adapter, waits for the first page to be updated, and scrolls the
|
||||||
* the recyclerview to the first notification that was visible before the refresh.
|
* recyclerview to the first notification that was visible before the refresh.
|
||||||
*
|
*
|
||||||
* This ensures the user's position is not lost during adapter refreshes.
|
* This ensures the user's position is not lost during adapter refreshes.
|
||||||
*/
|
*/
|
||||||
@ -385,23 +373,6 @@ class NotificationsFragment :
|
|||||||
adapter.refresh()
|
adapter.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs [action] after the next prepend operation completes on the adapter.
|
|
||||||
*
|
|
||||||
* A prepend operation is complete when the adapter's prepend [LoadState] transitions
|
|
||||||
* from [LoadState.Loading] to [LoadState.NotLoading].
|
|
||||||
*/
|
|
||||||
private suspend fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.postPrepend(
|
|
||||||
action: () -> Unit,
|
|
||||||
) {
|
|
||||||
val initial: Pair<LoadState?, LoadState?> = Pair(null, null)
|
|
||||||
loadStateFlow
|
|
||||||
.runningFold(initial) { prev, next -> prev.second to next.prepend }
|
|
||||||
.filter { it.first is LoadState.Loading && it.second is LoadState.NotLoading }
|
|
||||||
.take(1)
|
|
||||||
.collect { action() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
menuInflater.inflate(R.menu.fragment_notifications, menu)
|
menuInflater.inflate(R.menu.fragment_notifications, menu)
|
||||||
menu.findItem(R.id.action_refresh)?.apply {
|
menu.findItem(R.id.action_refresh)?.apply {
|
||||||
@ -450,9 +421,7 @@ class NotificationsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.swipeRefreshLayout.isRefreshing = false
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
refreshAdapterAndScrollToVisibleId()
|
||||||
refreshAdapterAndScrollToVisibleId()
|
|
||||||
}
|
|
||||||
clearNotificationsForAccount(requireContext(), pachliAccountId)
|
clearNotificationsForAccount(requireContext(), pachliAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,20 +452,18 @@ class NotificationsFragment :
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
if (::adapter.isInitialized) {
|
val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
|
||||||
val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
|
val wasEnabled = talkBackWasEnabled
|
||||||
val wasEnabled = talkBackWasEnabled
|
talkBackWasEnabled = a11yManager?.isEnabled == true
|
||||||
talkBackWasEnabled = a11yManager?.isEnabled == true
|
if (talkBackWasEnabled && !wasEnabled) {
|
||||||
if (talkBackWasEnabled && !wasEnabled) {
|
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearNotificationsForAccount(requireContext(), pachliAccountId)
|
clearNotificationsForAccount(requireContext(), pachliAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(pachliAccountId: Long, viewData: NotificationViewData) {
|
override fun onReply(viewData: NotificationViewData) {
|
||||||
super.reply(pachliAccountId, viewData.statusViewData!!.actionable)
|
super.reply(viewData.pachliAccountId, viewData.statusViewData!!.actionable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReblog(viewData: NotificationViewData, reblog: Boolean) {
|
override fun onReblog(viewData: NotificationViewData, reblog: Boolean) {
|
||||||
@ -546,31 +513,23 @@ class NotificationsFragment :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is required by the interface StatusActionListener; the interface's ViewData T
|
|
||||||
// doesn't include pachliAccountId as a property.
|
|
||||||
// TODO: Update StatusActionListener.onExpandedChange to include the account ID.
|
|
||||||
override fun onExpandedChange(pachliAccountId: Long, viewData: NotificationViewData, expanded: Boolean) {
|
|
||||||
onExpandedChange(viewData, expanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onContentHiddenChange(
|
override fun onContentHiddenChange(
|
||||||
pachliAccountId: Long,
|
|
||||||
viewData: NotificationViewData,
|
viewData: NotificationViewData,
|
||||||
isShowingContent: Boolean,
|
isShowingContent: Boolean,
|
||||||
) {
|
) {
|
||||||
viewModel.accept(
|
viewModel.accept(
|
||||||
InfallibleUiAction.SetShowingContent(
|
InfallibleUiAction.SetShowingContent(
|
||||||
pachliAccountId,
|
viewData.pachliAccountId,
|
||||||
viewData.statusViewData!!,
|
viewData.statusViewData!!,
|
||||||
isShowingContent,
|
isShowingContent,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: NotificationViewData, isCollapsed: Boolean) {
|
override fun onContentCollapsedChange(viewData: NotificationViewData, isCollapsed: Boolean) {
|
||||||
viewModel.accept(
|
viewModel.accept(
|
||||||
InfallibleUiAction.SetContentCollapsed(
|
InfallibleUiAction.SetContentCollapsed(
|
||||||
pachliAccountId,
|
viewData.pachliAccountId,
|
||||||
viewData.statusViewData!!,
|
viewData.statusViewData!!,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
),
|
),
|
||||||
@ -585,17 +544,17 @@ class NotificationsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, viewData: NotificationViewData) {
|
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, viewData: NotificationViewData) {
|
||||||
onContentCollapsedChange(viewData.pachliAccountId, viewData, isCollapsed)
|
onContentCollapsedChange(viewData, isCollapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearContentFilter(pachliAccountId: Long, viewData: NotificationViewData) {
|
override fun clearContentFilter(viewData: NotificationViewData) {
|
||||||
viewModel.accept(InfallibleUiAction.ClearContentFilter(pachliAccountId, viewData.id))
|
viewModel.accept(InfallibleUiAction.ClearContentFilter(viewData.pachliAccountId, viewData.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearAccountFilter(viewData: NotificationViewData) {
|
override fun clearAccountFilter(viewData: NotificationViewData) {
|
||||||
viewModel.accept(
|
viewModel.accept(
|
||||||
InfallibleUiAction.OverrideAccountFilter(
|
InfallibleUiAction.OverrideAccountFilter(
|
||||||
pachliAccountId,
|
viewData.pachliAccountId,
|
||||||
viewData.id,
|
viewData.id,
|
||||||
viewData.accountFilterDecision,
|
viewData.accountFilterDecision,
|
||||||
),
|
),
|
||||||
@ -629,15 +588,11 @@ class NotificationsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
refreshAdapterAndScrollToVisibleId()
|
||||||
refreshAdapterAndScrollToVisibleId()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBlock(block: Boolean, id: String, position: Int) {
|
override fun onBlock(block: Boolean, id: String, position: Int) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
refreshAdapterAndScrollToVisibleId()
|
||||||
refreshAdapterAndScrollToVisibleId()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
|
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
|
||||||
|
@ -148,7 +148,7 @@ class NotificationsPagingAdapter(
|
|||||||
private val statusActionListener: StatusActionListener<NotificationViewData>,
|
private val statusActionListener: StatusActionListener<NotificationViewData>,
|
||||||
private val notificationActionListener: NotificationActionListener,
|
private val notificationActionListener: NotificationActionListener,
|
||||||
private val accountActionListener: AccountActionListener,
|
private val accountActionListener: AccountActionListener,
|
||||||
var statusDisplayOptions: StatusDisplayOptions,
|
var statusDisplayOptions: StatusDisplayOptions = StatusDisplayOptions(),
|
||||||
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
|
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
|
||||||
|
|
||||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||||
|
@ -367,7 +367,7 @@ class NotificationsViewModel @AssistedInject constructor(
|
|||||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||||
@Assisted val pachliAccountId: Long,
|
@Assisted val pachliAccountId: Long,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId)
|
private val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId)
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
|
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
|
||||||
|
|
||||||
|
@ -46,7 +46,6 @@ internal class StatusViewHolder(
|
|||||||
showStatusContent(true)
|
showStatusContent(true)
|
||||||
}
|
}
|
||||||
setupWithStatus(
|
setupWithStatus(
|
||||||
viewData.pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
statusActionListener,
|
statusActionListener,
|
||||||
statusDisplayOptions,
|
statusDisplayOptions,
|
||||||
@ -81,7 +80,6 @@ class FilterableStatusViewHolder(
|
|||||||
showStatusContent(true)
|
showStatusContent(true)
|
||||||
}
|
}
|
||||||
setupWithStatus(
|
setupWithStatus(
|
||||||
viewData.pachliAccountId,
|
|
||||||
viewData,
|
viewData,
|
||||||
statusActionListener,
|
statusActionListener,
|
||||||
statusDisplayOptions,
|
statusDisplayOptions,
|
||||||
|
@ -27,7 +27,10 @@ import androidx.paging.map
|
|||||||
import app.pachli.components.report.adapter.StatusesPagingSource
|
import app.pachli.components.report.adapter.StatusesPagingSource
|
||||||
import app.pachli.components.report.model.StatusViewState
|
import app.pachli.components.report.model.StatusViewState
|
||||||
import app.pachli.core.data.model.StatusViewData
|
import app.pachli.core.data.model.StatusViewData
|
||||||
|
import app.pachli.core.data.repository.AccountManager
|
||||||
|
import app.pachli.core.data.repository.Loadable
|
||||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||||
|
import app.pachli.core.database.model.AccountEntity
|
||||||
import app.pachli.core.eventhub.BlockEvent
|
import app.pachli.core.eventhub.BlockEvent
|
||||||
import app.pachli.core.eventhub.EventHub
|
import app.pachli.core.eventhub.EventHub
|
||||||
import app.pachli.core.eventhub.MuteEvent
|
import app.pachli.core.eventhub.MuteEvent
|
||||||
@ -45,12 +48,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ReportViewModel @Inject constructor(
|
class ReportViewModel @Inject constructor(
|
||||||
|
private val accountManager: AccountManager,
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||||
private val eventHub: EventHub,
|
private val eventHub: EventHub,
|
||||||
@ -78,19 +85,24 @@ class ReportViewModel @Inject constructor(
|
|||||||
|
|
||||||
val statusDisplayOptions = statusDisplayOptionsRepository.flow
|
val statusDisplayOptions = statusDisplayOptionsRepository.flow
|
||||||
|
|
||||||
val statusesFlow = accountIdFlow.flatMapLatest { accountId ->
|
val activeAccountFlow = accountManager.accountsFlow
|
||||||
|
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||||
|
.mapNotNull { it.data }
|
||||||
|
|
||||||
|
val statusesFlow = activeAccountFlow.combine(accountIdFlow) { activeAccount, reportedAccountId ->
|
||||||
|
Pair(activeAccount, reportedAccountId)
|
||||||
|
}.flatMapLatest { (activeAccount, reportedAccountId) ->
|
||||||
Pager(
|
Pager(
|
||||||
initialKey = statusId,
|
initialKey = statusId,
|
||||||
config = PagingConfig(pageSize = 20, initialLoadSize = 20),
|
config = PagingConfig(pageSize = 20, initialLoadSize = 20),
|
||||||
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) },
|
pagingSourceFactory = { StatusesPagingSource(reportedAccountId, mastodonApi) },
|
||||||
).flow
|
).flow
|
||||||
}
|
.map { pagingData ->
|
||||||
.map { pagingData ->
|
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData
|
||||||
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData
|
instead of StatusViewState */
|
||||||
instead of StatusViewState */
|
pagingData.map { status -> StatusViewData.from(activeAccount.id, status, false, false, false) }
|
||||||
pagingData.map { status -> StatusViewData.from(status, false, false, false) }
|
}
|
||||||
}
|
}.cachedIn(viewModelScope)
|
||||||
.cachedIn(viewModelScope)
|
|
||||||
|
|
||||||
private val selectedIds = HashSet<String>()
|
private val selectedIds = HashSet<String>()
|
||||||
val statusViewState = StatusViewState()
|
val statusViewState = StatusViewState()
|
||||||
|
@ -83,7 +83,7 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val accountManager: AccountManager,
|
private val accountManager: AccountManager,
|
||||||
private val serverRepository: ServerRepository,
|
serverRepository: ServerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
var currentQuery: String = ""
|
var currentQuery: String = ""
|
||||||
@ -204,6 +204,7 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
||||||
it.statuses.map { status ->
|
it.statuses.map { status ->
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = activeAccount!!.id,
|
||||||
status,
|
status,
|
||||||
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
||||||
isExpanded = alwaysOpenSpoiler,
|
isExpanded = alwaysOpenSpoiler,
|
||||||
|
@ -27,7 +27,6 @@ import app.pachli.databinding.ItemStatusBinding
|
|||||||
import app.pachli.interfaces.StatusActionListener
|
import app.pachli.interfaces.StatusActionListener
|
||||||
|
|
||||||
class SearchStatusesAdapter(
|
class SearchStatusesAdapter(
|
||||||
private val pachliAccountId: Long,
|
|
||||||
private val statusDisplayOptions: StatusDisplayOptions,
|
private val statusDisplayOptions: StatusDisplayOptions,
|
||||||
private val statusListener: StatusActionListener<StatusViewData>,
|
private val statusListener: StatusActionListener<StatusViewData>,
|
||||||
) : PagingDataAdapter<StatusViewData, StatusViewHolder<StatusViewData>>(STATUS_COMPARATOR) {
|
) : PagingDataAdapter<StatusViewData, StatusViewHolder<StatusViewData>>(STATUS_COMPARATOR) {
|
||||||
@ -40,7 +39,7 @@ class SearchStatusesAdapter(
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: StatusViewHolder<StatusViewData>, position: Int) {
|
override fun onBindViewHolder(holder: StatusViewHolder<StatusViewData>, position: Int) {
|
||||||
getItem(position)?.let { item ->
|
getItem(position)?.let { item ->
|
||||||
holder.setupWithStatus(pachliAccountId, item, statusListener, statusDisplayOptions)
|
holder.setupWithStatus(item, statusListener, statusDisplayOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,15 +85,15 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||||||
MaterialDividerItemDecoration(requireContext(), MaterialDividerItemDecoration.VERTICAL),
|
MaterialDividerItemDecoration(requireContext(), MaterialDividerItemDecoration.VERTICAL),
|
||||||
)
|
)
|
||||||
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
|
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
|
||||||
return SearchStatusesAdapter(viewModel.activeAccount!!.id, statusDisplayOptions, this)
|
return SearchStatusesAdapter(statusDisplayOptions, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) {
|
override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
|
||||||
viewModel.contentHiddenChange(viewData, isShowingContent)
|
viewModel.contentHiddenChange(viewData, isShowingContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
|
override fun onReply(viewData: StatusViewData) {
|
||||||
reply(pachliAccountId, viewData)
|
reply(viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFavourite(viewData: StatusViewData, favourite: Boolean) {
|
override fun onFavourite(viewData: StatusViewData, favourite: Boolean) {
|
||||||
@ -148,11 +148,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||||||
bottomSheetActivity?.viewAccount(pachliAccountId, status.account.id)
|
bottomSheetActivity?.viewAccount(pachliAccountId, status.account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) {
|
override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
|
||||||
viewModel.expandedChange(viewData, expanded)
|
viewModel.expandedChange(viewData, expanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) {
|
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
|
||||||
viewModel.collapsedChange(viewData, isCollapsed)
|
viewModel.collapsedChange(viewData, isCollapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||||||
viewModel.voteInPoll(viewData, poll, choices)
|
viewModel.voteInPoll(viewData, poll, choices)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {}
|
override fun clearContentFilter(viewData: StatusViewData) {}
|
||||||
|
|
||||||
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
||||||
viewModel.reblog(viewData, reblog)
|
viewModel.reblog(viewData, reblog)
|
||||||
@ -173,7 +173,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reply(pachliAccountId: Long, status: StatusViewData) {
|
private fun reply(status: StatusViewData) {
|
||||||
val actionableStatus = status.actionable
|
val actionableStatus = status.actionable
|
||||||
val mentionedUsernames = actionableStatus.mentions.map { it.username }
|
val mentionedUsernames = actionableStatus.mentions.map { it.username }
|
||||||
.toMutableSet()
|
.toMutableSet()
|
||||||
@ -184,7 +184,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||||||
|
|
||||||
val intent = ComposeActivityIntent(
|
val intent = ComposeActivityIntent(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
pachliAccountId,
|
status.pachliAccountId,
|
||||||
ComposeOptions(
|
ComposeOptions(
|
||||||
inReplyToId = status.actionableId,
|
inReplyToId = status.actionableId,
|
||||||
replyVisibility = actionableStatus.visibility,
|
replyVisibility = actionableStatus.visibility,
|
||||||
|
@ -22,7 +22,9 @@ import androidx.paging.InvalidatingPagingSourceFactory
|
|||||||
import androidx.paging.Pager
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
|
import app.pachli.components.timeline.TimelineRepository.Companion.PAGE_SIZE
|
||||||
import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator
|
import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator
|
||||||
|
import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator.Companion.RKE_TIMELINE_ID
|
||||||
import app.pachli.core.common.di.ApplicationScope
|
import app.pachli.core.common.di.ApplicationScope
|
||||||
import app.pachli.core.data.model.StatusViewData
|
import app.pachli.core.data.model.StatusViewData
|
||||||
import app.pachli.core.database.dao.RemoteKeyDao
|
import app.pachli.core.database.dao.RemoteKeyDao
|
||||||
@ -30,6 +32,8 @@ import app.pachli.core.database.dao.TimelineDao
|
|||||||
import app.pachli.core.database.dao.TranslatedStatusDao
|
import app.pachli.core.database.dao.TranslatedStatusDao
|
||||||
import app.pachli.core.database.di.TransactionProvider
|
import app.pachli.core.database.di.TransactionProvider
|
||||||
import app.pachli.core.database.model.AccountEntity
|
import app.pachli.core.database.model.AccountEntity
|
||||||
|
import app.pachli.core.database.model.RemoteKeyEntity
|
||||||
|
import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind
|
||||||
import app.pachli.core.database.model.StatusViewDataEntity
|
import app.pachli.core.database.model.StatusViewDataEntity
|
||||||
import app.pachli.core.database.model.TimelineStatusWithAccount
|
import app.pachli.core.database.model.TimelineStatusWithAccount
|
||||||
import app.pachli.core.database.model.TranslatedStatusEntity
|
import app.pachli.core.database.model.TranslatedStatusEntity
|
||||||
@ -42,6 +46,7 @@ import at.connyduck.calladapter.networkresult.fold
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -62,47 +67,33 @@ class CachedTimelineRepository @Inject constructor(
|
|||||||
private val remoteKeyDao: RemoteKeyDao,
|
private val remoteKeyDao: RemoteKeyDao,
|
||||||
private val translatedStatusDao: TranslatedStatusDao,
|
private val translatedStatusDao: TranslatedStatusDao,
|
||||||
@ApplicationScope private val externalScope: CoroutineScope,
|
@ApplicationScope private val externalScope: CoroutineScope,
|
||||||
) {
|
) : TimelineRepository<TimelineStatusWithAccount> {
|
||||||
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
|
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
|
||||||
|
|
||||||
/** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */
|
/** @return flow of Mastodon [TimelineStatusWithAccount. */
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
fun getStatusStream(
|
override suspend fun getStatusStream(
|
||||||
account: AccountEntity,
|
account: AccountEntity,
|
||||||
kind: Timeline,
|
kind: Timeline,
|
||||||
pageSize: Int = PAGE_SIZE,
|
|
||||||
initialKey: String? = null,
|
|
||||||
): Flow<PagingData<TimelineStatusWithAccount>> {
|
): Flow<PagingData<TimelineStatusWithAccount>> {
|
||||||
Timber.d("getStatusStream(): key: %s", initialKey)
|
|
||||||
|
|
||||||
Timber.d("getStatusStream, account is %s", account.fullName)
|
Timber.d("getStatusStream, account is %s", account.fullName)
|
||||||
|
|
||||||
factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) }
|
factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) }
|
||||||
|
|
||||||
val row = initialKey?.let { key ->
|
val initialKey = remoteKeyDao.remoteKeyForKind(account.id, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH)
|
||||||
// Room is row-keyed (by Int), not item-keyed, so the status ID string that was
|
val row = initialKey?.key?.let { timelineDao.getStatusRowNumber(account.id, it) }
|
||||||
// passed as `initialKey` won't work.
|
|
||||||
//
|
|
||||||
// Instead, get all the status IDs for this account, in timeline order, and find the
|
|
||||||
// row index that contains the status. The row index is the correct initialKey.
|
|
||||||
timelineDao.getStatusRowNumber(account.id)
|
|
||||||
.indexOfFirst { it == key }.takeIf { it != -1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.d("initialKey: %s is row: %d", initialKey, row)
|
Timber.d("initialKey: %s is row: %d", initialKey, row)
|
||||||
|
|
||||||
return Pager(
|
return Pager(
|
||||||
config = PagingConfig(
|
|
||||||
pageSize = pageSize,
|
|
||||||
jumpThreshold = PAGE_SIZE * 3,
|
|
||||||
enablePlaceholders = true,
|
|
||||||
),
|
|
||||||
initialKey = row,
|
initialKey = row,
|
||||||
|
config = PagingConfig(
|
||||||
|
pageSize = PAGE_SIZE,
|
||||||
|
enablePlaceholders = false,
|
||||||
|
),
|
||||||
remoteMediator = CachedTimelineRemoteMediator(
|
remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey,
|
|
||||||
mastodonApi,
|
mastodonApi,
|
||||||
account.id,
|
account.id,
|
||||||
factory!!,
|
|
||||||
transactionProvider,
|
transactionProvider,
|
||||||
timelineDao,
|
timelineDao,
|
||||||
remoteKeyDao,
|
remoteKeyDao,
|
||||||
@ -112,7 +103,7 @@ class CachedTimelineRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */
|
/** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */
|
||||||
suspend fun invalidate(pachliAccountId: Long) {
|
override suspend fun invalidate(pachliAccountId: Long) {
|
||||||
// Invalidating when no statuses have been loaded can cause empty timelines because it
|
// Invalidating when no statuses have been loaded can cause empty timelines because it
|
||||||
// cancels the network load.
|
// cancels the network load.
|
||||||
if (timelineDao.getStatusCount(pachliAccountId) < 1) {
|
if (timelineDao.getStatusCount(pachliAccountId) < 1) {
|
||||||
@ -122,11 +113,11 @@ class CachedTimelineRepository @Inject constructor(
|
|||||||
factory?.invalidate()
|
factory?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveStatusViewData(pachliAccountId: Long, statusViewData: StatusViewData) = externalScope.launch {
|
suspend fun saveStatusViewData(statusViewData: StatusViewData) = externalScope.launch {
|
||||||
timelineDao.upsertStatusViewData(
|
timelineDao.upsertStatusViewData(
|
||||||
StatusViewDataEntity(
|
StatusViewDataEntity(
|
||||||
serverId = statusViewData.actionableId,
|
serverId = statusViewData.actionableId,
|
||||||
timelineUserId = pachliAccountId,
|
timelineUserId = statusViewData.pachliAccountId,
|
||||||
expanded = statusViewData.isExpanded,
|
expanded = statusViewData.isExpanded,
|
||||||
contentShowing = statusViewData.isShowingContent,
|
contentShowing = statusViewData.isShowingContent,
|
||||||
contentCollapsed = statusViewData.isCollapsed,
|
contentCollapsed = statusViewData.isCollapsed,
|
||||||
@ -164,28 +155,15 @@ class CachedTimelineRepository @Inject constructor(
|
|||||||
timelineDao.clearWarning(pachliAccountId, statusId)
|
timelineDao.clearWarning(pachliAccountId, statusId)
|
||||||
}.join()
|
}.join()
|
||||||
|
|
||||||
/** Remove all statuses and invalidate the pager, for the active account */
|
suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
|
||||||
suspend fun clearAndReload(pachliAccountId: Long) = externalScope.launch {
|
saveStatusViewData(statusViewData.copy(translationState = TranslationState.TRANSLATING))
|
||||||
Timber.d("clearAndReload()")
|
|
||||||
timelineDao.removeAll(pachliAccountId)
|
|
||||||
factory?.invalidate()
|
|
||||||
}.join()
|
|
||||||
|
|
||||||
suspend fun clearAndReloadFromNewest(pachliAccountId: Long) = externalScope.launch {
|
|
||||||
timelineDao.removeAll(pachliAccountId)
|
|
||||||
remoteKeyDao.delete(pachliAccountId, CachedTimelineRemoteMediator.RKE_TIMELINE_ID)
|
|
||||||
invalidate(pachliAccountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun translate(pachliAccountId: Long, statusViewData: StatusViewData): NetworkResult<Translation> {
|
|
||||||
saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.TRANSLATING))
|
|
||||||
val translation = mastodonApi.translate(statusViewData.actionableId)
|
val translation = mastodonApi.translate(statusViewData.actionableId)
|
||||||
translation.fold(
|
translation.fold(
|
||||||
{
|
{
|
||||||
translatedStatusDao.upsert(
|
translatedStatusDao.upsert(
|
||||||
TranslatedStatusEntity(
|
TranslatedStatusEntity(
|
||||||
serverId = statusViewData.actionableId,
|
serverId = statusViewData.actionableId,
|
||||||
timelineUserId = pachliAccountId,
|
timelineUserId = statusViewData.pachliAccountId,
|
||||||
// TODO: Should this embed the network type instead of copying data
|
// TODO: Should this embed the network type instead of copying data
|
||||||
// from one type to another?
|
// from one type to another?
|
||||||
content = it.content,
|
content = it.content,
|
||||||
@ -195,21 +173,31 @@ class CachedTimelineRepository @Inject constructor(
|
|||||||
provider = it.provider,
|
provider = it.provider,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION))
|
saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Reset the translation state
|
// Reset the translation state
|
||||||
saveStatusViewData(pachliAccountId, statusViewData)
|
saveStatusViewData(statusViewData)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return translation
|
return translation
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) {
|
suspend fun translateUndo(statusViewData: StatusViewData) {
|
||||||
saveStatusViewData(pachliAccountId, statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL))
|
saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL))
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
/**
|
||||||
private const val PAGE_SIZE = 30
|
* Saves the ID of the notification that future refreshes will try and restore
|
||||||
}
|
* from.
|
||||||
|
*
|
||||||
|
* @param pachliAccountId
|
||||||
|
* @param key Notification ID to restore from. Null indicates the refresh should
|
||||||
|
* refresh the newest notifications.
|
||||||
|
*/
|
||||||
|
suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async {
|
||||||
|
remoteKeyDao.upsert(
|
||||||
|
RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key),
|
||||||
|
)
|
||||||
|
}.await()
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ import androidx.paging.InvalidatingPagingSourceFactory
|
|||||||
import androidx.paging.Pager
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.PagingSource
|
import app.pachli.components.timeline.TimelineRepository.Companion.PAGE_SIZE
|
||||||
import app.pachli.components.timeline.viewmodel.NetworkTimelinePagingSource
|
import app.pachli.components.timeline.viewmodel.NetworkTimelinePagingSource
|
||||||
import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator
|
import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator
|
||||||
import app.pachli.components.timeline.viewmodel.PageCache
|
import app.pachli.components.timeline.viewmodel.PageCache
|
||||||
@ -70,30 +70,25 @@ import timber.log.Timber
|
|||||||
/** Timeline repository where the timeline information is backed by an in-memory cache. */
|
/** Timeline repository where the timeline information is backed by an in-memory cache. */
|
||||||
class NetworkTimelineRepository @Inject constructor(
|
class NetworkTimelineRepository @Inject constructor(
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
) {
|
) : TimelineRepository<Status> {
|
||||||
private val pageCache = PageCache()
|
private val pageCache = PageCache()
|
||||||
|
|
||||||
private var factory: InvalidatingPagingSourceFactory<String, Status>? = null
|
private var factory: InvalidatingPagingSourceFactory<String, Status>? = null
|
||||||
|
|
||||||
// TODO: This should use assisted injection, and inject the account.
|
/** @return flow of Mastodon [Status]. */
|
||||||
private var activeAccount: AccountEntity? = null
|
|
||||||
|
|
||||||
/** @return flow of Mastodon [Status], loaded in [pageSize] increments */
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
fun getStatusStream(
|
override suspend fun getStatusStream(
|
||||||
account: AccountEntity,
|
account: AccountEntity,
|
||||||
kind: Timeline,
|
kind: Timeline,
|
||||||
pageSize: Int = PAGE_SIZE,
|
|
||||||
initialKey: String? = null,
|
|
||||||
): Flow<PagingData<Status>> {
|
): Flow<PagingData<Status>> {
|
||||||
Timber.d("getStatusStream(): key: %s", initialKey)
|
Timber.d("getStatusStream()")
|
||||||
|
|
||||||
factory = InvalidatingPagingSourceFactory {
|
factory = InvalidatingPagingSourceFactory {
|
||||||
NetworkTimelinePagingSource(pageCache)
|
NetworkTimelinePagingSource(pageCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Pager(
|
return Pager(
|
||||||
config = PagingConfig(pageSize = pageSize),
|
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||||
remoteMediator = NetworkTimelineRemoteMediator(
|
remoteMediator = NetworkTimelineRemoteMediator(
|
||||||
mastodonApi,
|
mastodonApi,
|
||||||
account,
|
account,
|
||||||
@ -105,10 +100,9 @@ class NetworkTimelineRepository @Inject constructor(
|
|||||||
).flow
|
).flow
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invalidate the active paging source, see [PagingSource.invalidate] */
|
override suspend fun invalidate(pachliAccountId: Long) = factory?.invalidate() ?: Unit
|
||||||
fun invalidate() {
|
|
||||||
factory?.invalidate()
|
fun invalidate() = factory?.invalidate()
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAllByAccountId(accountId: String) {
|
fun removeAllByAccountId(accountId: String) {
|
||||||
synchronized(pageCache) {
|
synchronized(pageCache) {
|
||||||
@ -171,8 +165,4 @@ class NetworkTimelineRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PAGE_SIZE = 30
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -44,13 +44,13 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
|||||||
import app.pachli.BuildConfig
|
import app.pachli.BuildConfig
|
||||||
import app.pachli.R
|
import app.pachli.R
|
||||||
import app.pachli.adapter.StatusBaseViewHolder
|
import app.pachli.adapter.StatusBaseViewHolder
|
||||||
import app.pachli.components.timeline.util.isExpected
|
|
||||||
import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel
|
import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel
|
||||||
import app.pachli.components.timeline.viewmodel.InfallibleUiAction
|
import app.pachli.components.timeline.viewmodel.InfallibleUiAction
|
||||||
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
|
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||||
import app.pachli.components.timeline.viewmodel.StatusAction
|
import app.pachli.components.timeline.viewmodel.StatusAction
|
||||||
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
|
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
|
||||||
import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
||||||
|
import app.pachli.components.timeline.viewmodel.UiError
|
||||||
import app.pachli.components.timeline.viewmodel.UiSuccess
|
import app.pachli.components.timeline.viewmodel.UiSuccess
|
||||||
import app.pachli.core.activity.RefreshableFragment
|
import app.pachli.core.activity.RefreshableFragment
|
||||||
import app.pachli.core.activity.ReselectableFragment
|
import app.pachli.core.activity.ReselectableFragment
|
||||||
@ -78,11 +78,10 @@ import app.pachli.interfaces.ActionButtonActivity
|
|||||||
import app.pachli.interfaces.AppBarLayoutHost
|
import app.pachli.interfaces.AppBarLayoutHost
|
||||||
import app.pachli.interfaces.StatusActionListener
|
import app.pachli.interfaces.StatusActionListener
|
||||||
import app.pachli.util.ListStatusAccessibilityDelegate
|
import app.pachli.util.ListStatusAccessibilityDelegate
|
||||||
import app.pachli.util.PresentationState
|
|
||||||
import app.pachli.util.UserRefreshState
|
|
||||||
import app.pachli.util.asRefreshState
|
|
||||||
import app.pachli.util.withPresentationState
|
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
|
import com.github.michaelbull.result.Result
|
||||||
|
import com.github.michaelbull.result.onFailure
|
||||||
|
import com.github.michaelbull.result.onSuccess
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
@ -96,12 +95,13 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.take
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import postPrepend
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@ -120,7 +120,7 @@ class TimelineFragment :
|
|||||||
//
|
//
|
||||||
// If the navigation library was being used this would happen automatically, so this
|
// If the navigation library was being used this would happen automatically, so this
|
||||||
// workaround can be removed when that change happens.
|
// workaround can be removed when that change happens.
|
||||||
private val viewModel: TimelineViewModel by lazy {
|
private val viewModel: TimelineViewModel<out Any> by lazy {
|
||||||
if (timeline == Timeline.Home) {
|
if (timeline == Timeline.Home) {
|
||||||
viewModels<CachedTimelineViewModel>(
|
viewModels<CachedTimelineViewModel>(
|
||||||
extrasProducer = {
|
extrasProducer = {
|
||||||
@ -160,6 +160,19 @@ class TimelineFragment :
|
|||||||
|
|
||||||
override var pachliAccountId by Delegates.notNull<Long>()
|
override var pachliAccountId by Delegates.notNull<Long>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect this flow to notify the adapter that the timestamps of the visible items have
|
||||||
|
* changed
|
||||||
|
*/
|
||||||
|
private val updateTimestampFlow = flow {
|
||||||
|
while (true) {
|
||||||
|
delay(60.seconds)
|
||||||
|
emit(Unit)
|
||||||
|
}
|
||||||
|
}.onEach {
|
||||||
|
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@ -171,7 +184,7 @@ class TimelineFragment :
|
|||||||
|
|
||||||
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||||
|
|
||||||
adapter = TimelinePagingAdapter(pachliAccountId, this, viewModel.statusDisplayOptions.value)
|
adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
@ -179,16 +192,6 @@ class TimelineFragment :
|
|||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?,
|
savedInstanceState: Bundle?,
|
||||||
): View? {
|
): View? {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
|
||||||
launch {
|
|
||||||
viewModel.statuses.collectLatest { pagingData ->
|
|
||||||
adapter.submitData(pagingData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,123 +219,13 @@ class TimelineFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect this flow to notify the adapter that the timestamps of the visible items have
|
|
||||||
* changed
|
|
||||||
*/
|
|
||||||
// TODO: Copied from NotificationsFragment
|
|
||||||
val updateTimestampFlow = flow {
|
|
||||||
while (true) {
|
|
||||||
delay(60.seconds)
|
|
||||||
emit(Unit)
|
|
||||||
}
|
|
||||||
}.onEach {
|
|
||||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
|
||||||
}
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
// Show errors from the view model as snack bars.
|
launch { viewModel.statuses.collectLatest { adapter.submitData(it) } }
|
||||||
//
|
|
||||||
// Errors are shown:
|
|
||||||
// - Indefinitely, so the user has a chance to read and understand
|
|
||||||
// the message
|
|
||||||
// - With a max of 5 text lines, to allow space for longer errors.
|
|
||||||
// E.g., on a typical device, an error message like "Bookmarking
|
|
||||||
// post failed: Unable to resolve host 'mastodon.social': No
|
|
||||||
// address associated with hostname" is 3 lines.
|
|
||||||
// - With a "Retry" option if the error included a UiAction to retry.
|
|
||||||
// TODO: Very similar to same code in NotificationsFragment
|
|
||||||
launch {
|
|
||||||
viewModel.uiError.collect { error ->
|
|
||||||
val message = getString(
|
|
||||||
error.message,
|
|
||||||
error.throwable.getErrorString(requireContext()),
|
|
||||||
)
|
|
||||||
Timber.d(error.throwable, message)
|
|
||||||
snackbar?.dismiss()
|
|
||||||
snackbar = Snackbar.make(
|
|
||||||
// Without this the FAB will not move out of the way
|
|
||||||
(activity as? ActionButtonActivity)?.actionButton ?: binding.root,
|
|
||||||
message,
|
|
||||||
Snackbar.LENGTH_INDEFINITE,
|
|
||||||
)
|
|
||||||
error.action?.let { action ->
|
|
||||||
snackbar!!.setAction(app.pachli.core.ui.R.string.action_retry) {
|
|
||||||
viewModel.accept(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
snackbar!!.show()
|
|
||||||
|
|
||||||
// The status view has pre-emptively updated its state to show
|
launch { viewModel.uiResult.collect(::bindUiResult) }
|
||||||
// that the action succeeded. Since it hasn't, re-bind the view
|
|
||||||
// to show the correct data.
|
|
||||||
error.action?.let { action ->
|
|
||||||
if (action !is StatusAction) return@let
|
|
||||||
|
|
||||||
adapter.snapshot()
|
// Collect the uiState.
|
||||||
.indexOfFirst { it?.id == action.statusViewData.id }
|
|
||||||
.takeIf { it != RecyclerView.NO_POSITION }
|
|
||||||
?.let { adapter.notifyItemChanged(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update adapter data when status actions are successful, and re-bind to update
|
|
||||||
// the UI.
|
|
||||||
launch {
|
|
||||||
viewModel.uiSuccess
|
|
||||||
.filterIsInstance<StatusActionSuccess>()
|
|
||||||
.collect {
|
|
||||||
val indexedViewData = adapter.snapshot()
|
|
||||||
.withIndex()
|
|
||||||
.firstOrNull { indexed ->
|
|
||||||
indexed.value?.id == it.action.statusViewData.id
|
|
||||||
} ?: return@collect
|
|
||||||
|
|
||||||
val statusViewData =
|
|
||||||
indexedViewData.value ?: return@collect
|
|
||||||
|
|
||||||
val status = when (it) {
|
|
||||||
is StatusActionSuccess.Bookmark ->
|
|
||||||
statusViewData.status.copy(bookmarked = it.action.state)
|
|
||||||
is StatusActionSuccess.Favourite ->
|
|
||||||
statusViewData.status.copy(favourited = it.action.state)
|
|
||||||
is StatusActionSuccess.Reblog ->
|
|
||||||
statusViewData.status.copy(reblogged = it.action.state)
|
|
||||||
is StatusActionSuccess.VoteInPoll ->
|
|
||||||
statusViewData.status.copy(
|
|
||||||
poll = it.action.poll.votedCopy(it.action.choices),
|
|
||||||
)
|
|
||||||
is StatusActionSuccess.Translate -> statusViewData.status
|
|
||||||
}
|
|
||||||
(indexedViewData.value as StatusViewData).status = status
|
|
||||||
|
|
||||||
adapter.notifyItemChanged(indexedViewData.index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh adapter on mutes and blocks
|
|
||||||
launch {
|
|
||||||
viewModel.uiSuccess.collectLatest {
|
|
||||||
when (it) {
|
|
||||||
is UiSuccess.Block,
|
|
||||||
is UiSuccess.Mute,
|
|
||||||
is UiSuccess.MuteConversation,
|
|
||||||
->
|
|
||||||
adapter.refresh()
|
|
||||||
|
|
||||||
is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status)
|
|
||||||
is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status)
|
|
||||||
|
|
||||||
else -> { /* nothing to do */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect the uiState. Nothing is done with it, but if you don't collect it then
|
|
||||||
// accessing viewModel.uiState.value (e.g., to check whether the FAB should be
|
|
||||||
// hidden) always returns the initial state.
|
|
||||||
launch {
|
launch {
|
||||||
viewModel.uiState.collect { uiState ->
|
viewModel.uiState.collect { uiState ->
|
||||||
if (layoutManager.reverseLayout != uiState.reverseTimeline) {
|
if (layoutManager.reverseLayout != uiState.reverseTimeline) {
|
||||||
@ -348,164 +241,190 @@ class TimelineFragment :
|
|||||||
// Update status display from statusDisplayOptions. If the new options request
|
// Update status display from statusDisplayOptions. If the new options request
|
||||||
// relative time display collect the flow to periodically re-bind the UI.
|
// relative time display collect the flow to periodically re-bind the UI.
|
||||||
launch {
|
launch {
|
||||||
viewModel.statusDisplayOptions
|
viewModel.statusDisplayOptions.collectLatest {
|
||||||
.collectLatest {
|
adapter.statusDisplayOptions = it
|
||||||
adapter.statusDisplayOptions = it
|
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
|
||||||
layoutManager.findFirstVisibleItemPosition().let { first ->
|
|
||||||
first == RecyclerView.NO_POSITION && return@let
|
|
||||||
val count = layoutManager.findLastVisibleItemPosition() - first
|
|
||||||
adapter.notifyItemRangeChanged(
|
|
||||||
first,
|
|
||||||
count,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!it.useAbsoluteTime) {
|
if (!it.useAbsoluteTime) {
|
||||||
updateTimestampFlow.collect()
|
updateTimestampFlow.collect()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage the progress display. Rather than hide as soon as the Refresh portion
|
adapter.loadStateFlow.collect { loadState ->
|
||||||
// completes, hide when then first Prepend completes. This is a better signal to
|
when (loadState.refresh) {
|
||||||
// the user that it is now possible to scroll up and see new content.
|
is LoadState.Error -> {
|
||||||
launch {
|
binding.progressIndicator.hide()
|
||||||
refreshState.collect {
|
binding.statusView.setup((loadState.refresh as LoadState.Error).error) {
|
||||||
when (it) {
|
adapter.retry()
|
||||||
UserRefreshState.COMPLETE, UserRefreshState.ERROR -> {
|
}
|
||||||
|
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
|
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) {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
menuInflater.inflate(R.menu.fragment_timeline, menu)
|
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
|
* The best status is the first completely visible status, if available. We assume the user
|
||||||
* has read this far, or will recognise it on return.
|
* has read this far, or will recognise it on return.
|
||||||
@ -554,23 +482,14 @@ class TimelineFragment :
|
|||||||
* In this case the best status is the last partially visible status, as we can assume the
|
* In this case the best status is the last partially visible status, as we can assume the
|
||||||
* user has read this far.
|
* user has read this far.
|
||||||
*/
|
*/
|
||||||
fun saveVisibleId(statusId: String? = null) {
|
fun saveVisibleId() {
|
||||||
val id = statusId ?: (
|
val id = getFirstVisibleStatus()?.id
|
||||||
layoutManager.findFirstCompletelyVisibleItemPosition()
|
|
||||||
.takeIf { it != RecyclerView.NO_POSITION }
|
|
||||||
?: layoutManager.findLastVisibleItemPosition()
|
|
||||||
.takeIf { it != RecyclerView.NO_POSITION }
|
|
||||||
)
|
|
||||||
?.let { adapter.snapshot().getOrNull(it)?.id }
|
|
||||||
|
|
||||||
if (BuildConfig.DEBUG && id == null) {
|
if (BuildConfig.DEBUG && id == null) {
|
||||||
Toast.makeText(requireActivity(), "Could not find ID of item to save", LENGTH_LONG).show()
|
Toast.makeText(requireActivity(), "Could not find ID of item to save", LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
id?.let {
|
id?.let {
|
||||||
Timber.d("saveVisibleId: Saving ID: %s", it)
|
viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, id))
|
||||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = it))
|
}
|
||||||
} ?: Timber.d("saveVisibleId: Not saving, as no ID was visible")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSwipeRefreshLayout() {
|
private fun setupSwipeRefreshLayout() {
|
||||||
@ -615,7 +534,6 @@ class TimelineFragment :
|
|||||||
/** Refresh the displayed content, as if the user had swiped on the SwipeRefreshLayout */
|
/** Refresh the displayed content, as if the user had swiped on the SwipeRefreshLayout */
|
||||||
override fun refreshContent() {
|
override fun refreshContent() {
|
||||||
Timber.d("Reloading via refreshContent")
|
Timber.d("Reloading via refreshContent")
|
||||||
binding.swipeRefreshLayout.isRefreshing = true
|
|
||||||
onRefresh()
|
onRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -625,13 +543,26 @@ class TimelineFragment :
|
|||||||
*/
|
*/
|
||||||
override fun onRefresh() {
|
override fun onRefresh() {
|
||||||
Timber.d("Reloading via onRefresh")
|
Timber.d("Reloading via onRefresh")
|
||||||
binding.statusView.hide()
|
|
||||||
snackbar?.dismiss()
|
// Peek the list when refreshing completes.
|
||||||
adapter.refresh()
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
adapter.postPrepend {
|
||||||
|
binding.recyclerView.post {
|
||||||
|
view ?: return@post
|
||||||
|
binding.recyclerView.smoothScrollBy(
|
||||||
|
0,
|
||||||
|
Utils.dpToPx(requireContext(), -30),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
|
refreshAdapterAndScrollToVisibleId()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
|
override fun onReply(viewData: StatusViewData) {
|
||||||
super.reply(pachliAccountId, viewData.actionable)
|
super.reply(viewData.pachliAccountId, viewData.actionable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
||||||
@ -650,8 +581,8 @@ class TimelineFragment :
|
|||||||
viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData))
|
viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {
|
override fun clearContentFilter(viewData: StatusViewData) {
|
||||||
viewModel.clearWarning(pachliAccountId, viewData)
|
viewModel.clearWarning(viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEditFilterById(pachliAccountId: Long, filterId: String) {
|
override fun onEditFilterById(pachliAccountId: Long, filterId: String) {
|
||||||
@ -669,12 +600,12 @@ class TimelineFragment :
|
|||||||
super.openReblog(status)
|
super.openReblog(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) {
|
override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
|
||||||
viewModel.changeExpanded(pachliAccountId, expanded, viewData)
|
viewModel.changeExpanded(expanded, viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) {
|
override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
|
||||||
viewModel.changeContentShowing(pachliAccountId, isShowingContent, viewData)
|
viewModel.changeContentShowing(isShowingContent, viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShowReblogs(statusId: String) {
|
override fun onShowReblogs(statusId: String) {
|
||||||
@ -687,8 +618,8 @@ class TimelineFragment :
|
|||||||
activity?.startActivityWithDefaultTransition(intent)
|
activity?.startActivityWithDefaultTransition(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) {
|
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
|
||||||
viewModel.changeContentCollapsed(pachliAccountId, isCollapsed, viewData)
|
viewModel.changeContentCollapsed(isCollapsed, viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can only translate the home timeline at the moment
|
// Can only translate the home timeline at the moment
|
||||||
|
@ -33,7 +33,6 @@ import app.pachli.databinding.ItemStatusWrapperBinding
|
|||||||
import app.pachli.interfaces.StatusActionListener
|
import app.pachli.interfaces.StatusActionListener
|
||||||
|
|
||||||
class TimelinePagingAdapter(
|
class TimelinePagingAdapter(
|
||||||
private val pachliAccountId: Long,
|
|
||||||
private val statusListener: StatusActionListener<StatusViewData>,
|
private val statusListener: StatusActionListener<StatusViewData>,
|
||||||
var statusDisplayOptions: StatusDisplayOptions,
|
var statusDisplayOptions: StatusDisplayOptions,
|
||||||
) : PagingDataAdapter<StatusViewData, RecyclerView.ViewHolder>(TimelineDifferCallback) {
|
) : PagingDataAdapter<StatusViewData, RecyclerView.ViewHolder>(TimelineDifferCallback) {
|
||||||
@ -73,7 +72,6 @@ class TimelinePagingAdapter(
|
|||||||
null
|
null
|
||||||
}?.let {
|
}?.let {
|
||||||
(viewHolder as StatusViewHolder<StatusViewData>).setupWithStatus(
|
(viewHolder as StatusViewHolder<StatusViewData>).setupWithStatus(
|
||||||
pachliAccountId,
|
|
||||||
it,
|
it,
|
||||||
statusListener,
|
statusListener,
|
||||||
statusDisplayOptions,
|
statusDisplayOptions,
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -18,7 +18,6 @@
|
|||||||
package app.pachli.components.timeline.viewmodel
|
package app.pachli.components.timeline.viewmodel
|
||||||
|
|
||||||
import androidx.paging.ExperimentalPagingApi
|
import androidx.paging.ExperimentalPagingApi
|
||||||
import androidx.paging.InvalidatingPagingSourceFactory
|
|
||||||
import androidx.paging.LoadType
|
import androidx.paging.LoadType
|
||||||
import androidx.paging.PagingState
|
import androidx.paging.PagingState
|
||||||
import androidx.paging.RemoteMediator
|
import androidx.paging.RemoteMediator
|
||||||
@ -42,10 +41,8 @@ import timber.log.Timber
|
|||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
class CachedTimelineRemoteMediator(
|
class CachedTimelineRemoteMediator(
|
||||||
private val initialKey: String?,
|
private val mastodonApi: MastodonApi,
|
||||||
private val api: MastodonApi,
|
|
||||||
private val pachliAccountId: Long,
|
private val pachliAccountId: Long,
|
||||||
private val factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>,
|
|
||||||
private val transactionProvider: TransactionProvider,
|
private val transactionProvider: TransactionProvider,
|
||||||
private val timelineDao: TimelineDao,
|
private val timelineDao: TimelineDao,
|
||||||
private val remoteKeyDao: RemoteKeyDao,
|
private val remoteKeyDao: RemoteKeyDao,
|
||||||
@ -59,22 +56,15 @@ class CachedTimelineRemoteMediator(
|
|||||||
return try {
|
return try {
|
||||||
val response = when (loadType) {
|
val response = when (loadType) {
|
||||||
LoadType.REFRESH -> {
|
LoadType.REFRESH -> {
|
||||||
val closestItem = state.anchorPosition?.let {
|
// Ignore the provided state, always try and fetch from the remote
|
||||||
state.closestItemToPosition(maxOf(0, it - (state.config.pageSize / 2)))
|
// REFRESH key.
|
||||||
}?.status?.serverId
|
val statusId = remoteKeyDao.remoteKeyForKind(
|
||||||
val statusId = closestItem ?: initialKey
|
|
||||||
Timber.d("Loading from item: %s", statusId)
|
|
||||||
getInitialPage(statusId, state.config.pageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
LoadType.APPEND -> {
|
|
||||||
val rke = remoteKeyDao.remoteKeyForKind(
|
|
||||||
pachliAccountId,
|
pachliAccountId,
|
||||||
RKE_TIMELINE_ID,
|
RKE_TIMELINE_ID,
|
||||||
RemoteKeyKind.NEXT,
|
RemoteKeyKind.REFRESH,
|
||||||
) ?: return MediatorResult.Success(endOfPaginationReached = true)
|
)?.key
|
||||||
Timber.d("Loading from remoteKey: %s", rke)
|
Timber.d("Loading from item: %s", statusId)
|
||||||
api.homeTimeline(maxId = rke.key, limit = state.config.pageSize)
|
getInitialPage(statusId, state.config.pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadType.PREPEND -> {
|
LoadType.PREPEND -> {
|
||||||
@ -84,7 +74,17 @@ class CachedTimelineRemoteMediator(
|
|||||||
RemoteKeyKind.PREV,
|
RemoteKeyKind.PREV,
|
||||||
) ?: return MediatorResult.Success(endOfPaginationReached = true)
|
) ?: return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
Timber.d("Loading from remoteKey: %s", rke)
|
Timber.d("Loading from remoteKey: %s", rke)
|
||||||
api.homeTimeline(minId = rke.key, limit = state.config.pageSize)
|
mastodonApi.homeTimeline(minId = rke.key, limit = state.config.pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
val rke = remoteKeyDao.remoteKeyForKind(
|
||||||
|
pachliAccountId,
|
||||||
|
RKE_TIMELINE_ID,
|
||||||
|
RemoteKeyKind.NEXT,
|
||||||
|
) ?: return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
Timber.d("Loading from remoteKey: %s", rke)
|
||||||
|
mastodonApi.homeTimeline(maxId = rke.key, limit = state.config.pageSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +98,6 @@ class CachedTimelineRemoteMediator(
|
|||||||
// This request succeeded with no new data, and pagination ends (unless this is a
|
// This request succeeded with no new data, and pagination ends (unless this is a
|
||||||
// REFRESH, which must always set endOfPaginationReached to false).
|
// REFRESH, which must always set endOfPaginationReached to false).
|
||||||
if (statuses.isEmpty()) {
|
if (statuses.isEmpty()) {
|
||||||
factory.invalidate()
|
|
||||||
return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH)
|
return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,8 +108,8 @@ class CachedTimelineRemoteMediator(
|
|||||||
transactionProvider {
|
transactionProvider {
|
||||||
when (loadType) {
|
when (loadType) {
|
||||||
LoadType.REFRESH -> {
|
LoadType.REFRESH -> {
|
||||||
remoteKeyDao.delete(pachliAccountId, RKE_TIMELINE_ID)
|
remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID)
|
||||||
timelineDao.removeAllStatuses(pachliAccountId)
|
timelineDao.deleteAllStatusesForAccount(pachliAccountId)
|
||||||
|
|
||||||
remoteKeyDao.upsert(
|
remoteKeyDao.upsert(
|
||||||
RemoteKeyEntity(
|
RemoteKeyEntity(
|
||||||
@ -120,6 +119,7 @@ class CachedTimelineRemoteMediator(
|
|||||||
links.next,
|
links.next,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
remoteKeyDao.upsert(
|
remoteKeyDao.upsert(
|
||||||
RemoteKeyEntity(
|
RemoteKeyEntity(
|
||||||
pachliAccountId,
|
pachliAccountId,
|
||||||
@ -179,7 +179,7 @@ class CachedTimelineRemoteMediator(
|
|||||||
*/
|
*/
|
||||||
private suspend fun getInitialPage(statusId: String?, pageSize: Int): Response<List<Status>> = coroutineScope {
|
private suspend fun getInitialPage(statusId: String?, pageSize: Int): Response<List<Status>> = coroutineScope {
|
||||||
// If the key is null this is straightforward, just return the most recent statuses.
|
// If the key is null this is straightforward, just return the most recent statuses.
|
||||||
statusId ?: return@coroutineScope api.homeTimeline(limit = pageSize)
|
statusId ?: return@coroutineScope mastodonApi.homeTimeline(limit = pageSize)
|
||||||
|
|
||||||
// It's important to return *something* from this state. If an empty page is returned
|
// It's important to return *something* from this state. If an empty page is returned
|
||||||
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
|
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
|
||||||
@ -189,13 +189,17 @@ class CachedTimelineRemoteMediator(
|
|||||||
// you can not fetch the page itself.
|
// you can not fetch the page itself.
|
||||||
|
|
||||||
// Fetch the requested status, and the page immediately after (next)
|
// Fetch the requested status, and the page immediately after (next)
|
||||||
val deferredStatus = async { api.status(statusId = statusId) }
|
val deferredStatus = async { mastodonApi.status(statusId = statusId) }
|
||||||
|
val deferredPrevPage = async {
|
||||||
|
mastodonApi.homeTimeline(minId = statusId, limit = pageSize * 3)
|
||||||
|
}
|
||||||
val deferredNextPage = async {
|
val deferredNextPage = async {
|
||||||
api.homeTimeline(maxId = statusId, limit = pageSize)
|
mastodonApi.homeTimeline(maxId = statusId, limit = pageSize * 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
deferredStatus.await().getOrNull()?.let { status ->
|
deferredStatus.await().getOrNull()?.let { status ->
|
||||||
val statuses = buildList {
|
val statuses = buildList {
|
||||||
|
deferredPrevPage.await().body()?.let { this.addAll(it) }
|
||||||
this.add(status)
|
this.add(status)
|
||||||
deferredNextPage.await().body()?.let { this.addAll(it) }
|
deferredNextPage.await().body()?.let { this.addAll(it) }
|
||||||
}
|
}
|
||||||
@ -218,43 +222,41 @@ class CachedTimelineRemoteMediator(
|
|||||||
// The user's last read status was missing. Use the page of statuses chronologically older
|
// The user's last read status was missing. Use the page of statuses chronologically older
|
||||||
// than their desired status. This page must *not* be empty (as noted earlier, if it is,
|
// than their desired status. This page must *not* be empty (as noted earlier, if it is,
|
||||||
// paging stops).
|
// paging stops).
|
||||||
deferredNextPage.await().let { response ->
|
deferredNextPage.await().apply {
|
||||||
if (response.isSuccessful) {
|
if (isSuccessful && !body().isNullOrEmpty()) {
|
||||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
return@coroutineScope this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// There were no statuses older than the user's desired status. Return the page
|
// There were no statuses older than the user's desired status. Return the page
|
||||||
// of statuses immediately newer than their desired status. This page must
|
// of statuses immediately newer than their desired status. This page must
|
||||||
// *not* be empty (as noted earlier, if it is, paging stops).
|
// *not* be empty (as noted earlier, if it is, paging stops).
|
||||||
api.homeTimeline(minId = statusId, limit = pageSize).let { response ->
|
deferredPrevPage.await().apply {
|
||||||
if (response.isSuccessful) {
|
if (isSuccessful && !body().isNullOrEmpty()) {
|
||||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
return@coroutineScope this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything failed -- fallback to fetching the most recent statuses
|
// Everything failed -- fallback to fetching the most recent statuses
|
||||||
return@coroutineScope api.homeTimeline(limit = pageSize)
|
return@coroutineScope mastodonApi.homeTimeline(limit = pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inserts `statuses` and the accounts referenced by those statuses in to the cache.
|
* Inserts `statuses` and the accounts referenced by those statuses in to the cache.
|
||||||
*/
|
*/
|
||||||
private suspend fun insertStatuses(pachliAccountId: Long, statuses: List<Status>) {
|
private suspend fun insertStatuses(pachliAccountId: Long, statuses: List<Status>) {
|
||||||
for (status in statuses) {
|
check(transactionProvider.inTransaction())
|
||||||
timelineDao.insertAccount(TimelineAccountEntity.from(status.account, pachliAccountId))
|
|
||||||
status.reblog?.account?.let {
|
|
||||||
val account = TimelineAccountEntity.from(it, pachliAccountId)
|
|
||||||
timelineDao.insertAccount(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
timelineDao.insertStatus(
|
/** Unique accounts referenced in this batch of statuses. */
|
||||||
TimelineStatusEntity.from(
|
val accounts = buildSet {
|
||||||
status,
|
statuses.forEach { status ->
|
||||||
timelineUserId = pachliAccountId,
|
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 {
|
companion object {
|
||||||
|
@ -28,6 +28,7 @@ import app.pachli.core.data.model.StatusViewData
|
|||||||
import app.pachli.core.data.repository.AccountManager
|
import app.pachli.core.data.repository.AccountManager
|
||||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||||
import app.pachli.core.database.model.AccountEntity
|
import app.pachli.core.database.model.AccountEntity
|
||||||
|
import app.pachli.core.database.model.TimelineStatusWithAccount
|
||||||
import app.pachli.core.eventhub.BookmarkEvent
|
import app.pachli.core.eventhub.BookmarkEvent
|
||||||
import app.pachli.core.eventhub.EventHub
|
import app.pachli.core.eventhub.EventHub
|
||||||
import app.pachli.core.eventhub.FavoriteEvent
|
import app.pachli.core.eventhub.FavoriteEvent
|
||||||
@ -59,35 +60,30 @@ class CachedTimelineViewModel @Inject constructor(
|
|||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||||
sharedPreferencesRepository: SharedPreferencesRepository,
|
sharedPreferencesRepository: SharedPreferencesRepository,
|
||||||
) : TimelineViewModel(
|
) : TimelineViewModel<TimelineStatusWithAccount>(
|
||||||
savedStateHandle,
|
savedStateHandle,
|
||||||
timelineCases,
|
timelineCases,
|
||||||
eventHub,
|
eventHub,
|
||||||
accountManager,
|
accountManager,
|
||||||
|
repository,
|
||||||
statusDisplayOptionsRepository,
|
statusDisplayOptionsRepository,
|
||||||
sharedPreferencesRepository,
|
sharedPreferencesRepository,
|
||||||
) {
|
) {
|
||||||
override var statuses: Flow<PagingData<StatusViewData>>
|
override var statuses = accountFlow.flatMapLatest {
|
||||||
|
getStatuses(it.data!!)
|
||||||
init {
|
}.cachedIn(viewModelScope)
|
||||||
readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId
|
|
||||||
|
|
||||||
statuses = refreshFlow.flatMapLatest {
|
|
||||||
getStatuses(it.second, initialKey = getInitialKey())
|
|
||||||
}.cachedIn(viewModelScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
|
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
|
||||||
private fun getStatuses(
|
private suspend fun getStatuses(
|
||||||
account: AccountEntity,
|
account: AccountEntity,
|
||||||
initialKey: String? = null,
|
|
||||||
): Flow<PagingData<StatusViewData>> {
|
): Flow<PagingData<StatusViewData>> {
|
||||||
Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey)
|
Timber.d("getStatuses: kind: %s", timeline)
|
||||||
return repository.getStatusStream(account, kind = timeline, initialKey = initialKey)
|
return repository.getStatusStream(account, timeline)
|
||||||
.map { pagingData ->
|
.map { pagingData ->
|
||||||
pagingData
|
pagingData
|
||||||
.map {
|
.map {
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
it,
|
it,
|
||||||
isExpanded = activeAccount.alwaysOpenSpoiler,
|
isExpanded = activeAccount.alwaysOpenSpoiler,
|
||||||
isShowingContent = activeAccount.alwaysShowSensitiveMedia,
|
isShowingContent = activeAccount.alwaysShowSensitiveMedia,
|
||||||
@ -102,21 +98,21 @@ class CachedTimelineViewModel @Inject constructor(
|
|||||||
// handled by CacheUpdater
|
// handled by CacheUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) {
|
override fun changeExpanded(expanded: Boolean, status: StatusViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(pachliAccountId, status.copy(isExpanded = expanded))
|
repository.saveStatusViewData(status.copy(isExpanded = expanded))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData) {
|
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(pachliAccountId, status.copy(isShowingContent = isShowing))
|
repository.saveStatusViewData(status.copy(isShowingContent = isShowing))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData) {
|
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(pachliAccountId, status.copy(isCollapsed = isCollapsed))
|
repository.saveStatusViewData(status.copy(isCollapsed = isCollapsed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,9 +128,9 @@ class CachedTimelineViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData) {
|
override fun clearWarning(statusViewData: StatusViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.clearStatusWarning(pachliAccountId, statusViewData.actionableId)
|
repository.clearStatusWarning(statusViewData.pachliAccountId, statusViewData.actionableId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,20 +154,6 @@ class CachedTimelineViewModel @Inject constructor(
|
|||||||
// handled by CacheUpdater
|
// handled by CacheUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reloadKeepingReadingPosition(pachliAccountId: Long) {
|
|
||||||
super.reloadKeepingReadingPosition(pachliAccountId)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.clearAndReload(pachliAccountId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reloadFromNewest(pachliAccountId: Long) {
|
|
||||||
super.reloadFromNewest(pachliAccountId)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.clearAndReloadFromNewest(pachliAccountId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun invalidate(pachliAccountId: Long) {
|
override suspend fun invalidate(pachliAccountId: Long) {
|
||||||
repository.invalidate(pachliAccountId)
|
repository.invalidate(pachliAccountId)
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,7 @@ import app.pachli.core.eventhub.PinEvent
|
|||||||
import app.pachli.core.eventhub.ReblogEvent
|
import app.pachli.core.eventhub.ReblogEvent
|
||||||
import app.pachli.core.model.FilterAction
|
import app.pachli.core.model.FilterAction
|
||||||
import app.pachli.core.network.model.Poll
|
import app.pachli.core.network.model.Poll
|
||||||
|
import app.pachli.core.network.model.Status
|
||||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||||
import app.pachli.usecase.TimelineCases
|
import app.pachli.usecase.TimelineCases
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@ -59,11 +60,12 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||||
sharedPreferencesRepository: SharedPreferencesRepository,
|
sharedPreferencesRepository: SharedPreferencesRepository,
|
||||||
) : TimelineViewModel(
|
) : TimelineViewModel<Status>(
|
||||||
savedStateHandle,
|
savedStateHandle,
|
||||||
timelineCases,
|
timelineCases,
|
||||||
eventHub,
|
eventHub,
|
||||||
accountManager,
|
accountManager,
|
||||||
|
repository,
|
||||||
statusDisplayOptionsRepository,
|
statusDisplayOptionsRepository,
|
||||||
sharedPreferencesRepository,
|
sharedPreferencesRepository,
|
||||||
) {
|
) {
|
||||||
@ -72,22 +74,20 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||||||
override var statuses: Flow<PagingData<StatusViewData>>
|
override var statuses: Flow<PagingData<StatusViewData>>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
statuses = refreshFlow
|
statuses = accountFlow
|
||||||
.flatMapLatest {
|
.flatMapLatest { getStatuses(it.data!!) }.cachedIn(viewModelScope)
|
||||||
getStatuses(it.second, initialKey = getInitialKey())
|
|
||||||
}.cachedIn(viewModelScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
|
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
|
||||||
private fun getStatuses(
|
private suspend fun getStatuses(
|
||||||
account: AccountEntity,
|
account: AccountEntity,
|
||||||
initialKey: String? = null,
|
|
||||||
): Flow<PagingData<StatusViewData>> {
|
): Flow<PagingData<StatusViewData>> {
|
||||||
Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey)
|
Timber.d("getStatuses: kind: %s", timeline)
|
||||||
return repository.getStatusStream(account, kind = timeline, initialKey = initialKey)
|
return repository.getStatusStream(account, kind = timeline)
|
||||||
.map { pagingData ->
|
.map { pagingData ->
|
||||||
pagingData.map {
|
pagingData.map {
|
||||||
modifiedViewData[it.id] ?: StatusViewData.from(
|
modifiedViewData[it.id] ?: StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
it,
|
it,
|
||||||
isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive,
|
isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive,
|
||||||
isExpanded = statusDisplayOptions.value.openSpoiler,
|
isExpanded = statusDisplayOptions.value.openSpoiler,
|
||||||
@ -105,21 +105,21 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||||||
repository.invalidate()
|
repository.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData) {
|
override fun changeExpanded(expanded: Boolean, status: StatusViewData) {
|
||||||
modifiedViewData[status.id] = status.copy(
|
modifiedViewData[status.id] = status.copy(
|
||||||
isExpanded = expanded,
|
isExpanded = expanded,
|
||||||
)
|
)
|
||||||
repository.invalidate()
|
repository.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData) {
|
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) {
|
||||||
modifiedViewData[status.id] = status.copy(
|
modifiedViewData[status.id] = status.copy(
|
||||||
isShowingContent = isShowing,
|
isShowingContent = isShowing,
|
||||||
)
|
)
|
||||||
repository.invalidate()
|
repository.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData) {
|
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) {
|
||||||
Timber.d("changeContentCollapsed: %s", isCollapsed)
|
Timber.d("changeContentCollapsed: %s", isCollapsed)
|
||||||
Timber.d(" %s", status.content)
|
Timber.d(" %s", status.content)
|
||||||
modifiedViewData[status.id] = status.copy(
|
modifiedViewData[status.id] = status.copy(
|
||||||
@ -181,19 +181,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||||||
repository.invalidate()
|
repository.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reloadKeepingReadingPosition(pachliAccountId: Long) {
|
override fun clearWarning(statusViewData: StatusViewData) {
|
||||||
super.reloadKeepingReadingPosition(pachliAccountId)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reloadFromNewest(pachliAccountId: Long) {
|
|
||||||
super.reloadFromNewest(pachliAccountId)
|
|
||||||
reloadKeepingReadingPosition(pachliAccountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateActionableStatusById(statusViewData.actionableId) {
|
repository.updateActionableStatusById(statusViewData.actionableId) {
|
||||||
it.copy(filtered = null)
|
it.copy(filtered = null)
|
||||||
|
@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
package app.pachli.components.timeline.viewmodel
|
package app.pachli.components.timeline.viewmodel
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
@ -26,6 +25,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import app.pachli.R
|
import app.pachli.R
|
||||||
|
import app.pachli.components.timeline.TimelineRepository
|
||||||
import app.pachli.core.common.extensions.throttleFirst
|
import app.pachli.core.common.extensions.throttleFirst
|
||||||
import app.pachli.core.data.model.StatusViewData
|
import app.pachli.core.data.model.StatusViewData
|
||||||
import app.pachli.core.data.repository.AccountManager
|
import app.pachli.core.data.repository.AccountManager
|
||||||
@ -58,21 +58,21 @@ import app.pachli.core.preferences.TabTapBehaviour
|
|||||||
import app.pachli.network.ContentFilterModel
|
import app.pachli.network.ContentFilterModel
|
||||||
import app.pachli.usecase.TimelineCases
|
import app.pachli.usecase.TimelineCases
|
||||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||||
|
import com.github.michaelbull.result.Err
|
||||||
|
import com.github.michaelbull.result.Ok
|
||||||
|
import com.github.michaelbull.result.Result
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.fold
|
import kotlinx.coroutines.flow.fold
|
||||||
import kotlinx.coroutines.flow.getAndUpdate
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@ -114,7 +114,10 @@ sealed interface InfallibleUiAction : UiAction {
|
|||||||
* Infallible because if it fails there's nowhere to show the error, and nothing the user
|
* Infallible because if it fails there's nowhere to show the error, and nothing the user
|
||||||
* can do.
|
* can do.
|
||||||
*/
|
*/
|
||||||
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction
|
data class SaveVisibleId(
|
||||||
|
val pachliAccountId: Long,
|
||||||
|
val visibleId: String,
|
||||||
|
) : InfallibleUiAction
|
||||||
|
|
||||||
/** Ignore the saved reading position, load the page with the newest items */
|
/** Ignore the saved reading position, load the page with the newest items */
|
||||||
// Resets the account's reading position, which can't fail, which is why this is
|
// Resets the account's reading position, which can't fail, which is why this is
|
||||||
@ -145,6 +148,12 @@ sealed interface UiSuccess {
|
|||||||
|
|
||||||
/** A status the user wrote was successfully edited */
|
/** A status the user wrote was successfully edited */
|
||||||
data class StatusEdited(val status: Status) : UiSuccess
|
data class StatusEdited(val status: Status) : UiSuccess
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resetting the reading position completed, the UI should refresh the adapter
|
||||||
|
* to load content at the new position.
|
||||||
|
*/
|
||||||
|
data object LoadNewest : UiSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actions the user can trigger on an individual status */
|
/** Actions the user can trigger on an individual status */
|
||||||
@ -268,11 +277,12 @@ sealed interface UiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class TimelineViewModel(
|
abstract class TimelineViewModel<T : Any>(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val eventHub: EventHub,
|
private val eventHub: EventHub,
|
||||||
protected val accountManager: AccountManager,
|
protected val accountManager: AccountManager,
|
||||||
|
private val repository: TimelineRepository<T>,
|
||||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@ -286,26 +296,8 @@ abstract class TimelineViewModel(
|
|||||||
/** Flow of user actions received from the UI */
|
/** Flow of user actions received from the UI */
|
||||||
private val uiAction = MutableSharedFlow<UiAction>()
|
private val uiAction = MutableSharedFlow<UiAction>()
|
||||||
|
|
||||||
/** Flow that can be used to trigger a full reload */
|
private val _uiResult = Channel<Result<UiSuccess, UiError>>()
|
||||||
protected val reload = MutableStateFlow(0)
|
val uiResult = _uiResult.receiveAsFlow()
|
||||||
|
|
||||||
/** Flow of successful action results */
|
|
||||||
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
|
|
||||||
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
|
|
||||||
// (e.g., after a device orientation change) should not re-show the most recent success
|
|
||||||
// message, as it will be confusing to the user.
|
|
||||||
val uiSuccess = MutableSharedFlow<UiSuccess>()
|
|
||||||
|
|
||||||
@Suppress("ktlint:standard:property-naming")
|
|
||||||
/** Channel for error results */
|
|
||||||
// Errors are sent to a channel to ensure that any errors that occur *before* there are any
|
|
||||||
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
|
|
||||||
// was a StateFlow any errors would be retained, and there would need to be an explicit
|
|
||||||
// mechanism to dismiss them.
|
|
||||||
private val _uiErrorChannel = Channel<UiError>()
|
|
||||||
|
|
||||||
/** Expose UI errors as a flow */
|
|
||||||
val uiError = _uiErrorChannel.receiveAsFlow()
|
|
||||||
|
|
||||||
/** Accept UI actions in to actionStateFlow */
|
/** Accept UI actions in to actionStateFlow */
|
||||||
val accept: (UiAction) -> Unit = { action ->
|
val accept: (UiAction) -> Unit = { action ->
|
||||||
@ -323,18 +315,10 @@ abstract class TimelineViewModel(
|
|||||||
return accountManager.activeAccount!!
|
return accountManager.activeAccount!!
|
||||||
}
|
}
|
||||||
|
|
||||||
protected val refreshFlow = reload.combine(
|
protected val accountFlow = accountManager.activeAccountFlow
|
||||||
accountManager.activeAccountFlow
|
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||||
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
.filter { it.data != null }
|
||||||
.filter { it.data != null }
|
.distinctUntilChangedBy { it.data?.id!! }
|
||||||
.distinctUntilChangedBy { it.data?.id!! },
|
|
||||||
) { refresh, account -> Pair(refresh, account.data!!) }
|
|
||||||
|
|
||||||
/** The ID of the status to which the user's reading position should be restored */
|
|
||||||
// Not part of the UiState as it's only used once in the lifespan of the fragment.
|
|
||||||
// Subclasses should set this if they support restoring the reading position.
|
|
||||||
open var readingPositionId: String? = null
|
|
||||||
protected set
|
|
||||||
|
|
||||||
private var contentFilterModel: ContentFilterModel? = null
|
private var contentFilterModel: ContentFilterModel? = null
|
||||||
|
|
||||||
@ -348,9 +332,7 @@ abstract class TimelineViewModel(
|
|||||||
ContentFilterVersion.V2 -> ContentFilterModel(filterContext)
|
ContentFilterVersion.V2 -> ContentFilterModel(filterContext)
|
||||||
ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters)
|
ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters)
|
||||||
}
|
}
|
||||||
if (reload) {
|
if (reload) repository.invalidate(account.id)
|
||||||
reloadKeepingReadingPosition(account.id)
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -385,12 +367,15 @@ abstract class TimelineViewModel(
|
|||||||
action.choices,
|
action.choices,
|
||||||
)
|
)
|
||||||
is StatusAction.Translate -> {
|
is StatusAction.Translate -> {
|
||||||
timelineCases.translate(activeAccount.id, action.statusViewData)
|
timelineCases.translate(action.statusViewData)
|
||||||
}
|
}
|
||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
uiSuccess.emit(StatusActionSuccess.from(action))
|
// TODO: This should look like the equivalent code in
|
||||||
|
// NotificationsViewModel when timelineCases returns
|
||||||
|
// Result<_, _> instead of NetworkResult.
|
||||||
|
_uiResult.send(Ok(StatusActionSuccess.from(action)))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiErrorChannel.send(UiError.make(e, action))
|
_uiResult.send(Err(UiError.make(e, action)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -399,11 +384,11 @@ abstract class TimelineViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventHub.events.collectLatest {
|
eventHub.events.collectLatest {
|
||||||
when (it) {
|
when (it) {
|
||||||
is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
|
is BlockEvent -> _uiResult.send(Ok(UiSuccess.Block))
|
||||||
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
|
is MuteEvent -> _uiResult.send(Ok(UiSuccess.Mute))
|
||||||
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
|
is MuteConversationEvent -> _uiResult.send(Ok(UiSuccess.MuteConversation))
|
||||||
is StatusComposedEvent -> uiSuccess.emit(UiSuccess.StatusSent(it.status))
|
is StatusComposedEvent -> _uiResult.send(Ok(UiSuccess.StatusSent(it.status)))
|
||||||
is StatusEditedEvent -> uiSuccess.emit(UiSuccess.StatusEdited(it.status))
|
is StatusEditedEvent -> _uiResult.send(Ok(UiSuccess.StatusEdited(it.status)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -455,8 +440,7 @@ abstract class TimelineViewModel(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collectLatest { action ->
|
.collectLatest { action ->
|
||||||
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId)
|
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId)
|
||||||
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, action.visibleId)
|
timelineCases.saveRefreshKey(activeAccount.id, action.visibleId)
|
||||||
readingPositionId = action.visibleId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -467,10 +451,10 @@ abstract class TimelineViewModel(
|
|||||||
.filterIsInstance<InfallibleUiAction.LoadNewest>()
|
.filterIsInstance<InfallibleUiAction.LoadNewest>()
|
||||||
.collectLatest {
|
.collectLatest {
|
||||||
if (timeline == Timeline.Home) {
|
if (timeline == Timeline.Home) {
|
||||||
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, null)
|
timelineCases.saveRefreshKey(activeAccount.id, null)
|
||||||
}
|
}
|
||||||
Timber.d("Reload because InfallibleUiAction.LoadNewest")
|
Timber.d("Reload because InfallibleUiAction.LoadNewest")
|
||||||
reloadFromNewest(activeAccount.id)
|
_uiResult.send(Ok(UiSuccess.LoadNewest))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -481,27 +465,16 @@ abstract class TimelineViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { eventHub.events.collect { handleEvent(it) } }
|
||||||
eventHub.events
|
|
||||||
.collect { event -> handleEvent(event) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getInitialKey(): String? {
|
|
||||||
if (timeline != Timeline.Home) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeAccount.lastVisibleHomeTimelineStatusId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun updatePoll(newPoll: Poll, status: StatusViewData)
|
abstract fun updatePoll(newPoll: Poll, status: StatusViewData)
|
||||||
|
|
||||||
abstract fun changeExpanded(pachliAccountId: Long, expanded: Boolean, status: StatusViewData)
|
abstract fun changeExpanded(expanded: Boolean, status: StatusViewData)
|
||||||
|
|
||||||
abstract fun changeContentShowing(pachliAccountId: Long, isShowing: Boolean, status: StatusViewData)
|
abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData)
|
||||||
|
|
||||||
abstract fun changeContentCollapsed(pachliAccountId: Long, isCollapsed: Boolean, status: StatusViewData)
|
abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData)
|
||||||
|
|
||||||
abstract fun removeAllByAccountId(pachliAccountId: Long, accountId: String)
|
abstract fun removeAllByAccountId(pachliAccountId: Long, accountId: String)
|
||||||
|
|
||||||
@ -517,27 +490,7 @@ abstract class TimelineViewModel(
|
|||||||
|
|
||||||
abstract fun handlePinEvent(pinEvent: PinEvent)
|
abstract fun handlePinEvent(pinEvent: PinEvent)
|
||||||
|
|
||||||
/**
|
abstract fun clearWarning(statusViewData: StatusViewData)
|
||||||
* Reload data for this timeline while preserving the user's reading position.
|
|
||||||
*
|
|
||||||
* Subclasses should call this, then start loading data.
|
|
||||||
*/
|
|
||||||
@CallSuper
|
|
||||||
open fun reloadKeepingReadingPosition(pachliAccountId: Long) {
|
|
||||||
reload.getAndUpdate { it + 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the most recent data for this timeline, ignoring the user's reading position.
|
|
||||||
*
|
|
||||||
* Subclasses should call this, then start loading data.
|
|
||||||
*/
|
|
||||||
@CallSuper
|
|
||||||
open fun reloadFromNewest(pachliAccountId: Long) {
|
|
||||||
reload.getAndUpdate { it + 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun clearWarning(pachliAccountId: Long, statusViewData: StatusViewData)
|
|
||||||
|
|
||||||
/** Triggered when currently displayed data must be reloaded. */
|
/** Triggered when currently displayed data must be reloaded. */
|
||||||
protected abstract suspend fun invalidate(pachliAccountId: Long)
|
protected abstract suspend fun invalidate(pachliAccountId: Long)
|
||||||
@ -556,7 +509,7 @@ abstract class TimelineViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update this so that the list of UIPrefs is correct
|
// TODO: Update this so that the list of UIPrefs is correct
|
||||||
private fun onPreferenceChanged(key: String) {
|
private suspend fun onPreferenceChanged(key: String) {
|
||||||
when (key) {
|
when (key) {
|
||||||
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
|
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
|
||||||
val filter = sharedPreferencesRepository.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
val filter = sharedPreferencesRepository.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||||
@ -564,7 +517,7 @@ abstract class TimelineViewModel(
|
|||||||
filterRemoveReplies = timeline is Timeline.Home && !filter
|
filterRemoveReplies = timeline is Timeline.Home && !filter
|
||||||
if (oldRemoveReplies != filterRemoveReplies) {
|
if (oldRemoveReplies != filterRemoveReplies) {
|
||||||
Timber.d("Reload because TAB_FILTER_HOME_REPLIES changed")
|
Timber.d("Reload because TAB_FILTER_HOME_REPLIES changed")
|
||||||
reloadKeepingReadingPosition(activeAccount.id)
|
repository.invalidate(activeAccount.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
|
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
|
||||||
@ -573,7 +526,7 @@ abstract class TimelineViewModel(
|
|||||||
filterRemoveReblogs = timeline is Timeline.Home && !filter
|
filterRemoveReblogs = timeline is Timeline.Home && !filter
|
||||||
if (oldRemoveReblogs != filterRemoveReblogs) {
|
if (oldRemoveReblogs != filterRemoveReblogs) {
|
||||||
Timber.d("Reload because TAB_FILTER_HOME_BOOSTS changed")
|
Timber.d("Reload because TAB_FILTER_HOME_BOOSTS changed")
|
||||||
reloadKeepingReadingPosition(activeAccount.id)
|
repository.invalidate(activeAccount.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> {
|
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> {
|
||||||
@ -582,13 +535,13 @@ abstract class TimelineViewModel(
|
|||||||
filterRemoveSelfReblogs = timeline is Timeline.Home && !filter
|
filterRemoveSelfReblogs = timeline is Timeline.Home && !filter
|
||||||
if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) {
|
if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) {
|
||||||
Timber.d("Reload because TAB_SHOW_SOME_SELF_BOOSTS changed")
|
Timber.d("Reload because TAB_SHOW_SOME_SELF_BOOSTS changed")
|
||||||
reloadKeepingReadingPosition(activeAccount.id)
|
repository.invalidate(activeAccount.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleEvent(event: Event) {
|
private suspend fun handleEvent(event: Event) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is FavoriteEvent -> handleFavEvent(event)
|
is FavoriteEvent -> handleFavEvent(event)
|
||||||
is ReblogEvent -> handleReblogEvent(event)
|
is ReblogEvent -> handleReblogEvent(event)
|
||||||
@ -596,7 +549,7 @@ abstract class TimelineViewModel(
|
|||||||
is PinEvent -> handlePinEvent(event)
|
is PinEvent -> handlePinEvent(event)
|
||||||
is MuteConversationEvent -> {
|
is MuteConversationEvent -> {
|
||||||
Timber.d("Reload because MuteConversationEvent")
|
Timber.d("Reload because MuteConversationEvent")
|
||||||
reloadKeepingReadingPosition(activeAccount.id)
|
repository.invalidate(activeAccount.id)
|
||||||
}
|
}
|
||||||
is UnfollowEvent -> {
|
is UnfollowEvent -> {
|
||||||
if (timeline is Timeline.Home) {
|
if (timeline is Timeline.Home) {
|
||||||
|
@ -33,7 +33,6 @@ import app.pachli.databinding.ItemStatusWrapperBinding
|
|||||||
import app.pachli.interfaces.StatusActionListener
|
import app.pachli.interfaces.StatusActionListener
|
||||||
|
|
||||||
class ThreadAdapter(
|
class ThreadAdapter(
|
||||||
private val pachliAccountId: Long,
|
|
||||||
private val statusDisplayOptions: StatusDisplayOptions,
|
private val statusDisplayOptions: StatusDisplayOptions,
|
||||||
private val statusActionListener: StatusActionListener<StatusViewData>,
|
private val statusActionListener: StatusActionListener<StatusViewData>,
|
||||||
) : ListAdapter<StatusViewData, StatusBaseViewHolder<StatusViewData>>(ThreadDifferCallback) {
|
) : ListAdapter<StatusViewData, StatusBaseViewHolder<StatusViewData>>(ThreadDifferCallback) {
|
||||||
@ -56,7 +55,7 @@ class ThreadAdapter(
|
|||||||
|
|
||||||
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder<StatusViewData>, position: Int) {
|
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder<StatusViewData>, position: Int) {
|
||||||
val status = getItem(position)
|
val status = getItem(position)
|
||||||
viewHolder.setupWithStatus(pachliAccountId, status, statusActionListener, statusDisplayOptions)
|
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
@ -96,7 +96,7 @@ class ViewThreadFragment :
|
|||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val statusDisplayOptions = viewModel.statusDisplayOptions.value
|
val statusDisplayOptions = viewModel.statusDisplayOptions.value
|
||||||
adapter = ThreadAdapter(pachliAccountId, statusDisplayOptions, this@ViewThreadFragment)
|
adapter = ThreadAdapter(statusDisplayOptions, this@ViewThreadFragment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,8 +279,8 @@ class ViewThreadFragment :
|
|||||||
viewModel.refresh(thisThreadsStatusId)
|
viewModel.refresh(thisThreadsStatusId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
|
override fun onReply(viewData: StatusViewData) {
|
||||||
super.reply(pachliAccountId, viewData.actionable)
|
super.reply(viewData.pachliAccountId, viewData.actionable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
||||||
@ -339,11 +339,11 @@ class ViewThreadFragment :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(pachliAccountId: Long, viewData: StatusViewData, expanded: Boolean) {
|
override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) {
|
||||||
viewModel.changeExpanded(expanded, viewData)
|
viewModel.changeExpanded(expanded, viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentHiddenChange(pachliAccountId: Long, viewData: StatusViewData, isShowingContent: Boolean) {
|
override fun onContentHiddenChange(viewData: StatusViewData, isShowingContent: Boolean) {
|
||||||
viewModel.changeContentShowing(isShowingContent, viewData)
|
viewModel.changeContentShowing(isShowingContent, viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,7 +357,7 @@ class ViewThreadFragment :
|
|||||||
activity?.startActivityWithDefaultTransition(intent)
|
activity?.startActivityWithDefaultTransition(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(pachliAccountId: Long, viewData: StatusViewData, isCollapsed: Boolean) {
|
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
|
||||||
viewModel.changeContentCollapsed(isCollapsed, viewData)
|
viewModel.changeContentCollapsed(isCollapsed, viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,7 +397,7 @@ class ViewThreadFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearContentFilter(pachliAccountId: Long, viewData: StatusViewData) {
|
override fun clearContentFilter(viewData: StatusViewData) {
|
||||||
viewModel.clearWarning(viewData)
|
viewModel.clearWarning(viewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,6 +147,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
// status content is the same. Then the status flickers as it is drawn twice.
|
// status content is the same. Then the status flickers as it is drawn twice.
|
||||||
if (status.actionableId == id) {
|
if (status.actionableId == id) {
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
status = status.actionableStatus,
|
status = status.actionableStatus,
|
||||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler,
|
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler,
|
||||||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||||
@ -157,6 +158,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
timelineStatusWithAccount,
|
timelineStatusWithAccount,
|
||||||
isExpanded = account.alwaysOpenSpoiler,
|
isExpanded = account.alwaysOpenSpoiler,
|
||||||
isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||||
@ -186,6 +188,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
if (timelineStatusWithAccount != null) {
|
if (timelineStatusWithAccount != null) {
|
||||||
api.status(id).getOrNull()?.let {
|
api.status(id).getOrNull()?.let {
|
||||||
detailedStatus = StatusViewData.from(
|
detailedStatus = StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
it,
|
it,
|
||||||
isShowingContent = detailedStatus.isShowingContent,
|
isShowingContent = detailedStatus.isShowingContent,
|
||||||
isExpanded = detailedStatus.isExpanded,
|
isExpanded = detailedStatus.isExpanded,
|
||||||
@ -207,6 +210,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
val ancestors = statusContext.ancestors.map { status ->
|
val ancestors = statusContext.ancestors.map { status ->
|
||||||
val svd = cachedViewData[status.id]
|
val svd = cachedViewData[status.id]
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
status,
|
status,
|
||||||
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||||
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
|
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
|
||||||
@ -219,6 +223,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
val descendants = statusContext.descendants.map { status ->
|
val descendants = statusContext.descendants.map { status ->
|
||||||
val svd = cachedViewData[status.id]
|
val svd = cachedViewData[status.id]
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
status,
|
status,
|
||||||
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||||
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
|
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
|
||||||
@ -336,7 +341,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(activeAccount.id, status.copy(isExpanded = expanded))
|
repository.saveStatusViewData(status.copy(isExpanded = expanded))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,7 +350,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
viewData.copy(isShowingContent = isShowing)
|
viewData.copy(isShowingContent = isShowing)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(activeAccount.id, status.copy(isShowingContent = isShowing))
|
repository.saveStatusViewData(status.copy(isShowingContent = isShowing))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,7 +359,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
viewData.copy(isCollapsed = isCollapsed)
|
viewData.copy(isCollapsed = isCollapsed)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(activeAccount.id, status.copy(isCollapsed = isCollapsed))
|
repository.saveStatusViewData(status.copy(isCollapsed = isCollapsed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,11 +461,11 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun translate(statusViewData: StatusViewData) {
|
fun translate(statusViewData: StatusViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.translate(activeAccount.id, statusViewData).fold(
|
repository.translate(statusViewData).fold(
|
||||||
{
|
{
|
||||||
val translatedEntity = TranslatedStatusEntity(
|
val translatedEntity = TranslatedStatusEntity(
|
||||||
serverId = statusViewData.actionableId,
|
serverId = statusViewData.actionableId,
|
||||||
timelineUserId = activeAccount.id,
|
timelineUserId = statusViewData.pachliAccountId,
|
||||||
content = it.content,
|
content = it.content,
|
||||||
spoilerText = it.spoilerText,
|
spoilerText = it.spoilerText,
|
||||||
poll = it.poll,
|
poll = it.poll,
|
||||||
@ -490,7 +495,6 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveStatusViewData(
|
repository.saveStatusViewData(
|
||||||
activeAccount.id,
|
|
||||||
statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL),
|
statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -563,6 +567,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||||||
private fun StatusViewData.Companion.fromStatusAndUiState(account: AccountEntity, status: Status, isDetailed: Boolean = false): StatusViewData {
|
private fun StatusViewData.Companion.fromStatusAndUiState(account: AccountEntity, status: Status, isDetailed: Boolean = false): StatusViewData {
|
||||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id }
|
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id }
|
||||||
return from(
|
return from(
|
||||||
|
pachliAccountId = account.id,
|
||||||
status,
|
status,
|
||||||
isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||||
isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler,
|
isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler,
|
||||||
|
@ -24,7 +24,7 @@ import app.pachli.core.network.model.Status
|
|||||||
import app.pachli.core.ui.LinkListener
|
import app.pachli.core.ui.LinkListener
|
||||||
|
|
||||||
interface StatusActionListener<T : IStatusViewData> : LinkListener {
|
interface StatusActionListener<T : IStatusViewData> : LinkListener {
|
||||||
fun onReply(pachliAccountId: Long, viewData: T)
|
fun onReply(viewData: T)
|
||||||
fun onReblog(viewData: T, reblog: Boolean)
|
fun onReblog(viewData: T, reblog: Boolean)
|
||||||
fun onFavourite(viewData: T, favourite: Boolean)
|
fun onFavourite(viewData: T, favourite: Boolean)
|
||||||
fun onBookmark(viewData: T, bookmark: Boolean)
|
fun onBookmark(viewData: T, bookmark: Boolean)
|
||||||
@ -36,8 +36,8 @@ interface StatusActionListener<T : IStatusViewData> : LinkListener {
|
|||||||
* Open reblog author for the status.
|
* Open reblog author for the status.
|
||||||
*/
|
*/
|
||||||
fun onOpenReblog(status: Status)
|
fun onOpenReblog(status: Status)
|
||||||
fun onExpandedChange(pachliAccountId: Long, viewData: T, expanded: Boolean)
|
fun onExpandedChange(viewData: T, expanded: Boolean)
|
||||||
fun onContentHiddenChange(pachliAccountId: Long, viewData: T, isShowingContent: Boolean)
|
fun onContentHiddenChange(viewData: T, isShowingContent: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
|
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
|
||||||
@ -45,7 +45,7 @@ interface StatusActionListener<T : IStatusViewData> : LinkListener {
|
|||||||
*
|
*
|
||||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
||||||
*/
|
*/
|
||||||
fun onContentCollapsedChange(pachliAccountId: Long, viewData: T, isCollapsed: Boolean)
|
fun onContentCollapsedChange(viewData: T, isCollapsed: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* called when the reblog count has been clicked
|
* called when the reblog count has been clicked
|
||||||
@ -60,7 +60,7 @@ interface StatusActionListener<T : IStatusViewData> : LinkListener {
|
|||||||
fun onShowEdits(statusId: String) {}
|
fun onShowEdits(statusId: String) {}
|
||||||
|
|
||||||
/** Remove the content filter from the status. */
|
/** Remove the content filter from the status. */
|
||||||
fun clearContentFilter(pachliAccountId: Long, viewData: T)
|
fun clearContentFilter(viewData: T)
|
||||||
|
|
||||||
/** Edit the filter that matched this status. */
|
/** Edit the filter that matched this status. */
|
||||||
fun onEditFilterById(pachliAccountId: Long, filterId: String)
|
fun onEditFilterById(pachliAccountId: Long, filterId: String)
|
||||||
|
@ -33,7 +33,7 @@ class DeveloperToolsUseCase @Inject constructor(
|
|||||||
* Clear the home timeline cache.
|
* Clear the home timeline cache.
|
||||||
*/
|
*/
|
||||||
suspend fun clearHomeTimelineCache(accountId: Long) {
|
suspend fun clearHomeTimelineCache(accountId: Long) {
|
||||||
timelineDao.removeAllStatuses(accountId)
|
timelineDao.deleteAllStatusesForAccount(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -143,11 +143,15 @@ class TimelineCases @Inject constructor(
|
|||||||
return mastodonApi.rejectFollowRequest(accountId)
|
return mastodonApi.rejectFollowRequest(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun translate(pachliAccountId: Long, statusViewData: StatusViewData): NetworkResult<Translation> {
|
suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
|
||||||
return cachedTimelineRepository.translate(pachliAccountId, statusViewData)
|
return cachedTimelineRepository.translate(statusViewData)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) {
|
suspend fun translateUndo(pachliAccountId: Long, statusViewData: StatusViewData) {
|
||||||
cachedTimelineRepository.translateUndo(pachliAccountId, statusViewData)
|
cachedTimelineRepository.translateUndo(statusViewData)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRefreshKey(pachliAccountId: Long, statusId: String?) {
|
||||||
|
cachedTimelineRepository.saveRefreshKey(pachliAccountId, statusId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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")
|
|
||||||
}
|
|
@ -113,7 +113,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
|
|||||||
when (action) {
|
when (action) {
|
||||||
app.pachli.core.ui.R.id.action_reply -> {
|
app.pachli.core.ui.R.id.action_reply -> {
|
||||||
interrupt()
|
interrupt()
|
||||||
statusActionListener.onReply(pachliAccountId, status)
|
statusActionListener.onReply(status)
|
||||||
}
|
}
|
||||||
app.pachli.core.ui.R.id.action_favourite -> statusActionListener.onFavourite(status, true)
|
app.pachli.core.ui.R.id.action_favourite -> statusActionListener.onFavourite(status, true)
|
||||||
app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false)
|
app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false)
|
||||||
@ -152,7 +152,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
|
|||||||
forceFocus(host)
|
forceFocus(host)
|
||||||
}
|
}
|
||||||
app.pachli.core.ui.R.id.action_collapse_cw -> {
|
app.pachli.core.ui.R.id.action_collapse_cw -> {
|
||||||
statusActionListener.onExpandedChange(pachliAccountId, status, false)
|
statusActionListener.onExpandedChange(status, false)
|
||||||
interrupt()
|
interrupt()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +201,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
|
|||||||
app.pachli.core.ui.R.id.action_more -> {
|
app.pachli.core.ui.R.id.action_more -> {
|
||||||
statusActionListener.onMore(host, status)
|
statusActionListener.onMore(host, status)
|
||||||
}
|
}
|
||||||
app.pachli.core.ui.R.id.action_show_anyway -> statusActionListener.clearContentFilter(pachliAccountId, status)
|
app.pachli.core.ui.R.id.action_show_anyway -> statusActionListener.clearContentFilter(status)
|
||||||
app.pachli.core.ui.R.id.action_edit_filter -> {
|
app.pachli.core.ui.R.id.action_edit_filter -> {
|
||||||
(recyclerView.findContainingViewHolder(host) as? FilterableStatusViewHolder<*>)?.matchedFilter?.let {
|
(recyclerView.findContainingViewHolder(host) as? FilterableStatusViewHolder<*>)?.matchedFilter?.let {
|
||||||
statusActionListener.onEditFilterById(pachliAccountId, it.id)
|
statusActionListener.onEditFilterById(pachliAccountId, it.id)
|
||||||
|
@ -55,7 +55,7 @@ import app.pachli.core.network.model.TimelineAccount
|
|||||||
* because of the account that sent it, and why.
|
* because of the account that sent it, and why.
|
||||||
*/
|
*/
|
||||||
data class NotificationViewData(
|
data class NotificationViewData(
|
||||||
val pachliAccountId: Long,
|
override val pachliAccountId: Long,
|
||||||
val localDomain: String,
|
val localDomain: String,
|
||||||
val type: NotificationEntity.Type,
|
val type: NotificationEntity.Type,
|
||||||
val id: String,
|
val id: String,
|
||||||
@ -93,6 +93,7 @@ data class NotificationViewData(
|
|||||||
account = data.account.toTimelineAccount(),
|
account = data.account.toTimelineAccount(),
|
||||||
statusViewData = data.status?.let {
|
statusViewData = data.status?.let {
|
||||||
StatusViewData.from(
|
StatusViewData.from(
|
||||||
|
pachliAccountId = pachliAccountEntity.id,
|
||||||
it,
|
it,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
isShowingContent = isShowingContent,
|
isShowingContent = isShowingContent,
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
@ -9,6 +11,17 @@
|
|||||||
android:layout_gravity="center_horizontal"
|
android:layout_gravity="center_horizontal"
|
||||||
android:background="?android:attr/colorBackground">
|
android:background="?android:attr/colorBackground">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/progressIndicator"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription=""
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipeRefreshLayout"
|
android:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:contentDescription=""
|
android:contentDescription=""
|
||||||
android:indeterminate="true"
|
android:indeterminate="true"
|
||||||
android:visibility="visible"
|
android:visibility="gone"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
@ -1,8 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/progressIndicator"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription=""
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipeRefreshLayout"
|
android:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:contentDescription=""
|
android:contentDescription=""
|
||||||
android:indeterminate="true"
|
android:indeterminate="true"
|
||||||
android:visibility="visible"
|
android:visibility="gone"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
@ -54,6 +54,7 @@ class StatusComparisonTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `two equal status view data - should be equal`() {
|
fun `two equal status view data - should be equal`() {
|
||||||
val viewdata1 = StatusViewData(
|
val viewdata1 = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = createStatus(),
|
status = createStatus(),
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -61,6 +62,7 @@ class StatusComparisonTest {
|
|||||||
translationState = TranslationState.SHOW_ORIGINAL,
|
translationState = TranslationState.SHOW_ORIGINAL,
|
||||||
)
|
)
|
||||||
val viewdata2 = StatusViewData(
|
val viewdata2 = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = createStatus(),
|
status = createStatus(),
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -73,6 +75,7 @@ class StatusComparisonTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `status view data with different isExpanded - should not be equal`() {
|
fun `status view data with different isExpanded - should not be equal`() {
|
||||||
val viewdata1 = StatusViewData(
|
val viewdata1 = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = createStatus(),
|
status = createStatus(),
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -80,6 +83,7 @@ class StatusComparisonTest {
|
|||||||
translationState = TranslationState.SHOW_ORIGINAL,
|
translationState = TranslationState.SHOW_ORIGINAL,
|
||||||
)
|
)
|
||||||
val viewdata2 = StatusViewData(
|
val viewdata2 = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = createStatus(),
|
status = createStatus(),
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -92,6 +96,7 @@ class StatusComparisonTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `status view data with different statuses- should not be equal`() {
|
fun `status view data with different statuses- should not be equal`() {
|
||||||
val viewdata1 = StatusViewData(
|
val viewdata1 = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = createStatus(content = "whatever"),
|
status = createStatus(content = "whatever"),
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -99,6 +104,7 @@ class StatusComparisonTest {
|
|||||||
translationState = TranslationState.SHOW_ORIGINAL,
|
translationState = TranslationState.SHOW_ORIGINAL,
|
||||||
)
|
)
|
||||||
val viewdata2 = StatusViewData(
|
val viewdata2 = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = createStatus(),
|
status = createStatus(),
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
|
@ -31,7 +31,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.mockito.kotlin.any
|
import org.mockito.kotlin.any
|
||||||
import org.mockito.kotlin.argumentCaptor
|
|
||||||
import org.mockito.kotlin.doReturn
|
import org.mockito.kotlin.doReturn
|
||||||
import org.mockito.kotlin.stub
|
import org.mockito.kotlin.stub
|
||||||
|
|
||||||
@ -47,6 +46,7 @@ import org.mockito.kotlin.stub
|
|||||||
class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() {
|
class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() {
|
||||||
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
||||||
private val statusViewData = StatusViewData(
|
private val statusViewData = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = status,
|
status = status,
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -70,10 +70,6 @@ class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestB
|
|||||||
statusViewData,
|
statusViewData,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Captors for status ID and state arguments */
|
|
||||||
private val id = argumentCaptor<String>()
|
|
||||||
private val state = argumentCaptor<Boolean>()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bookmark succeeds && emits UiSuccess`() = runTest {
|
fun `bookmark succeeds && emits UiSuccess`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
|
@ -96,12 +96,10 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
@ExperimentalPagingApi
|
@ExperimentalPagingApi
|
||||||
fun `should return error when network call returns error code`() {
|
fun `should return error when network call returns error code`() {
|
||||||
val remoteMediator = CachedTimelineRemoteMediator(
|
val remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey = null,
|
mastodonApi = mock {
|
||||||
api = mock {
|
|
||||||
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
|
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
|
||||||
},
|
},
|
||||||
pachliAccountId = activeAccount.id,
|
pachliAccountId = activeAccount.id,
|
||||||
factory = pagingSourceFactory,
|
|
||||||
transactionProvider = transactionProvider,
|
transactionProvider = transactionProvider,
|
||||||
timelineDao = db.timelineDao(),
|
timelineDao = db.timelineDao(),
|
||||||
remoteKeyDao = db.remoteKeyDao(),
|
remoteKeyDao = db.remoteKeyDao(),
|
||||||
@ -118,12 +116,10 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
@ExperimentalPagingApi
|
@ExperimentalPagingApi
|
||||||
fun `should return error when network call fails`() {
|
fun `should return error when network call fails`() {
|
||||||
val remoteMediator = CachedTimelineRemoteMediator(
|
val remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey = null,
|
mastodonApi = mock {
|
||||||
api = mock {
|
|
||||||
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
|
onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
|
||||||
},
|
},
|
||||||
pachliAccountId = activeAccount.id,
|
pachliAccountId = activeAccount.id,
|
||||||
factory = pagingSourceFactory,
|
|
||||||
transactionProvider = transactionProvider,
|
transactionProvider = transactionProvider,
|
||||||
timelineDao = db.timelineDao(),
|
timelineDao = db.timelineDao(),
|
||||||
remoteKeyDao = db.remoteKeyDao(),
|
remoteKeyDao = db.remoteKeyDao(),
|
||||||
@ -139,10 +135,8 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
@ExperimentalPagingApi
|
@ExperimentalPagingApi
|
||||||
fun `should not prepend statuses`() {
|
fun `should not prepend statuses`() {
|
||||||
val remoteMediator = CachedTimelineRemoteMediator(
|
val remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey = null,
|
mastodonApi = mock(),
|
||||||
api = mock(),
|
|
||||||
pachliAccountId = activeAccount.id,
|
pachliAccountId = activeAccount.id,
|
||||||
factory = pagingSourceFactory,
|
|
||||||
transactionProvider = transactionProvider,
|
transactionProvider = transactionProvider,
|
||||||
timelineDao = db.timelineDao(),
|
timelineDao = db.timelineDao(),
|
||||||
remoteKeyDao = db.remoteKeyDao(),
|
remoteKeyDao = db.remoteKeyDao(),
|
||||||
@ -170,8 +164,7 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
@ExperimentalPagingApi
|
@ExperimentalPagingApi
|
||||||
fun `should not try to refresh already cached statuses when db is empty`() {
|
fun `should not try to refresh already cached statuses when db is empty`() {
|
||||||
val remoteMediator = CachedTimelineRemoteMediator(
|
val remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey = null,
|
mastodonApi = mock {
|
||||||
api = mock {
|
|
||||||
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
|
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
|
||||||
listOf(
|
listOf(
|
||||||
mockStatus("5"),
|
mockStatus("5"),
|
||||||
@ -181,7 +174,6 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
pachliAccountId = activeAccount.id,
|
pachliAccountId = activeAccount.id,
|
||||||
factory = pagingSourceFactory,
|
|
||||||
transactionProvider = transactionProvider,
|
transactionProvider = transactionProvider,
|
||||||
timelineDao = db.timelineDao(),
|
timelineDao = db.timelineDao(),
|
||||||
remoteKeyDao = db.remoteKeyDao(),
|
remoteKeyDao = db.remoteKeyDao(),
|
||||||
@ -224,8 +216,7 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
db.insert(statusesAlreadyInDb)
|
db.insert(statusesAlreadyInDb)
|
||||||
|
|
||||||
val remoteMediator = CachedTimelineRemoteMediator(
|
val remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey = null,
|
mastodonApi = mock {
|
||||||
api = mock {
|
|
||||||
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
|
onBlocking { homeTimeline(limit = 20) } doReturn Response.success(
|
||||||
listOf(
|
listOf(
|
||||||
mockStatus("3"),
|
mockStatus("3"),
|
||||||
@ -234,7 +225,6 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
pachliAccountId = activeAccount.id,
|
pachliAccountId = activeAccount.id,
|
||||||
factory = pagingSourceFactory,
|
|
||||||
transactionProvider = transactionProvider,
|
transactionProvider = transactionProvider,
|
||||||
timelineDao = db.timelineDao(),
|
timelineDao = db.timelineDao(),
|
||||||
remoteKeyDao = db.remoteKeyDao(),
|
remoteKeyDao = db.remoteKeyDao(),
|
||||||
@ -280,8 +270,7 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
db.remoteKeyDao().upsert(RemoteKeyEntity(1, RKE_TIMELINE_ID, RemoteKeyKind.NEXT, "5"))
|
db.remoteKeyDao().upsert(RemoteKeyEntity(1, RKE_TIMELINE_ID, RemoteKeyKind.NEXT, "5"))
|
||||||
|
|
||||||
val remoteMediator = CachedTimelineRemoteMediator(
|
val remoteMediator = CachedTimelineRemoteMediator(
|
||||||
initialKey = null,
|
mastodonApi = mock {
|
||||||
api = mock {
|
|
||||||
onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success(
|
onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success(
|
||||||
listOf(
|
listOf(
|
||||||
mockStatus("3"),
|
mockStatus("3"),
|
||||||
@ -294,7 +283,6 @@ class CachedTimelineRemoteMediatorTest {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
pachliAccountId = activeAccount.id,
|
pachliAccountId = activeAccount.id,
|
||||||
factory = pagingSourceFactory,
|
|
||||||
transactionProvider = transactionProvider,
|
transactionProvider = transactionProvider,
|
||||||
timelineDao = db.timelineDao(),
|
timelineDao = db.timelineDao(),
|
||||||
remoteKeyDao = db.remoteKeyDao(),
|
remoteKeyDao = db.remoteKeyDao(),
|
||||||
|
@ -103,7 +103,7 @@ abstract class CachedTimelineViewModelTestBase {
|
|||||||
lateinit var moshi: Moshi
|
lateinit var moshi: Moshi
|
||||||
|
|
||||||
protected lateinit var timelineCases: TimelineCases
|
protected lateinit var timelineCases: TimelineCases
|
||||||
protected lateinit var viewModel: TimelineViewModel
|
protected lateinit var viewModel: CachedTimelineViewModel
|
||||||
|
|
||||||
private val eventHub = EventHub()
|
private val eventHub = EventHub()
|
||||||
|
|
||||||
|
@ -25,6 +25,8 @@ import app.pachli.components.timeline.viewmodel.UiError
|
|||||||
import app.pachli.core.data.model.StatusViewData
|
import app.pachli.core.data.model.StatusViewData
|
||||||
import app.pachli.core.database.model.TranslationState
|
import app.pachli.core.database.model.TranslationState
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
|
import com.github.michaelbull.result.get
|
||||||
|
import com.github.michaelbull.result.getError
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@ -50,6 +52,7 @@ import org.mockito.kotlin.verify
|
|||||||
class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTestBase() {
|
class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTestBase() {
|
||||||
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
||||||
private val statusViewData = StatusViewData(
|
private val statusViewData = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = status,
|
status = status,
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -82,14 +85,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
|
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(bookmarkAction)
|
viewModel.accept(bookmarkAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.Bookmark
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java)
|
assertThat(item?.action).isEqualTo(bookmarkAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -103,14 +105,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(bookmarkAction)
|
viewModel.accept(bookmarkAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.Bookmark
|
||||||
assertThat(item).isInstanceOf(UiError.Bookmark::class.java)
|
assertThat(item?.action).isEqualTo(bookmarkAction)
|
||||||
assertThat(item.action).isEqualTo(bookmarkAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,14 +122,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
|
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(favouriteAction)
|
viewModel.accept(favouriteAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.Favourite
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java)
|
assertThat(item?.action).isEqualTo(favouriteAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -142,14 +142,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(favouriteAction)
|
viewModel.accept(favouriteAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.Favourite
|
||||||
assertThat(item).isInstanceOf(UiError.Favourite::class.java)
|
assertThat(item?.action).isEqualTo(favouriteAction)
|
||||||
assertThat(item.action).isEqualTo(favouriteAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,14 +157,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
|
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(reblogAction)
|
viewModel.accept(reblogAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.Reblog
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java)
|
assertThat(item?.action).isEqualTo(reblogAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -179,14 +177,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(reblogAction)
|
viewModel.accept(reblogAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.Reblog
|
||||||
assertThat(item).isInstanceOf(UiError.Reblog::class.java)
|
assertThat(item?.action).isEqualTo(reblogAction)
|
||||||
assertThat(item.action).isEqualTo(reblogAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,14 +194,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
|
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(voteInPollAction)
|
viewModel.accept(voteInPollAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.VoteInPoll
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java)
|
assertThat(item?.action).isEqualTo(voteInPollAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -221,14 +217,13 @@ class CachedTimelineViewModelTestStatusFilterAction : CachedTimelineViewModelTes
|
|||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(voteInPollAction)
|
viewModel.accept(voteInPollAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.VoteInPoll
|
||||||
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java)
|
assertThat(item?.action).isEqualTo(voteInPollAction)
|
||||||
assertThat(item.action).isEqualTo(voteInPollAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -93,7 +93,7 @@ abstract class NetworkTimelineViewModelTestBase {
|
|||||||
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
|
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
|
||||||
|
|
||||||
protected lateinit var timelineCases: TimelineCases
|
protected lateinit var timelineCases: TimelineCases
|
||||||
protected lateinit var viewModel: TimelineViewModel
|
protected lateinit var viewModel: NetworkTimelineViewModel
|
||||||
|
|
||||||
private val eventHub = EventHub()
|
private val eventHub = EventHub()
|
||||||
|
|
||||||
|
@ -22,9 +22,12 @@ import app.pachli.ContentFilterV1Test.Companion.mockStatus
|
|||||||
import app.pachli.components.timeline.viewmodel.StatusAction
|
import app.pachli.components.timeline.viewmodel.StatusAction
|
||||||
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
|
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
|
||||||
import app.pachli.components.timeline.viewmodel.UiError
|
import app.pachli.components.timeline.viewmodel.UiError
|
||||||
|
import app.pachli.components.timeline.viewmodel.UiSuccess
|
||||||
import app.pachli.core.data.model.StatusViewData
|
import app.pachli.core.data.model.StatusViewData
|
||||||
import app.pachli.core.database.model.TranslationState
|
import app.pachli.core.database.model.TranslationState
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
|
import com.github.michaelbull.result.get
|
||||||
|
import com.github.michaelbull.result.getError
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@ -50,6 +53,7 @@ import org.mockito.kotlin.verify
|
|||||||
class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelTestBase() {
|
class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelTestBase() {
|
||||||
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
||||||
private val statusViewData = StatusViewData(
|
private val statusViewData = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = status,
|
status = status,
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
isShowingContent = false,
|
isShowingContent = false,
|
||||||
@ -78,18 +82,17 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
|
|||||||
private val state = argumentCaptor<Boolean>()
|
private val state = argumentCaptor<Boolean>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bookmark succeeds && emits UiSuccess`() = runTest {
|
fun `bookmark succeeds && emits Ok uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
|
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(bookmarkAction)
|
viewModel.accept(bookmarkAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.Bookmark
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java)
|
assertThat(item?.action).isEqualTo(bookmarkAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -99,36 +102,34 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bookmark fails && emits UiError`() = runTest {
|
fun `bookmark fails && emits Err uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(bookmarkAction)
|
viewModel.accept(bookmarkAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.Bookmark
|
||||||
assertThat(item).isInstanceOf(UiError.Bookmark::class.java)
|
assertThat(item?.action).isEqualTo(bookmarkAction)
|
||||||
assertThat(item.action).isEqualTo(bookmarkAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `favourite succeeds && emits UiSuccess`() = runTest {
|
fun `favourite succeeds && emits Ok uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub {
|
timelineCases.stub {
|
||||||
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
|
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(favouriteAction)
|
viewModel.accept(favouriteAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.Favourite
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java)
|
assertThat(item?.action).isEqualTo(favouriteAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -138,34 +139,32 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `favourite fails && emits UiError`() = runTest {
|
fun `favourite fails && emits Err uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(favouriteAction)
|
viewModel.accept(favouriteAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.Favourite
|
||||||
assertThat(item).isInstanceOf(UiError.Favourite::class.java)
|
assertThat(item?.action).isEqualTo(favouriteAction)
|
||||||
assertThat(item.action).isEqualTo(favouriteAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reblog succeeds && emits UiSuccess`() = runTest {
|
fun `reblog succeeds && emits Ok uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
|
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(reblogAction)
|
viewModel.accept(reblogAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.Reblog
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java)
|
assertThat(item?.action).isEqualTo(reblogAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -175,36 +174,34 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reblog fails && emits UiError`() = runTest {
|
fun `reblog fails && emits Err uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(reblogAction)
|
viewModel.accept(reblogAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.Reblog
|
||||||
assertThat(item).isInstanceOf(UiError.Reblog::class.java)
|
assertThat(item?.action).isEqualTo(reblogAction)
|
||||||
assertThat(item.action).isEqualTo(reblogAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `voteinpoll succeeds && emits UiSuccess`() = runTest {
|
fun `voteinpoll succeeds && emits Ok uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub {
|
timelineCases.stub {
|
||||||
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
|
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.uiSuccess.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(voteInPollAction)
|
viewModel.accept(voteInPollAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().get() as? StatusActionSuccess.VoteInPoll
|
||||||
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java)
|
assertThat(item?.action).isEqualTo(voteInPollAction)
|
||||||
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@ -217,18 +214,17 @@ class NetworkTimelineViewModelTestStatusFilterAction : NetworkTimelineViewModelT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `voteinpoll fails && emits UiError`() = runTest {
|
fun `voteinpoll fails && emits Err uiResult`() = runTest {
|
||||||
// Given
|
// Given
|
||||||
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
|
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
|
||||||
|
|
||||||
viewModel.uiError.test {
|
viewModel.uiResult.test {
|
||||||
// When
|
// When
|
||||||
viewModel.accept(voteInPollAction)
|
viewModel.accept(voteInPollAction)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val item = awaitItem()
|
val item = awaitItem().getError() as? UiError.VoteInPoll
|
||||||
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java)
|
assertThat(item?.action).isEqualTo(voteInPollAction)
|
||||||
assertThat(item.action).isEqualTo(voteInPollAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,15 +6,8 @@ import app.pachli.core.database.model.TimelineAccountEntity
|
|||||||
import app.pachli.core.database.model.TimelineStatusEntity
|
import app.pachli.core.database.model.TimelineStatusEntity
|
||||||
import app.pachli.core.database.model.TimelineStatusWithAccount
|
import app.pachli.core.database.model.TimelineStatusWithAccount
|
||||||
import app.pachli.core.database.model.TranslationState
|
import app.pachli.core.database.model.TranslationState
|
||||||
import app.pachli.core.network.json.BooleanIfNull
|
|
||||||
import app.pachli.core.network.json.DefaultIfNull
|
|
||||||
import app.pachli.core.network.json.Guarded
|
|
||||||
import app.pachli.core.network.json.InstantJsonAdapter
|
|
||||||
import app.pachli.core.network.json.LenientRfc3339DateJsonAdapter
|
|
||||||
import app.pachli.core.network.model.Status
|
import app.pachli.core.network.model.Status
|
||||||
import app.pachli.core.network.model.TimelineAccount
|
import app.pachli.core.network.model.TimelineAccount
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
private val fixedDate = Date(1638889052000)
|
private val fixedDate = Date(1638889052000)
|
||||||
@ -81,6 +74,7 @@ fun mockStatusViewData(
|
|||||||
favourited: Boolean = true,
|
favourited: Boolean = true,
|
||||||
bookmarked: Boolean = true,
|
bookmarked: Boolean = true,
|
||||||
) = StatusViewData(
|
) = StatusViewData(
|
||||||
|
pachliAccountId = 1L,
|
||||||
status = mockStatus(
|
status = mockStatus(
|
||||||
id = id,
|
id = id,
|
||||||
inReplyToId = inReplyToId,
|
inReplyToId = inReplyToId,
|
||||||
@ -103,13 +97,6 @@ fun mockStatusEntityWithAccount(
|
|||||||
expanded: Boolean = false,
|
expanded: Boolean = false,
|
||||||
): TimelineStatusWithAccount {
|
): TimelineStatusWithAccount {
|
||||||
val mockedStatus = mockStatus(id)
|
val mockedStatus = mockStatus(id)
|
||||||
val moshi = Moshi.Builder()
|
|
||||||
.add(Date::class.java, LenientRfc3339DateJsonAdapter())
|
|
||||||
.add(Instant::class.java, InstantJsonAdapter())
|
|
||||||
.add(Guarded.Factory())
|
|
||||||
.add(DefaultIfNull.Factory())
|
|
||||||
.add(BooleanIfNull.Factory())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return TimelineStatusWithAccount(
|
return TimelineStatusWithAccount(
|
||||||
status = TimelineStatusEntity.from(
|
status = TimelineStatusEntity.from(
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
package app.pachli.core.activity
|
package app.pachli.core.activity
|
||||||
|
|
||||||
|
import android.database.sqlite.SQLiteException
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import app.pachli.core.common.di.ApplicationScope
|
import app.pachli.core.common.di.ApplicationScope
|
||||||
import app.pachli.core.database.dao.LogEntryDao
|
import app.pachli.core.database.dao.LogEntryDao
|
||||||
@ -58,15 +59,22 @@ class LogEntryTree @Inject constructor(
|
|||||||
|
|
||||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||||
externalScope.launch {
|
externalScope.launch {
|
||||||
logEntryDao.upsert(
|
try {
|
||||||
LogEntryEntity(
|
logEntryDao.insert(
|
||||||
instant = Instant.now(),
|
LogEntryEntity(
|
||||||
priority = priority,
|
instant = Instant.now(),
|
||||||
tag = tag,
|
priority = priority,
|
||||||
message = message,
|
tag = tag,
|
||||||
t = t,
|
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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,8 @@ import app.pachli.core.network.replaceCrashingCharacters
|
|||||||
* [app.pachli.components.conversation.ConversationViewData].
|
* [app.pachli.components.conversation.ConversationViewData].
|
||||||
*/
|
*/
|
||||||
interface IStatusViewData {
|
interface IStatusViewData {
|
||||||
|
/** ID of the Pachli account that loaded this status. */
|
||||||
|
val pachliAccountId: Long
|
||||||
val username: String
|
val username: String
|
||||||
val rebloggedAvatar: String?
|
val rebloggedAvatar: String?
|
||||||
|
|
||||||
@ -117,6 +119,7 @@ interface IStatusViewData {
|
|||||||
* Data required to display a status.
|
* Data required to display a status.
|
||||||
*/
|
*/
|
||||||
data class StatusViewData(
|
data class StatusViewData(
|
||||||
|
override val pachliAccountId: Long,
|
||||||
override var status: Status,
|
override var status: Status,
|
||||||
override var translation: TranslatedStatusEntity? = null,
|
override var translation: TranslatedStatusEntity? = null,
|
||||||
override val isExpanded: Boolean,
|
override val isExpanded: Boolean,
|
||||||
@ -196,6 +199,7 @@ data class StatusViewData(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(
|
fun from(
|
||||||
|
pachliAccountId: Long,
|
||||||
status: Status,
|
status: Status,
|
||||||
isShowingContent: Boolean,
|
isShowingContent: Boolean,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
@ -217,6 +221,7 @@ data class StatusViewData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return StatusViewData(
|
return StatusViewData(
|
||||||
|
pachliAccountId = pachliAccountId,
|
||||||
status = status,
|
status = status,
|
||||||
isShowingContent = isShowingContent,
|
isShowingContent = isShowingContent,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
@ -228,7 +233,8 @@ data class StatusViewData(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun from(conversationStatusEntity: ConversationStatusEntity) = StatusViewData(
|
fun from(pachliAccountId: Long, conversationStatusEntity: ConversationStatusEntity) = StatusViewData(
|
||||||
|
pachliAccountId = pachliAccountId,
|
||||||
status = Status(
|
status = Status(
|
||||||
id = conversationStatusEntity.id,
|
id = conversationStatusEntity.id,
|
||||||
url = conversationStatusEntity.url,
|
url = conversationStatusEntity.url,
|
||||||
@ -281,6 +287,7 @@ data class StatusViewData(
|
|||||||
* the status viewdata is null.
|
* the status viewdata is null.
|
||||||
*/
|
*/
|
||||||
fun from(
|
fun from(
|
||||||
|
pachliAccountId: Long,
|
||||||
timelineStatusWithAccount: TimelineStatusWithAccount,
|
timelineStatusWithAccount: TimelineStatusWithAccount,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
isShowingContent: Boolean,
|
isShowingContent: Boolean,
|
||||||
@ -290,6 +297,7 @@ data class StatusViewData(
|
|||||||
): StatusViewData {
|
): StatusViewData {
|
||||||
val status = timelineStatusWithAccount.toStatus()
|
val status = timelineStatusWithAccount.toStatus()
|
||||||
return StatusViewData(
|
return StatusViewData(
|
||||||
|
pachliAccountId = pachliAccountId,
|
||||||
status = status,
|
status = status,
|
||||||
translation = timelineStatusWithAccount.translatedStatus,
|
translation = timelineStatusWithAccount.translatedStatus,
|
||||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded,
|
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded,
|
||||||
|
@ -786,11 +786,6 @@ class AccountManager @Inject constructor(
|
|||||||
accountDao.setNotificationAccountFilterLimitedByServer(accountId, action)
|
accountDao.setNotificationAccountFilterLimitedByServer(accountId, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?) {
|
|
||||||
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", accountId, value)
|
|
||||||
accountDao.setLastVisibleHomeTimelineStatusId(accountId, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Announcements
|
// -- Announcements
|
||||||
suspend fun deleteAnnouncement(accountId: Long, announcementId: String) {
|
suspend fun deleteAnnouncement(accountId: Long, announcementId: String) {
|
||||||
announcementsDao.deleteForAccount(accountId, announcementId)
|
announcementsDao.deleteForAccount(accountId, announcementId)
|
||||||
|
@ -221,18 +221,18 @@ class NotificationsRemoteMediator(
|
|||||||
// The user's last read notification was missing. Use the page of notifications
|
// The user's last read notification was missing. Use the page of notifications
|
||||||
// chronologically older than their desired notification. This page must *not* be
|
// chronologically older than their desired notification. This page must *not* be
|
||||||
// empty (as noted earlier, if it is, paging stops).
|
// empty (as noted earlier, if it is, paging stops).
|
||||||
deferredNextPage.await().let { response ->
|
deferredNextPage.await().apply {
|
||||||
if (response.isSuccessful) {
|
if (isSuccessful && !body().isNullOrEmpty()) {
|
||||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
return@coroutineScope this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// There were no notifications older than the user's desired notification. Return the page
|
// There were no notifications older than the user's desired notification. Return the page
|
||||||
// of notifications immediately newer than their desired notification. This page must
|
// of notifications immediately newer than their desired notification. This page must
|
||||||
// *not* be empty (as noted earlier, if it is, paging stops).
|
// *not* be empty (as noted earlier, if it is, paging stops).
|
||||||
mastodonApi.notifications(minId = notificationId, limit = pageSize).let { response ->
|
deferredPrevPage.await().apply {
|
||||||
if (response.isSuccessful) {
|
if (isSuccessful && !body().isNullOrEmpty()) {
|
||||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
return@coroutineScope this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,7 +99,6 @@ class NotificationsRepository @Inject constructor(
|
|||||||
private val remoteKeyDao: RemoteKeyDao,
|
private val remoteKeyDao: RemoteKeyDao,
|
||||||
private val eventHub: EventHub,
|
private val eventHub: EventHub,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private var factory: InvalidatingPagingSourceFactory<Int, NotificationData>? = null
|
private var factory: InvalidatingPagingSourceFactory<Int, NotificationData>? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -144,12 +143,7 @@ class NotificationsRepository @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async {
|
suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async {
|
||||||
remoteKeyDao.upsert(
|
remoteKeyDao.upsert(
|
||||||
RemoteKeyEntity(
|
RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key),
|
||||||
pachliAccountId,
|
|
||||||
RKE_TIMELINE_ID,
|
|
||||||
RemoteKeyKind.REFRESH,
|
|
||||||
key,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}.await()
|
}.await()
|
||||||
|
|
||||||
|
1980
core/database/schemas/app.pachli.core.database.AppDatabase/14.json
Normal file
1980
core/database/schemas/app.pachli.core.database.AppDatabase/14.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,7 +92,7 @@ import java.util.TimeZone
|
|||||||
NotificationViewDataEntity::class,
|
NotificationViewDataEntity::class,
|
||||||
NotificationRelationshipSeveranceEventEntity::class,
|
NotificationRelationshipSeveranceEventEntity::class,
|
||||||
],
|
],
|
||||||
version = 13,
|
version = 14,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
|
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
|
||||||
AutoMigration(from = 2, to = 3),
|
AutoMigration(from = 2, to = 3),
|
||||||
@ -105,6 +105,7 @@ import java.util.TimeZone
|
|||||||
AutoMigration(from = 10, to = 11),
|
AutoMigration(from = 10, to = 11),
|
||||||
AutoMigration(from = 11, to = 12, spec = AppDatabase.MIGRATE_11_12::class),
|
AutoMigration(from = 11, to = 12, spec = AppDatabase.MIGRATE_11_12::class),
|
||||||
AutoMigration(from = 12, to = 13),
|
AutoMigration(from = 12, to = 13),
|
||||||
|
AutoMigration(from = 13, to = 14, spec = AppDatabase.MIGRATE_13_14::class),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@ -180,6 +181,10 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
// lastNotificationId removed in favour of the REFRESH key in RemoteKeyEntity.
|
// lastNotificationId removed in favour of the REFRESH key in RemoteKeyEntity.
|
||||||
@DeleteColumn("AccountEntity", "lastNotificationId")
|
@DeleteColumn("AccountEntity", "lastNotificationId")
|
||||||
class MIGRATE_11_12 : AutoMigrationSpec
|
class MIGRATE_11_12 : AutoMigrationSpec
|
||||||
|
|
||||||
|
// lastVisibleHomeTimelineStatusId removed in favour of the REFRESH key in RemoteKeyEntity.
|
||||||
|
@DeleteColumn("AccountEntity", "lastVisibleHomeTimelineStatusId")
|
||||||
|
class MIGRATE_13_14 : AutoMigrationSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
val MIGRATE_8_9 = object : Migration(8, 9) {
|
val MIGRATE_8_9 = object : Migration(8, 9) {
|
||||||
|
@ -394,15 +394,6 @@ interface AccountDao {
|
|||||||
)
|
)
|
||||||
fun setNotificationLight(accountId: Long, value: Boolean)
|
fun setNotificationLight(accountId: Long, value: Boolean)
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE AccountEntity
|
|
||||||
SET lastVisibleHomeTimelineStatusId = :value
|
|
||||||
WHERE id = :accountId
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?)
|
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
UPDATE AccountEntity
|
UPDATE AccountEntity
|
||||||
|
@ -18,9 +18,9 @@
|
|||||||
package app.pachli.core.database.dao
|
package app.pachli.core.database.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import androidx.room.Upsert
|
|
||||||
import app.pachli.core.database.Converters
|
import app.pachli.core.database.Converters
|
||||||
import app.pachli.core.database.model.LogEntryEntity
|
import app.pachli.core.database.model.LogEntryEntity
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@ -30,9 +30,8 @@ import java.time.Instant
|
|||||||
*/
|
*/
|
||||||
@Dao
|
@Dao
|
||||||
interface LogEntryDao {
|
interface LogEntryDao {
|
||||||
/** Upsert [logEntry] */
|
@Insert
|
||||||
@Upsert
|
suspend fun insert(logEntry: LogEntryEntity): Long
|
||||||
suspend fun upsert(logEntry: LogEntryEntity): Long
|
|
||||||
|
|
||||||
/** Load all [LogEntryEntity], ordered oldest first */
|
/** Load all [LogEntryEntity], ordered oldest first */
|
||||||
@Query(
|
@Query(
|
||||||
|
@ -152,18 +152,22 @@ ORDER BY LENGTH(n.serverId) DESC, n.serverId DESC
|
|||||||
)
|
)
|
||||||
fun pagingSource(pachliAccountId: Long): PagingSource<Int, NotificationData>
|
fun pagingSource(pachliAccountId: Long): PagingSource<Int, NotificationData>
|
||||||
|
|
||||||
/** @return The database row number of the row for [notificationId]. */
|
/**
|
||||||
|
* @return Row number (0-based) of the notification with ID [notificationId]
|
||||||
|
* for [pachliAccountId]
|
||||||
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT RowNum
|
SELECT rownum
|
||||||
FROM
|
FROM (
|
||||||
(SELECT pachliAccountId, serverId,
|
SELECT t1.pachliAccountId AS pachliAccountId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum
|
||||||
(SELECT count(*) + 1
|
FROM NotificationEntity t1
|
||||||
FROM notificationentity
|
JOIN NotificationEntity t2 ON t1.pachliAccountId = t2.pachliAccountId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId)
|
||||||
WHERE rowid < t.rowid
|
WHERE t1.pachliAccountId = :pachliAccountId
|
||||||
ORDER BY length(serverId) DESC, serverId DESC) AS RowNum
|
GROUP BY t1.serverId
|
||||||
FROM notificationentity t)
|
ORDER BY length(t1.serverId) DESC, t1.serverId DESC
|
||||||
WHERE pachliAccountId = :pachliAccountId AND serverId = :notificationId;
|
)
|
||||||
|
WHERE serverId = :notificationId
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
suspend fun getNotificationRowNumber(pachliAccountId: Long, notificationId: String): Int
|
suspend fun getNotificationRowNumber(pachliAccountId: Long, notificationId: String): Int
|
||||||
|
@ -83,19 +83,25 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""",
|
|||||||
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
|
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All statuses for [account] in timeline ID. Used to find the correct initialKey to restore
|
* @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId].
|
||||||
* the user's reading position.
|
|
||||||
*
|
*
|
||||||
* @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses]
|
* @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses]
|
||||||
*/
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT serverId
|
SELECT rownum
|
||||||
FROM TimelineStatusEntity
|
FROM (
|
||||||
WHERE timelineUserId = :account
|
SELECT t1.timelineUserId AS timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum
|
||||||
ORDER BY LENGTH(serverId) DESC, serverId DESC""",
|
FROM TimelineStatusEntity t1
|
||||||
|
JOIN TimelineStatusEntity t2 ON t1.timelineUserId = t2.timelineUserId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId)
|
||||||
|
WHERE t1.timelineUserId = :pachliAccountId
|
||||||
|
GROUP BY t1.serverId
|
||||||
|
ORDER BY length(t1.serverId) DESC, t1.serverId DESC
|
||||||
|
)
|
||||||
|
WHERE serverId = :statusId
|
||||||
|
""",
|
||||||
)
|
)
|
||||||
abstract fun getStatusRowNumber(account: Long): List<String>
|
abstract suspend fun getStatusRowNumber(pachliAccountId: Long, statusId: String): Int
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@ -163,23 +169,6 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe
|
|||||||
)
|
)
|
||||||
abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String)
|
abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String)
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes everything for one account in the following tables:
|
|
||||||
*
|
|
||||||
* - TimelineStatusEntity
|
|
||||||
* - TimelineAccountEntity
|
|
||||||
* - StatusViewDataEntity
|
|
||||||
* - TranslatedStatusEntity
|
|
||||||
*
|
|
||||||
* @param accountId id of the account for which to clean tables
|
|
||||||
*/
|
|
||||||
suspend fun removeAll(accountId: Long) {
|
|
||||||
removeAllStatuses(accountId)
|
|
||||||
removeAllAccounts(accountId)
|
|
||||||
removeAllStatusViewData(accountId)
|
|
||||||
removeAllTranslatedStatus(accountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes all statuses from the cached **home** timeline.
|
* Removes all statuses from the cached **home** timeline.
|
||||||
*
|
*
|
||||||
@ -197,7 +186,7 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
abstract suspend fun removeAllStatuses(accountId: Long)
|
abstract suspend fun deleteAllStatusesForAccount(accountId: Long)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes [TimelineAccountEntity] that are not referenced by a
|
* Deletes [TimelineAccountEntity] that are not referenced by a
|
||||||
|
@ -99,12 +99,6 @@ data class AccountEntity(
|
|||||||
val pushAuth: String = "",
|
val pushAuth: String = "",
|
||||||
val pushServerKey: String = "",
|
val pushServerKey: String = "",
|
||||||
|
|
||||||
/**
|
|
||||||
* ID of the status at the top of the visible list in the home timeline when the
|
|
||||||
* user navigated away.
|
|
||||||
*/
|
|
||||||
val lastVisibleHomeTimelineStatusId: String? = null,
|
|
||||||
|
|
||||||
/** True if the connected Mastodon account is locked (has to manually approve all follow requests **/
|
/** True if the connected Mastodon account is locked (has to manually approve all follow requests **/
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val locked: Boolean = false,
|
val locked: Boolean = false,
|
||||||
|
@ -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() }
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user