From fc81e8bad71bd29820e276139d989335b9eaf277 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 26 Jan 2024 12:15:27 +0100 Subject: [PATCH] fix: Ensure actions happen against the correct status (#373) Previously when the user interacted with a status the operation (reblog, favourite, etc) travels through multiple layers of code, carrying with it the position of the item in the list that the user operated on. At some point the status is retrieved from the list using its position so that the correct status ID can be used in the network operation. If this happens while the list is also refreshing there's a possible race condition, and the original status' position may have changed in the list. Looking up the status by position to determine which status to perform the action on may cause the action to happen on the wrong status. Fix this by passing the status' viewdata to any actions instead of its position. This includes all the information necessary to make the API call, so there is no chance of a race. This is quite an involved change because there are three types of viewdata: - `StatusViewData`, used for regular timelines - `NotificationViewData`, used for notifications, may wrap a status that can be operated on - `ConversationViewData`, used for conversations, does wrap a status The previous code treated them all differently, which is probably why it operated by position instead of type. The high level fix is to: 1. Create an interface, `IStatusViewData`, that contains the data exposed by any viewdata that contains a status. 2. Implement the interface in `StatusViewData`, `NotificationViewData`, and `ConversationViewData`. 3. Change the code that operates on viewdata (`SFragment`, `StatusActionListener`, etc) to be generic over anything that implements `IStatusViewData`. 4. Change the code that handles actions to pass the viewdata instead of the position. Fixes #370 --- app/lint-baseline.xml | 32 +-- .../adapter/FilterableStatusViewHolder.kt | 22 +- .../pachli/adapter/StatusBaseViewHolder.kt | 192 ++++++++---------- .../adapter/StatusDetailedViewHolder.kt | 32 ++- .../app/pachli/adapter/StatusViewHolder.kt | 51 +++-- .../conversation/ConversationAdapter.kt | 2 +- .../conversation/ConversationViewData.kt | 38 +--- .../conversation/ConversationViewHolder.kt | 48 ++--- .../conversation/ConversationsFragment.kt | 101 ++++----- .../conversation/ConversationsViewModel.kt | 107 +++++----- .../notifications/NotificationsFragment.kt | 129 ++++++------ .../NotificationsPagingAdapter.kt | 11 +- .../StatusNotificationViewHolder.kt | 70 +++---- .../notifications/StatusViewHolder.kt | 12 +- .../components/search/SearchViewModel.kt | 5 +- .../search/adapter/SearchStatusesAdapter.kt | 8 +- .../fragments/SearchStatusesFragment.kt | 171 +++++++--------- .../components/timeline/TimelineFragment.kt | 105 ++++------ .../timeline/TimelinePagingAdapter.kt | 8 +- .../viewmodel/CachedTimelineViewModel.kt | 4 +- .../viewmodel/NetworkTimelineViewModel.kt | 4 +- .../timeline/viewmodel/TimelineViewModel.kt | 2 +- .../components/viewthread/ThreadAdapter.kt | 8 +- .../viewthread/ViewThreadFragment.kt | 87 ++++---- .../viewthread/ViewThreadViewModel.kt | 8 +- .../java/app/pachli/fragment/SFragment.kt | 63 +++--- .../interfaces/AccountActionListener.kt | 2 +- .../pachli/interfaces/StatusActionListener.kt | 41 ++-- .../util/ListStatusAccessibilityDelegate.kt | 52 ++--- .../pachli/viewdata/NotificationViewData.kt | 55 ++++- .../app/pachli/viewdata/StatusViewData.kt | 145 +++++++------ .../core/database/dao/ConversationsDao.kt | 63 ++++++ .../core/database/model/ConversationEntity.kt | 1 + .../database/model/TimelineStatusEntity.kt | 6 +- 34 files changed, 833 insertions(+), 852 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 39a1bd244..8bdb7aaec 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -2423,7 +2423,7 @@ errorLine2=" ~~~~~~~"> @@ -2434,7 +2434,7 @@ errorLine2=" ~~~~~~~"> @@ -2522,7 +2522,7 @@ errorLine2=" ~~~~~~~"> @@ -2533,7 +2533,7 @@ errorLine2=" ~~~~~~~~"> @@ -2544,7 +2544,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -2555,7 +2555,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2566,7 +2566,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3523,7 +3523,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3534,40 +3534,40 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3644,7 +3644,7 @@ errorLine2=" ~~~~~~~~~"> diff --git a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt index f896cdd88..6ebb55ffc 100644 --- a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt @@ -23,25 +23,25 @@ import app.pachli.core.network.model.Filter import app.pachli.databinding.ItemStatusWrapperBinding import app.pachli.interfaces.StatusActionListener import app.pachli.util.StatusDisplayOptions -import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.IStatusViewData -open class FilterableStatusViewHolder( +open class FilterableStatusViewHolder( private val binding: ItemStatusWrapperBinding, -) : StatusViewHolder(binding.statusContainer, binding.root) { +) : StatusViewHolder(binding.statusContainer, binding.root) { override fun setupWithStatus( - status: StatusViewData, - listener: StatusActionListener, + viewData: T, + listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, payloads: Any?, ) { - super.setupWithStatus(status, listener, statusDisplayOptions, payloads) - setupFilterPlaceholder(status, listener) + super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads) + setupFilterPlaceholder(viewData, listener) } private fun setupFilterPlaceholder( - status: StatusViewData, - listener: StatusActionListener, + status: T, + listener: StatusActionListener, ) { if (status.filterAction !== Filter.Action.WARN) { showFilteredPlaceholder(false) @@ -75,9 +75,7 @@ open class FilterableStatusViewHolder( matchedFilter.title, ) binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener { - listener.clearWarningAction( - bindingAdapterPosition, - ) + listener.clearWarningAction(status) } } diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index d41aae29e..4d4c19764 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -50,8 +50,8 @@ import app.pachli.view.MediaPreviewImageView import app.pachli.view.MediaPreviewLayout import app.pachli.view.PollView import app.pachli.view.PreviewCardView +import app.pachli.viewdata.IStatusViewData import app.pachli.viewdata.PollViewData.Companion.from -import app.pachli.viewdata.StatusViewData import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide @@ -61,7 +61,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import java.text.NumberFormat import java.util.Date -abstract class StatusBaseViewHolder protected constructor(itemView: View) : +abstract class StatusBaseViewHolder protected constructor(itemView: View) : RecyclerView.ViewHolder(itemView) { object Key { const val KEY_CREATED = "created" @@ -173,17 +173,16 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected fun setSpoilerAndContent( - status: StatusViewData, + viewData: T, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { - val (_, _, _, _, _, _, _, _, _, emojis) = status.actionable - val spoilerText = status.spoilerText + val spoilerText = viewData.spoilerText val sensitive = !TextUtils.isEmpty(spoilerText) - val expanded = status.isExpanded + val expanded = viewData.isExpanded if (sensitive) { val emojiSpoiler = spoilerText.emojify( - emojis, + viewData.actionable.emojis, contentWarningDescription, statusDisplayOptions.animateEmojis, ) @@ -193,14 +192,14 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : setContentWarningButtonText(expanded) contentWarningButton.setOnClickListener { toggleExpandedState( + viewData, true, !expanded, - status, statusDisplayOptions, listener, ) } - setTextVisible(true, expanded, status, statusDisplayOptions, listener) + setTextVisible(true, expanded, viewData, statusDisplayOptions, listener) return } @@ -209,7 +208,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : setTextVisible( sensitive = false, expanded = true, - status = status, + viewData = viewData, statusDisplayOptions = statusDisplayOptions, listener = listener, ) @@ -224,21 +223,18 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected open fun toggleExpandedState( + viewData: T, sensitive: Boolean, expanded: Boolean, - status: StatusViewData, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { contentWarningDescription.invalidate() - val adapterPosition = bindingAdapterPosition - if (adapterPosition != RecyclerView.NO_POSITION) { - listener.onExpandedChange(expanded, adapterPosition) - } + listener.onExpandedChange(viewData, expanded) setContentWarningButtonText(expanded) - setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener) + setTextVisible(sensitive, expanded, viewData, statusDisplayOptions, listener) setupCard( - status, + viewData, expanded, statusDisplayOptions.cardViewMode, statusDisplayOptions, @@ -249,12 +245,12 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : private fun setTextVisible( sensitive: Boolean, expanded: Boolean, - status: StatusViewData, + viewData: T, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { - val (_, _, _, _, _, _, _, _, _, emojis, _, _, _, _, _, _, _, _, _, _, mentions, tags, _, _, _, poll) = status.actionable - when (status.translationState) { + val (_, _, _, _, _, _, _, _, _, emojis, _, _, _, _, _, _, _, _, _, _, mentions, tags, _, _, _, poll) = viewData.actionable + when (viewData.translationState) { TranslationState.SHOW_ORIGINAL -> translationProvider?.hide() TranslationState.TRANSLATING -> { translationProvider?.apply { @@ -264,7 +260,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } TranslationState.SHOW_TRANSLATION -> { translationProvider?.apply { - status.translation?.provider?.let { + viewData.translation?.provider?.let { text = context.getString(R.string.translation_provider_fmt, it) show() } @@ -272,7 +268,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } } - val content = status.content + val content = viewData.content if (expanded) { val emojifiedText = content.emojify(emojis, this.content, statusDisplayOptions.animateEmojis) @@ -282,10 +278,10 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } poll?.let { - val pollViewData = if (status.translationState == TranslationState.SHOW_TRANSLATION) { - from(it).copy(translatedPoll = status.translation?.poll) + val pollViewData = if (viewData.translationState == TranslationState.SHOW_TRANSLATION) { + from(poll).copy(translatedPoll = viewData.translation?.poll) } else { - from(it) + from(poll) } pollView.bind( @@ -295,11 +291,8 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : numberFormat, absoluteTimeFormatter, ) { choices -> - val position = bindingAdapterPosition - if (position != RecyclerView.NO_POSITION) { - choices?.let { listener.onVoteInPoll(position, it) } - ?: listener.onViewThread(position) - } + choices?.let { listener.onVoteInPoll(viewData, poll, it) } + ?: listener.onViewThread(viewData.actionable) } } ?: pollView.hide() } else { @@ -355,11 +348,11 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected open fun setMetaData( - statusViewData: StatusViewData, + viewData: T, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { - val (_, _, _, _, _, _, _, createdAt, editedAt) = statusViewData.actionable + val (_, _, _, _, _, _, _, createdAt, editedAt) = viewData.actionable var timestampText: String timestampText = if (statusDisplayOptions.useAbsoluteTime) { absoluteTimeFormatter.format(createdAt, true) @@ -497,9 +490,10 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected fun setMediaPreviews( + viewData: T, attachments: List, sensitive: Boolean, - listener: StatusActionListener, + listener: StatusActionListener, showingContent: Boolean, useBlurhash: Boolean, ) { @@ -528,7 +522,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } else { imageView.foreground = null } - setAttachmentClickListener(imageView, listener, i, attachment, true) + setAttachmentClickListener(viewData, imageView, listener, i, attachment, true) if (sensitive) { sensitiveMediaWarning.setText(R.string.post_sensitive_media_title) } else { @@ -539,17 +533,13 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : descriptionIndicator.visibility = if (hasDescription && showingContent) View.VISIBLE else View.GONE sensitiveMediaShow.setOnClickListener { v: View -> - if (bindingAdapterPosition != RecyclerView.NO_POSITION) { - listener.onContentHiddenChange(false, bindingAdapterPosition) - } + listener.onContentHiddenChange(viewData, false) v.visibility = View.GONE sensitiveMediaWarning.visibility = View.VISIBLE descriptionIndicator.visibility = View.GONE } sensitiveMediaWarning.setOnClickListener { v: View -> - if (bindingAdapterPosition != RecyclerView.NO_POSITION) { - listener.onContentHiddenChange(true, bindingAdapterPosition) - } + listener.onContentHiddenChange(viewData, true) v.visibility = View.GONE sensitiveMediaShow.visibility = View.VISIBLE descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE @@ -564,9 +554,10 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected fun setMediaLabel( + viewData: T, attachments: List, sensitive: Boolean, - listener: StatusActionListener, + listener: StatusActionListener, showingContent: Boolean, ) { for (i in mediaLabels.indices) { @@ -580,7 +571,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : // Set the icon next to the label. val drawableId = attachments[0].iconResource() mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0) - setAttachmentClickListener(mediaLabel, listener, i, attachment, false) + setAttachmentClickListener(viewData, mediaLabel, listener, i, attachment, false) } else { mediaLabel.visibility = View.GONE } @@ -588,18 +579,18 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } private fun setAttachmentClickListener( + viewData: T, view: View, - listener: StatusActionListener, + listener: StatusActionListener, index: Int, attachment: Attachment, animateTransition: Boolean, ) { view.setOnClickListener { v: View? -> - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener if (sensitiveMediaWarning.visibility == View.VISIBLE) { - listener.onContentHiddenChange(true, bindingAdapterPosition) + listener.onContentHiddenChange(viewData, true) } else { - listener.onViewMedia(position, index, if (animateTransition) v else null) + listener.onViewMedia(viewData, index, if (animateTransition) v else null) } } view.setOnLongClickListener { @@ -615,7 +606,8 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected fun setupButtons( - listener: StatusActionListener, + viewData: T, + listener: StatusActionListener, accountId: String, statusDisplayOptions: StatusDisplayOptions, ) { @@ -623,39 +615,34 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : avatar.setOnClickListener(profileButtonClickListener) displayName.setOnClickListener(profileButtonClickListener) replyButton.setOnClickListener { - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener - listener.onReply(position) + listener.onReply(viewData) } reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean -> // return true to play animation - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener false return@setEventListener if (statusDisplayOptions.confirmReblogs) { - showConfirmReblog(listener, buttonState, position) + showConfirmReblog(viewData, listener, buttonState) false } else { - listener.onReblog(!buttonState, position) + listener.onReblog(viewData, !buttonState) true } } favouriteButton.setEventListener { _: SparkButton?, buttonState: Boolean -> // return true to play animation - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener true return@setEventListener if (statusDisplayOptions.confirmFavourites) { - showConfirmFavourite(listener, buttonState, position) + showConfirmFavourite(viewData, listener, buttonState) false } else { - listener.onFavourite(!buttonState, position) + listener.onFavourite(viewData, !buttonState) true } } bookmarkButton.setEventListener { _: SparkButton?, buttonState: Boolean -> - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener true - listener.onBookmark(!buttonState, position) + listener.onBookmark(viewData, !buttonState) true } moreButton.setOnClickListener { v: View? -> - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener - listener.onMore(v!!, position) + listener.onMore(v!!, viewData) } /* Even though the content TextView is a child of the container, it won't respond to clicks @@ -663,8 +650,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : * just eat the clicks instead of deferring to the parent listener, but WILL respond to a * listener directly on the TextView, for whatever reason. */ val viewThreadListener = View.OnClickListener { - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@OnClickListener - listener.onViewThread(position) + listener.onViewThread(viewData.actionable) } content.setOnClickListener(viewThreadListener) @@ -672,9 +658,9 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } private fun showConfirmReblog( - listener: StatusActionListener, + viewData: T, + listener: StatusActionListener, buttonState: Boolean, - position: Int, ) { val popup = PopupMenu(context, reblogButton!!) popup.inflate(R.menu.status_reblog) @@ -685,7 +671,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : menu.findItem(R.id.menu_action_unreblog).isVisible = false } popup.setOnMenuItemClickListener { - listener.onReblog(!buttonState, position) + listener.onReblog(viewData, !buttonState) if (!buttonState) { reblogButton.playAnimation() } @@ -695,9 +681,9 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } private fun showConfirmFavourite( - listener: StatusActionListener, + viewData: T, + listener: StatusActionListener, buttonState: Boolean, - position: Int, ) { val popup = PopupMenu(context, favouriteButton) popup.inflate(R.menu.status_favourite) @@ -708,7 +694,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : menu.findItem(R.id.menu_action_unfavourite).isVisible = false } popup.setOnMenuItemClickListener { - listener.onFavourite(!buttonState, position) + listener.onFavourite(viewData, !buttonState) if (!buttonState) { favouriteButton.playAnimation() } @@ -718,29 +704,29 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } open fun setupWithStatus( - status: StatusViewData, - listener: StatusActionListener, + viewData: T, + listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, payloads: Any? = null, ) { if (payloads == null) { - val actionable = status.actionable + val actionable = viewData.actionable setDisplayName(actionable.account.name, actionable.account.emojis, statusDisplayOptions) - setUsername(status.username) - setMetaData(status, statusDisplayOptions, listener) + setUsername(viewData.username) + setMetaData(viewData, statusDisplayOptions, listener) setIsReply(actionable.inReplyToId != null) setReplyCount(actionable.repliesCount, statusDisplayOptions.showStatsInline) setAvatar( actionable.account.avatar, - status.rebloggedAvatar, + viewData.rebloggedAvatar, actionable.account.bot, statusDisplayOptions, ) setReblogged(actionable.reblogged) setFavourited(actionable.favourited) setBookmarked(actionable.bookmarked) - val attachments = if (status.translationState == TranslationState.SHOW_TRANSLATION) { - status.translation?.attachments?.zip(actionable.attachments) { t, a -> + val attachments = if (viewData.translationState == TranslationState.SHOW_TRANSLATION) { + viewData.translation?.attachments?.zip(actionable.attachments) { t, a -> a.copy(description = t.description) } ?: actionable.attachments } else { @@ -749,10 +735,11 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : val sensitive = actionable.sensitive if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { setMediaPreviews( + viewData, attachments, sensitive, listener, - status.isShowingContent, + viewData.isShowingContent, statusDisplayOptions.useBlurhash, ) if (attachments.isEmpty()) { @@ -763,26 +750,27 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : mediaLabel.visibility = View.GONE } } else { - setMediaLabel(attachments, sensitive, listener, status.isShowingContent) + setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent) // Hide all unused views. mediaPreview.visibility = View.GONE hideSensitiveMediaWarning() } setupCard( - status, - status.isExpanded, + viewData, + viewData.isExpanded, statusDisplayOptions.cardViewMode, statusDisplayOptions, listener, ) setupButtons( + viewData, listener, actionable.account.id, statusDisplayOptions, ) setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility) - setSpoilerAndContent(status, statusDisplayOptions, listener) - setDescriptionForStatus(status, statusDisplayOptions) + setSpoilerAndContent(viewData, statusDisplayOptions, listener) + setDescriptionForStatus(viewData, statusDisplayOptions) // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // RecyclerView tries to set AccessibilityDelegateCompat to null @@ -794,7 +782,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : if (payloads is List<*>) { for (item in payloads) { if (Key.KEY_CREATED == item) { - setMetaData(status, statusDisplayOptions, listener) + setMetaData(viewData, statusDisplayOptions, listener) } } } @@ -802,27 +790,27 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } private fun setDescriptionForStatus( - status: StatusViewData, + viewData: T, statusDisplayOptions: StatusDisplayOptions, ) { - val (_, _, account, _, _, _, _, createdAt, editedAt, _, reblogsCount, favouritesCount, _, reblogged, favourited, bookmarked, sensitive, _, visibility) = status.actionable + val (_, _, account, _, _, _, _, createdAt, editedAt, _, reblogsCount, favouritesCount, _, reblogged, favourited, bookmarked, sensitive, _, visibility) = viewData.actionable val description = context.getString( R.string.description_status, account.displayName, - getContentWarningDescription(context, status), - if (TextUtils.isEmpty(status.spoilerText) || !sensitive || status.isExpanded) status.content else "", + getContentWarningDescription(context, viewData), + if (TextUtils.isEmpty(viewData.spoilerText) || !sensitive || viewData.isExpanded) viewData.content else "", getCreatedAtDescription(createdAt, statusDisplayOptions), editedAt?.let { context.getString(R.string.description_post_edited) } ?: "", - getReblogDescription(context, status), - status.username, + getReblogDescription(context, viewData), + viewData.username, if (reblogged) context.getString(R.string.description_post_reblogged) else "", if (favourited) context.getString(R.string.description_post_favourited) else "", if (bookmarked) context.getString(R.string.description_post_bookmarked) else "", - getMediaDescription(context, status), + getMediaDescription(context, viewData), visibility.description(context), getFavsText(favouritesCount), getReblogsText(reblogsCount), - status.actionable.poll?.let { + viewData.actionable.poll?.let { pollView.getPollDescription( from(it), statusDisplayOptions, @@ -859,22 +847,22 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } protected fun setupCard( - status: StatusViewData, + viewData: T, expanded: Boolean, cardViewMode: CardViewMode, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { cardView ?: return - val (_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, sensitive, _, _, attachments, _, _, _, _, _, poll, card) = status.actionable + val (_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, sensitive, _, _, attachments, _, _, _, _, _, poll, card) = viewData.actionable if (cardViewMode !== CardViewMode.NONE && attachments.isEmpty() && poll == null && card != null && !TextUtils.isEmpty(card.url) && (!sensitive || expanded) && - (!status.isCollapsible || !status.isCollapsed) + (!viewData.isCollapsible || !viewData.isCollapsed) ) { cardView.visibility = View.VISIBLE - cardView.bind(card, status.actionable.sensitive, statusDisplayOptions) { target -> + cardView.bind(card, viewData.actionable.sensitive, statusDisplayOptions) { target -> if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) { context.startActivity( ViewMediaActivityIntent(context, card.embedUrl), @@ -919,13 +907,13 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : return true } - private fun getReblogDescription(context: Context, status: StatusViewData): CharSequence { + private fun getReblogDescription(context: Context, status: IStatusViewData): CharSequence { return status.rebloggingStatus?.let { context.getString(R.string.post_boosted_format, it.account.username) } ?: "" } - private fun getMediaDescription(context: Context, status: StatusViewData): CharSequence { + private fun getMediaDescription(context: Context, status: IStatusViewData): CharSequence { if (status.actionable.attachments.isEmpty()) return "" val mediaDescriptions = @@ -942,7 +930,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : return context.getString(R.string.description_post_media, mediaDescriptions) } - private fun getContentWarningDescription(context: Context, status: StatusViewData): CharSequence { + private fun getContentWarningDescription(context: Context, status: IStatusViewData): CharSequence { return if (!TextUtils.isEmpty(status.spoilerText)) { context.getString(R.string.description_post_cw, status.spoilerText) } else { diff --git a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt index fcfac3a37..7b360ef50 100644 --- a/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusDetailedViewHolder.kt @@ -7,7 +7,6 @@ import android.text.method.LinkMovementMethod import android.text.style.DynamicDrawableSpan import android.text.style.ImageSpan import android.view.View -import androidx.recyclerview.widget.RecyclerView import app.pachli.R import app.pachli.databinding.ItemStatusDetailedBinding import app.pachli.interfaces.StatusActionListener @@ -24,14 +23,14 @@ import java.text.DateFormat class StatusDetailedViewHolder( private val binding: ItemStatusDetailedBinding, -) : StatusBaseViewHolder(binding.root) { +) : StatusBaseViewHolder(binding.root) { override fun setMetaData( - statusViewData: StatusViewData, + viewData: StatusViewData, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { - val (_, _, _, _, _, _, _, createdAt, editedAt, _, _, _, _, _, _, _, _, _, visibility, _, _, _, app) = statusViewData.actionable + val (_, _, _, _, _, _, _, createdAt, editedAt, _, _, _, _, _, _, _, _, _, visibility, _, _, _, app) = viewData.actionable val visibilityIcon = visibility.icon(metaInfo) val visibilityString = visibility.description(context) val sb = SpannableStringBuilder(visibilityString) @@ -50,10 +49,10 @@ class StatusDetailedViewHolder( val spanStart = sb.length val spanEnd = spanStart + editedAtString.length sb.append(editedAtString) - statusViewData.status.editedAt?.also { + viewData.status.editedAt?.also { val editedClickSpan: NoUnderlineURLSpan = object : NoUnderlineURLSpan("") { override fun onClick(view: View) { - listener.onShowEdits(bindingAdapterPosition) + listener.onShowEdits(viewData.actionableId) } } sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) @@ -71,9 +70,10 @@ class StatusDetailedViewHolder( } private fun setReblogAndFavCount( + viewData: StatusViewData, reblogCount: Int, favCount: Int, - listener: StatusActionListener, + listener: StatusActionListener, ) { if (reblogCount > 0) { binding.statusReblogs.text = getReblogsText(reblogCount) @@ -93,28 +93,26 @@ class StatusDetailedViewHolder( binding.statusInfoDivider.show() } binding.statusReblogs.setOnClickListener { - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener - listener.onShowReblogs(position) + listener.onShowReblogs(viewData.actionableId) } binding.statusFavourites.setOnClickListener { - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener - listener.onShowFavs(position) + listener.onShowFavs(viewData.actionableId) } } override fun setupWithStatus( - status: StatusViewData, - listener: StatusActionListener, + viewData: StatusViewData, + listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, payloads: Any?, ) { // We never collapse statuses in the detail view val uncollapsedStatus = - if (status.isCollapsible && status.isCollapsed) status.copyWithCollapsed(false) else status + if (viewData.isCollapsible && viewData.isCollapsed) viewData.copyWithCollapsed(false) else viewData super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads) setupCard( uncollapsedStatus, - status.isExpanded, + viewData.isExpanded, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener, @@ -122,7 +120,7 @@ class StatusDetailedViewHolder( if (payloads == null) { val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedStatus.actionable if (!statusDisplayOptions.hideStats) { - setReblogAndFavCount(reblogsCount, favouritesCount, listener) + setReblogAndFavCount(viewData, reblogsCount, favouritesCount, listener) } else { hideQuantitativeStats() } diff --git a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt index 2ea2e8d19..ee214db6c 100644 --- a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt @@ -19,7 +19,6 @@ package app.pachli.adapter import android.text.InputFilter import android.text.TextUtils import android.view.View -import androidx.recyclerview.widget.RecyclerView import app.pachli.R import app.pachli.core.common.string.unicodeWrap import app.pachli.core.common.util.formatNumber @@ -33,26 +32,26 @@ import app.pachli.util.emojify import app.pachli.util.hide import app.pachli.util.show import app.pachli.util.visible -import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.IStatusViewData import at.connyduck.sparkbutton.helpers.Utils -open class StatusViewHolder( +open class StatusViewHolder( private val binding: ItemStatusBinding, root: View? = null, -) : StatusBaseViewHolder(root ?: binding.root) { +) : StatusBaseViewHolder(root ?: binding.root) { override fun setupWithStatus( - status: StatusViewData, - listener: StatusActionListener, + viewData: T, + listener: StatusActionListener, statusDisplayOptions: StatusDisplayOptions, payloads: Any?, ) = with(binding) { if (payloads == null) { - val sensitive = !TextUtils.isEmpty(status.actionable.spoilerText) - val expanded = status.isExpanded - setupCollapsedState(sensitive, expanded, status, listener) - val reblogging = status.rebloggingStatus - if (reblogging == null || status.filterAction === Filter.Action.WARN) { + val sensitive = !TextUtils.isEmpty(viewData.actionable.spoilerText) + val expanded = viewData.isExpanded + setupCollapsedState(viewData, sensitive, expanded, listener) + val reblogging = viewData.rebloggingStatus + if (reblogging == null || viewData.filterAction === Filter.Action.WARN) { statusInfo.hide() } else { val rebloggedByDisplayName = reblogging.account.name @@ -62,15 +61,15 @@ open class StatusViewHolder( statusDisplayOptions, ) statusInfo.setOnClickListener { - listener.onOpenReblog(bindingAdapterPosition) + listener.onOpenReblog(viewData.status) } } } statusReblogsCount.visible(statusDisplayOptions.showStatsInline) statusFavouritesCount.visible(statusDisplayOptions.showStatsInline) - setFavouritedCount(status.actionable.favouritesCount) - setReblogsCount(status.actionable.reblogsCount) - super.setupWithStatus(status, listener, statusDisplayOptions, payloads) + setFavouritedCount(viewData.actionable.favouritesCount) + setReblogsCount(viewData.actionable.reblogsCount) + super.setupWithStatus(viewData, listener, statusDisplayOptions, payloads) } private fun setRebloggedByDisplayName( @@ -109,22 +108,18 @@ open class StatusViewHolder( } private fun setupCollapsedState( + viewData: T, sensitive: Boolean, expanded: Boolean, - status: StatusViewData, - listener: StatusActionListener, + listener: StatusActionListener, ) = with(binding) { /* input filter for TextViews have to be set before text */ - if (status.isCollapsible && (!sensitive || expanded)) { + if (viewData.isCollapsible && (!sensitive || expanded)) { buttonToggleContent.setOnClickListener { - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener - listener.onContentCollapsedChange( - !status.isCollapsed, - position, - ) + listener.onContentCollapsedChange(viewData, !viewData.isCollapsed) } buttonToggleContent.show() - if (status.isCollapsed) { + if (viewData.isCollapsed) { buttonToggleContent.setText(R.string.post_content_warning_show_more) content.filters = COLLAPSE_INPUT_FILTER } else { @@ -143,14 +138,14 @@ open class StatusViewHolder( } override fun toggleExpandedState( + viewData: T, sensitive: Boolean, expanded: Boolean, - status: StatusViewData, statusDisplayOptions: StatusDisplayOptions, - listener: StatusActionListener, + listener: StatusActionListener, ) { - setupCollapsedState(sensitive, expanded, status, listener) - super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener) + setupCollapsedState(viewData, sensitive, expanded, listener) + super.toggleExpandedState(viewData, sensitive, expanded, statusDisplayOptions, listener) } companion object { diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt b/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt index f806583d4..4d0a89b95 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationAdapter.kt @@ -27,7 +27,7 @@ import app.pachli.util.StatusDisplayOptions class ConversationAdapter( private var statusDisplayOptions: StatusDisplayOptions, - private val listener: StatusActionListener, + private val listener: StatusActionListener, ) : PagingDataAdapter(CONVERSATION_COMPARATOR) { var mediaPreviewEnabled: Boolean diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt b/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt index 97e9881c2..3aecb8de4 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationViewData.kt @@ -18,44 +18,22 @@ package app.pachli.components.conversation import app.pachli.core.database.model.ConversationAccountEntity import app.pachli.core.database.model.ConversationEntity -import app.pachli.core.network.model.Poll +import app.pachli.viewdata.IStatusViewData import app.pachli.viewdata.StatusViewData +/** + * Data necessary to show a conversation. + * + * Each conversation wraps the [StatusViewData] for the last status in the + * conversation for display. + */ data class ConversationViewData( val id: String, val order: Int, val accounts: List, val unread: Boolean, val lastStatus: StatusViewData, -) { - fun toConversationEntity( - accountId: Long, - favourited: Boolean = lastStatus.status.favourited, - bookmarked: Boolean = lastStatus.status.bookmarked, - muted: Boolean = lastStatus.status.muted ?: false, - poll: Poll? = lastStatus.status.poll, - expanded: Boolean = lastStatus.isExpanded, - collapsed: Boolean = lastStatus.isCollapsed, - showingHiddenContent: Boolean = lastStatus.isShowingContent, - ): ConversationEntity { - return ConversationEntity( - accountId = accountId, - id = id, - order = order, - accounts = accounts, - unread = unread, - lastStatus = lastStatus.toConversationStatusEntity( - favourited = favourited, - bookmarked = bookmarked, - muted = muted, - poll = poll, - expanded = expanded, - collapsed = collapsed, - showingHiddenContent = showingHiddenContent, - ), - ) - } - +) : IStatusViewData by lastStatus { companion object { fun from(conversationEntity: ConversationEntity) = ConversationViewData( id = conversationEntity.id, diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt index 88f074fa6..2a5e4fceb 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt @@ -22,7 +22,6 @@ import android.view.View import android.widget.Button import android.widget.ImageView import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView import app.pachli.R import app.pachli.adapter.StatusBaseViewHolder import app.pachli.core.database.model.ConversationAccountEntity @@ -36,8 +35,8 @@ import app.pachli.util.show class ConversationViewHolder internal constructor( itemView: View, private val statusDisplayOptions: StatusDisplayOptions, - private val listener: StatusActionListener, -) : StatusBaseViewHolder(itemView) { + private val listener: StatusActionListener, +) : StatusBaseViewHolder(itemView) { private val conversationNameTextView: TextView private val contentCollapseButton: Button private val avatars: Array @@ -53,31 +52,25 @@ class ConversationViewHolder internal constructor( } fun setupWithConversation( - conversation: ConversationViewData, + viewData: ConversationViewData, payloads: Any?, ) { - val statusViewData = conversation.lastStatus - val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = statusViewData.status + val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = viewData.status if (payloads == null) { - setupCollapsedState( - statusViewData.isCollapsible, - statusViewData.isCollapsed, - statusViewData.isExpanded, - statusViewData.spoilerText, - listener, - ) + setupCollapsedState(viewData, listener) setDisplayName(account.name, account.emojis, statusDisplayOptions) setUsername(account.username) - setMetaData(statusViewData, statusDisplayOptions, listener) + setMetaData(viewData, statusDisplayOptions, listener) setIsReply(inReplyToId != null) setFavourited(favourited) setBookmarked(bookmarked) if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { setMediaPreviews( + viewData, attachments, sensitive, listener, - statusViewData.isShowingContent, + viewData.isShowingContent, statusDisplayOptions.useBlurhash, ) if (attachments.isEmpty()) { @@ -88,24 +81,25 @@ class ConversationViewHolder internal constructor( mediaLabel.visibility = View.GONE } } else { - setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent) + setMediaLabel(viewData, attachments, sensitive, listener, viewData.isShowingContent) // Hide all unused views. mediaPreview.visibility = View.GONE hideSensitiveMediaWarning() } setupButtons( + viewData, listener, account.id, statusDisplayOptions, ) - setSpoilerAndContent(statusViewData, statusDisplayOptions, listener) - setConversationName(conversation.accounts) - setAvatars(conversation.accounts) + setSpoilerAndContent(viewData, statusDisplayOptions, listener) + setConversationName(viewData.accounts) + setAvatars(viewData.accounts) } else { if (payloads is List<*>) { for (item in payloads) { if (Key.KEY_CREATED == item) { - setMetaData(statusViewData, statusDisplayOptions, listener) + setMetaData(viewData, statusDisplayOptions, listener) } } } @@ -147,20 +141,16 @@ class ConversationViewHolder internal constructor( } private fun setupCollapsedState( - collapsible: Boolean, - collapsed: Boolean, - expanded: Boolean, - spoilerText: String, - listener: StatusActionListener, + viewData: ConversationViewData, + listener: StatusActionListener, ) { /* input filter for TextViews have to be set before text */ - if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { + if (viewData.isCollapsible && (viewData.isExpanded || TextUtils.isEmpty(viewData.spoilerText))) { contentCollapseButton.setOnClickListener { - val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener - listener.onContentCollapsedChange(!collapsed, position) + listener.onContentCollapsedChange(viewData, !viewData.isCollapsed) } contentCollapseButton.show() - if (collapsed) { + if (viewData.isCollapsed) { contentCollapseButton.setText(R.string.post_content_warning_show_more) content.filters = COLLAPSE_INPUT_FILTER } else { diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt index 324fd595a..16ecc7a1b 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt @@ -41,6 +41,8 @@ import app.pachli.appstore.EventHub import app.pachli.core.navigation.AccountActivityIntent import app.pachli.core.navigation.AttachmentViewData import app.pachli.core.navigation.StatusListActivityIntent +import app.pachli.core.network.model.Poll +import app.pachli.core.network.model.Status import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.databinding.FragmentTimelineBinding @@ -71,9 +73,9 @@ import kotlinx.coroutines.launch @AndroidEntryPoint class ConversationsFragment : - SFragment(), + SFragment(), OnRefreshListener, - StatusActionListener, + StatusActionListener, ReselectableFragment, MenuProvider { @@ -252,78 +254,63 @@ class ConversationsFragment : adapter.refresh() } - override fun onReblog(reblog: Boolean, position: Int) { + override fun onReblog(viewData: ConversationViewData, reblog: Boolean) { // its impossible to reblog private messages } - override fun onFavourite(favourite: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> - viewModel.favourite(favourite, conversation) - } + override fun onFavourite(viewData: ConversationViewData, favourite: Boolean) { + viewModel.favourite(favourite, viewData.lastStatus.actionableId) } - override fun onBookmark(favourite: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> - viewModel.bookmark(favourite, conversation) - } + override fun onBookmark(viewData: ConversationViewData, bookmark: Boolean) { + viewModel.bookmark(bookmark, viewData.lastStatus.actionableId) } - override fun onMore(view: View, position: Int) { - adapter.peek(position)?.let { conversation -> + override fun onMore(view: View, viewData: ConversationViewData) { + val status = viewData.lastStatus.status - val popup = PopupMenu(requireContext(), view) - popup.inflate(R.menu.conversation_more) + val popup = PopupMenu(requireContext(), view) + popup.inflate(R.menu.conversation_more) - if (conversation.lastStatus.status.muted == true) { - popup.menu.removeItem(R.id.status_mute_conversation) - } else { - popup.menu.removeItem(R.id.status_unmute_conversation) - } + if (status.muted == true) { + popup.menu.removeItem(R.id.status_mute_conversation) + } else { + popup.menu.removeItem(R.id.status_unmute_conversation) + } - popup.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.status_mute_conversation -> viewModel.muteConversation(conversation) - R.id.status_unmute_conversation -> viewModel.muteConversation(conversation) - R.id.conversation_delete -> deleteConversation(conversation) - } - true + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.status_mute_conversation -> viewModel.muteConversation(true, viewData.lastStatus.id) + R.id.status_unmute_conversation -> viewModel.muteConversation(false, viewData.lastStatus.id) + R.id.conversation_delete -> deleteConversation(viewData) } - popup.show() + true } + popup.show() } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - adapter.peek(position)?.let { conversation -> - viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view) - } + override fun onViewMedia(viewData: ConversationViewData, attachmentIndex: Int, view: View?) { + viewMedia(attachmentIndex, AttachmentViewData.list(viewData.lastStatus.status), view) } - override fun onViewThread(position: Int) { - adapter.peek(position)?.let { conversation -> - viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url) - } + override fun onViewThread(status: Status) { + viewThread(status.id, status.url) } - override fun onOpenReblog(position: Int) { + override fun onOpenReblog(status: Status) { // there are no reblogs in conversations } - override fun onExpandedChange(expanded: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> - viewModel.expandHiddenStatus(expanded, conversation) - } + override fun onExpandedChange(viewData: ConversationViewData, expanded: Boolean) { + viewModel.expandHiddenStatus(expanded, viewData.lastStatus.id) } - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> - viewModel.showContent(isShowing, conversation) - } + override fun onContentHiddenChange(viewData: ConversationViewData, isShowing: Boolean) { + viewModel.showContent(isShowing, viewData.lastStatus.id) } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> - viewModel.collapseLongStatus(isCollapsed, conversation) - } + override fun onContentCollapsedChange(viewData: ConversationViewData, isCollapsed: Boolean) { + viewModel.collapseLongStatus(isCollapsed, viewData.lastStatus.id) } override fun onViewAccount(id: String) { @@ -336,23 +323,19 @@ class ConversationsFragment : startActivity(intent) } - override fun removeItem(position: Int) { + override fun removeItem(viewData: ConversationViewData) { // not needed } - override fun onReply(position: Int) { - adapter.peek(position)?.let { conversation -> - reply(conversation.lastStatus.status) - } + override fun onReply(viewData: ConversationViewData) { + reply(viewData.lastStatus.actionable) } - override fun onVoteInPoll(position: Int, choices: List) { - adapter.peek(position)?.let { conversation -> - viewModel.voteInPoll(choices, conversation) - } + override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List) { + viewModel.voteInPoll(choices, viewData.lastStatus.actionableId, poll.id) } - override fun clearWarningAction(position: Int) { + override fun clearWarningAction(viewData: ConversationViewData) { } override fun onReselect() { diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt b/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt index e56e3efe6..ec402fca7 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationsViewModel.kt @@ -24,9 +24,9 @@ import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map import app.pachli.core.accounts.AccountManager +import app.pachli.core.database.Converters import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.di.TransactionProvider -import app.pachli.core.database.model.ConversationEntity import app.pachli.core.network.retrofit.MastodonApi import app.pachli.usecase.TimelineCases import app.pachli.util.EmptyPagingSource @@ -42,6 +42,7 @@ class ConversationsViewModel @Inject constructor( private val timelineCases: TimelineCases, transactionProvider: TransactionProvider, private val conversationsDao: ConversationsDao, + private val converters: Converters, private val accountManager: AccountManager, private val api: MastodonApi, ) : ViewModel() { @@ -70,89 +71,95 @@ class ConversationsViewModel @Inject constructor( } .cachedIn(viewModelScope) - fun favourite(favourite: Boolean, conversation: ConversationViewData) { + /** + * @param lastStatusId ID of the last status in the conversation + */ + fun favourite(favourite: Boolean, lastStatusId: String) { viewModelScope.launch { - timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - favourited = favourite, + timelineCases.favourite(lastStatusId, favourite).fold({ + conversationsDao.setFavourited( + accountManager.activeAccount!!.id, + lastStatusId, + favourite, ) - - saveConversationToDb(newConversation) }, { e -> Timber.w("failed to favourite status", e) }) } } - fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { + /** + * @param lastStatusId ID of the last status in the conversation + */ + fun bookmark(bookmark: Boolean, lastStatusId: String) { viewModelScope.launch { - timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - bookmarked = bookmark, + timelineCases.bookmark(lastStatusId, bookmark).fold({ + conversationsDao.setBookmarked( + accountManager.activeAccount!!.id, + lastStatusId, + bookmark, ) - - saveConversationToDb(newConversation) }, { e -> Timber.w("failed to bookmark status", e) }) } } - fun voteInPoll(choices: List, conversation: ConversationViewData) { + /** + * @param lastStatusId ID of the last status in the conversation + */ + fun voteInPoll(choices: List, lastStatusId: String, pollId: String) { viewModelScope.launch { - timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices) + timelineCases.voteInPoll(lastStatusId, pollId, choices) .fold({ poll -> - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - poll = poll, + conversationsDao.setVoted( + accountManager.activeAccount!!.id, + lastStatusId, + converters.pollToJson(poll)!!, ) - - saveConversationToDb(newConversation) }, { e -> Timber.w("failed to vote in poll", e) }) } } - fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { + fun expandHiddenStatus(expanded: Boolean, lastStatusId: String) { viewModelScope.launch { - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - expanded = expanded, + conversationsDao.setExpanded( + accountManager.activeAccount!!.id, + lastStatusId, + expanded, ) - saveConversationToDb(newConversation) } } - fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { + fun collapseLongStatus(collapsed: Boolean, lastStatusId: String) { viewModelScope.launch { - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - collapsed = collapsed, + conversationsDao.setCollapsed( + accountManager.activeAccount!!.id, + lastStatusId, + collapsed, ) - saveConversationToDb(newConversation) } } - fun showContent(showing: Boolean, conversation: ConversationViewData) { + fun showContent(showingHiddenContent: Boolean, lastStatusId: String) { viewModelScope.launch { - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - showingHiddenContent = showing, + conversationsDao.setShowingHiddenContent( + accountManager.activeAccount!!.id, + lastStatusId, + showingHiddenContent, ) - saveConversationToDb(newConversation) } } - fun remove(conversation: ConversationViewData) { + fun remove(viewData: ConversationViewData) { viewModelScope.launch { try { - api.deleteConversation(conversationId = conversation.id) + api.deleteConversation(conversationId = viewData.id) conversationsDao.delete( - id = conversation.id, + id = viewData.id, accountId = accountManager.activeAccount!!.id, ) } catch (e: Exception) { @@ -161,27 +168,19 @@ class ConversationsViewModel @Inject constructor( } } - fun muteConversation(conversation: ConversationViewData) { + fun muteConversation(muted: Boolean, lastStatusId: String) { viewModelScope.launch { try { - timelineCases.muteConversation( - conversation.lastStatus.id, - !(conversation.lastStatus.status.muted ?: false), - ) + timelineCases.muteConversation(lastStatusId, muted) - val newConversation = conversation.toConversationEntity( - accountId = accountManager.activeAccount!!.id, - muted = !(conversation.lastStatus.status.muted ?: false), + conversationsDao.setMuted( + accountManager.activeAccount!!.id, + lastStatusId, + muted, ) - - conversationsDao.insert(newConversation) } catch (e: Exception) { Timber.w("failed to mute conversation", e) } } } - - private suspend fun saveConversationToDb(conversation: ConversationEntity) { - conversationsDao.insert(conversation) - } } diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index 741e5fc6c..b1331179c 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -49,6 +49,7 @@ import app.pachli.components.timeline.TimelineLoadStateAdapter import app.pachli.core.navigation.AttachmentViewData.Companion.list import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Notification +import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.databinding.FragmentTimelineNotificationsBinding import app.pachli.fragment.SFragment @@ -86,8 +87,8 @@ import timber.log.Timber @AndroidEntryPoint class NotificationsFragment : - SFragment(), - StatusActionListener, + SFragment(), + StatusActionListener, NotificationActionListener, AccountActionListener, OnRefreshListener, @@ -144,17 +145,8 @@ class NotificationsFragment : layoutManager = LinearLayoutManager(context) binding.recyclerView.layoutManager = layoutManager binding.recyclerView.setAccessibilityDelegateCompat( - ListStatusAccessibilityDelegate( - binding.recyclerView, - this, - ) { pos: Int -> - val notification = adapter.snapshot().getOrNull(pos) - // We support replies only for now - if (notification is NotificationViewData) { - notification.statusViewData - } else { - null - } + ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos: Int -> + adapter.snapshot().getOrNull(pos) }, ) binding.recyclerView.addItemDecoration( @@ -513,90 +505,93 @@ class NotificationsFragment : clearNotificationsForAccount(requireContext(), viewModel.account) } - override fun onReply(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.reply(status) + override fun onReply(viewData: NotificationViewData) { + super.reply(viewData.statusViewData!!.actionable) } - override fun onReblog(reblog: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) + override fun onReblog(viewData: NotificationViewData, reblog: Boolean) { + viewModel.accept(StatusAction.Reblog(reblog, viewData.statusViewData!!)) } - override fun onFavourite(favourite: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) + override fun onFavourite(viewData: NotificationViewData, favourite: Boolean) { + viewModel.accept(StatusAction.Favourite(favourite, viewData.statusViewData!!)) } - override fun onBookmark(bookmark: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) + override fun onBookmark(viewData: NotificationViewData, bookmark: Boolean) { + viewModel.accept(StatusAction.Bookmark(bookmark, viewData.statusViewData!!)) } - override fun onVoteInPoll(position: Int, choices: List) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - val poll = statusViewData.actionable.poll ?: return - viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) + override fun onVoteInPoll(viewData: NotificationViewData, poll: Poll, choices: List) { + viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData.statusViewData!!)) } - override fun onMore(view: View, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - super.more(statusViewData, view, position) + override fun onMore(view: View, viewData: NotificationViewData) { + super.more(view, viewData) } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position)?.statusViewData?.status ?: return + override fun onViewMedia(viewData: NotificationViewData, attachmentIndex: Int, view: View?) { super.viewMedia( attachmentIndex, - list(status, viewModel.statusDisplayOptions.value.showSensitiveMedia), + list(viewData.statusViewData!!.status, viewModel.statusDisplayOptions.value.showSensitiveMedia), view, ) } - override fun onViewThread(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return + override fun onViewThread(status: Status) { super.viewThread(status.actionableId, status.actionableStatus.url) } - override fun onOpenReblog(position: Int) { - val account = adapter.peek(position)?.account!! - onViewAccount(account.id) + override fun onOpenReblog(status: Status) { + onViewAccount(status.account.id) } - override fun onExpandedChange(expanded: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isExpanded = expanded, - ) - adapter.notifyItemChanged(position) + override fun onExpandedChange(viewData: NotificationViewData, expanded: Boolean) { + adapter.snapshot().withIndex() + .filter { + it.value?.statusViewData?.actionableId == viewData.statusViewData!!.actionableId + } + .map { + it.value?.statusViewData = it.value?.statusViewData?.copy(isExpanded = expanded) + adapter.notifyItemChanged(it.index) + } } - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isShowingContent = isShowing, - ) - adapter.notifyItemChanged(position) + override fun onContentHiddenChange(viewData: NotificationViewData, isShowing: Boolean) { + adapter.snapshot().withIndex() + .filter { + it.value?.statusViewData?.actionableId == viewData.statusViewData!!.actionableId + } + .map { + it.value?.statusViewData = it.value?.statusViewData?.copy(isShowingContent = isShowing) + adapter.notifyItemChanged(it.index) + } } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isCollapsed = isCollapsed, - ) - adapter.notifyItemChanged(position) + override fun onContentCollapsedChange(viewData: NotificationViewData, isCollapsed: Boolean) { + adapter.snapshot().withIndex().filter { + it.value?.statusViewData?.actionableId == viewData.statusViewData!!.actionableId + } + .map { + it.value?.statusViewData = it.value?.statusViewData?.copy(isCollapsed = isCollapsed) + adapter.notifyItemChanged(it.index) + } } - override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { - onContentCollapsedChange(isCollapsed, position) + override fun onNotificationContentCollapsedChange( + isCollapsed: Boolean, + viewData: NotificationViewData, + ) { + onContentCollapsedChange(viewData, isCollapsed) } - override fun clearWarningAction(position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - filterAction = Filter.Action.NONE, - ) - adapter.notifyItemChanged(position) + override fun clearWarningAction(viewData: NotificationViewData) { + adapter.snapshot().withIndex().filter { it.value?.statusViewData?.actionableId == viewData.statusViewData!!.actionableId } + .map { + it.value?.statusViewData = it.value?.statusViewData?.copy( + filterAction = Filter.Action.NONE, + ) + adapter.notifyItemChanged(it.index) + } } private fun clearNotifications() { @@ -648,7 +643,7 @@ class NotificationsFragment : ) } - public override fun removeItem(position: Int) { + override fun removeItem(viewData: NotificationViewData) { // Empty -- this fragment doesn't remove items } diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt index a8084168f..53782ac5e 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt @@ -87,26 +87,27 @@ interface NotificationActionListener { * the warning is being changed. * * @param expanded the desired state of the content behind the content warning - * @param position the adapter position of the view * */ - fun onExpandedChange(expanded: Boolean, position: Int) + fun onExpandedChange(viewData: NotificationViewData, expanded: Boolean) /** * Called when the status [android.widget.ToggleButton] responsible for collapsing long * status content is interacted with. * * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. */ - fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) + fun onNotificationContentCollapsedChange( + isCollapsed: Boolean, + viewData: NotificationViewData, + ) } class NotificationsPagingAdapter( diffCallback: DiffUtil.ItemCallback, /** ID of the the account that notifications are being displayed for */ private val accountId: String, - private val statusActionListener: StatusActionListener, + private val statusActionListener: StatusActionListener, private val notificationActionListener: NotificationActionListener, private val accountActionListener: AccountActionListener, var statusDisplayOptions: StatusDisplayOptions, diff --git a/app/src/main/java/app/pachli/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/app/pachli/components/notifications/StatusNotificationViewHolder.kt index 6d3d427e4..50df3ed9c 100644 --- a/app/src/main/java/app/pachli/components/notifications/StatusNotificationViewHolder.kt +++ b/app/src/main/java/app/pachli/components/notifications/StatusNotificationViewHolder.kt @@ -65,7 +65,7 @@ import java.util.Date */ internal class StatusNotificationViewHolder( private val binding: ItemStatusNotificationBinding, - private val statusActionListener: StatusActionListener, + private val statusActionListener: StatusActionListener, private val notificationActionListener: NotificationActionListener, private val absoluteTimeFormatter: AbsoluteTimeFormatter, ) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { @@ -114,10 +114,10 @@ internal class StatusNotificationViewHolder( } binding.notificationContainer.setOnClickListener { - notificationActionListener.onViewThreadForStatus(statusViewData.status) + notificationActionListener.onViewThreadForStatus(viewData.status) } binding.notificationContent.setOnClickListener { - notificationActionListener.onViewThreadForStatus(statusViewData.status) + notificationActionListener.onViewThreadForStatus(viewData.status) } binding.notificationTopText.setOnClickListener { notificationActionListener.onViewAccount(viewData.account.id) @@ -128,7 +128,7 @@ internal class StatusNotificationViewHolder( for (item in payloads) { if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { setCreatedAt( - statusViewData.status.actionableStatus.createdAt, + viewData.actionable.createdAt, statusDisplayOptions.useAbsoluteTime, ) } @@ -237,13 +237,13 @@ internal class StatusNotificationViewHolder( } fun setMessage( - notificationViewData: NotificationViewData, + viewData: NotificationViewData, listener: LinkListener, animateEmojis: Boolean, ) { - val statusViewData = notificationViewData.statusViewData - val displayName = notificationViewData.account.name.unicodeWrap() - val type = notificationViewData.type + val statusViewData = viewData.statusViewData + val displayName = viewData.account.name.unicodeWrap() + val type = viewData.type val context = binding.notificationTopText.context val format: String val icon: Drawable? @@ -285,42 +285,44 @@ internal class StatusNotificationViewHolder( Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, ) val emojifiedText = str.emojify( - notificationViewData.account.emojis, + viewData.account.emojis, binding.notificationTopText, animateEmojis, ) binding.notificationTopText.text = emojifiedText - if (statusViewData != null) { - val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) - binding.notificationContentWarningDescription.visibility = - if (hasSpoiler) View.VISIBLE else View.GONE - binding.notificationContentWarningButton.visibility = - if (hasSpoiler) View.VISIBLE else View.GONE - if (statusViewData.isExpanded) { - binding.notificationContentWarningButton.setText( - R.string.post_content_warning_show_less, - ) - } else { - binding.notificationContentWarningButton.setText( - R.string.post_content_warning_show_more, + + statusViewData ?: return + + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + binding.notificationContentWarningDescription.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + if (statusViewData.isExpanded) { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_less, + ) + } else { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_more, + ) + } + binding.notificationContentWarningButton.setOnClickListener { + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange( + viewData, + !statusViewData.isExpanded, ) } - binding.notificationContentWarningButton.setOnClickListener { - if (bindingAdapterPosition != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange( - !statusViewData.isExpanded, - bindingAdapterPosition, - ) - } - binding.notificationContent.visibility = - if (statusViewData.isExpanded) View.GONE else View.VISIBLE - } - setupContentAndSpoiler(listener, statusViewData, animateEmojis) + binding.notificationContent.visibility = + if (statusViewData.isExpanded) View.GONE else View.VISIBLE } + setupContentAndSpoiler(listener, viewData, statusViewData, animateEmojis) } private fun setupContentAndSpoiler( listener: LinkListener, + viewData: NotificationViewData, statusViewData: StatusViewData, animateEmojis: Boolean, ) { @@ -339,7 +341,7 @@ internal class StatusNotificationViewHolder( if (position != RecyclerView.NO_POSITION) { notificationActionListener.onNotificationContentCollapsedChange( !statusViewData.isCollapsed, - position, + viewData, ) } } diff --git a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt index d8b64119e..0bcb49e15 100644 --- a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt +++ b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt @@ -28,9 +28,9 @@ import app.pachli.viewdata.NotificationViewData internal class StatusViewHolder( binding: ItemStatusBinding, - private val statusActionListener: StatusActionListener, + private val statusActionListener: StatusActionListener, private val accountId: String, -) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding) { +) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding) { override fun bind( viewData: NotificationViewData, @@ -47,7 +47,7 @@ internal class StatusViewHolder( showStatusContent(true) } setupWithStatus( - statusViewData, + viewData, statusActionListener, statusDisplayOptions, payloads?.firstOrNull(), @@ -63,9 +63,9 @@ internal class StatusViewHolder( class FilterableStatusViewHolder( binding: ItemStatusWrapperBinding, - private val statusActionListener: StatusActionListener, + private val statusActionListener: StatusActionListener, private val accountId: String, -) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder(binding) { +) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder(binding) { // Note: Identical to bind() in StatusViewHolder above override fun bind( viewData: NotificationViewData, @@ -82,7 +82,7 @@ class FilterableStatusViewHolder( showStatusContent(true) } setupWithStatus( - statusViewData, + viewData, statusActionListener, statusDisplayOptions, payloads?.firstOrNull(), diff --git a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt index 84a7ad07c..120596238 100644 --- a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt +++ b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt @@ -25,6 +25,7 @@ import app.pachli.components.search.adapter.SearchPagingSourceFactory import app.pachli.core.accounts.AccountManager import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.model.DeletedStatus +import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi import app.pachli.usecase.TimelineCases @@ -139,8 +140,8 @@ class SearchViewModel @Inject constructor( updateStatusViewData(statusViewData.copy(isCollapsed = collapsed)) } - fun voteInPoll(statusViewData: StatusViewData, choices: List) { - val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) + fun voteInPoll(statusViewData: StatusViewData, poll: Poll, choices: List) { + val votedPoll = poll.votedCopy(choices) updateStatus(statusViewData.status.copy(poll = votedPoll)) viewModelScope.launch { timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices) diff --git a/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt index 06b7db825..c1ecdc64f 100644 --- a/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/app/pachli/components/search/adapter/SearchStatusesAdapter.kt @@ -28,16 +28,16 @@ import app.pachli.viewdata.StatusViewData class SearchStatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, - private val statusListener: StatusActionListener, -) : PagingDataAdapter(STATUS_COMPARATOR) { + private val statusListener: StatusActionListener, +) : PagingDataAdapter>(STATUS_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { return StatusViewHolder( ItemStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false), ) } - override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { + override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { getItem(position)?.let { item -> holder.setupWithStatus(item, statusListener, statusDisplayOptions) } diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt index f5ead6be8..5bf2c344d 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt @@ -46,6 +46,7 @@ import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ReportActivityIntent import app.pachli.core.navigation.ViewMediaActivityIntent import app.pachli.core.network.model.Attachment +import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status.Mention import app.pachli.interfaces.AccountSelectionListener @@ -64,7 +65,7 @@ import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint -class SearchStatusesFragment : SearchFragment(), StatusActionListener { +class SearchStatusesFragment : SearchFragment(), StatusActionListener { @Inject lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository @@ -84,109 +85,80 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis return SearchStatusesAdapter(statusDisplayOptions, this) } - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - searchAdapter.peek(position)?.let { - viewModel.contentHiddenChange(it, isShowing) - } + override fun onContentHiddenChange(viewData: StatusViewData, isShowing: Boolean) { + viewModel.contentHiddenChange(viewData, isShowing) } - override fun onReply(position: Int) { - searchAdapter.peek(position)?.let { status -> - reply(status) - } + override fun onReply(viewData: StatusViewData) { + reply(viewData) } - override fun onFavourite(favourite: Boolean, position: Int) { - searchAdapter.peek(position)?.let { status -> - viewModel.favorite(status, favourite) - } + override fun onFavourite(viewData: StatusViewData, favourite: Boolean) { + viewModel.favorite(viewData, favourite) } - override fun onBookmark(bookmark: Boolean, position: Int) { - searchAdapter.peek(position)?.let { status -> - viewModel.bookmark(status, bookmark) - } + override fun onBookmark(viewData: StatusViewData, bookmark: Boolean) { + viewModel.bookmark(viewData, bookmark) } - override fun onMore(view: View, position: Int) { - searchAdapter.peek(position)?.status?.let { - more(it, view, position) - } + override fun onMore(view: View, viewData: StatusViewData) { + more(viewData, view) } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - searchAdapter.peek(position)?.status?.actionableStatus?.let { actionable -> - when (actionable.attachments[attachmentIndex].type) { - Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { - val attachments = AttachmentViewData.list(actionable) - val intent = ViewMediaActivityIntent( - requireContext(), - attachments, - attachmentIndex, + override fun onViewMedia(viewData: StatusViewData, attachmentIndex: Int, view: View?) { + val actionable = viewData.actionable + when (actionable.attachments[attachmentIndex].type) { + Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { + val attachments = AttachmentViewData.list(actionable) + val intent = ViewMediaActivityIntent( + requireContext(), + attachments, + attachmentIndex, + ) + if (view != null) { + val url = actionable.attachments[attachmentIndex].url + ViewCompat.setTransitionName(view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + view, + url, ) - if (view != null) { - val url = actionable.attachments[attachmentIndex].url - ViewCompat.setTransitionName(view, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation( - requireActivity(), - view, - url, - ) - startActivity(intent, options.toBundle()) - } else { - startActivity(intent) - } - } - Attachment.Type.UNKNOWN -> { - context?.openLink(actionable.attachments[attachmentIndex].url) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) } } + Attachment.Type.UNKNOWN -> { + context?.openLink(actionable.attachments[attachmentIndex].url) + } } } - override fun onViewThread(position: Int) { - searchAdapter.peek(position)?.status?.let { status -> - val actionableStatus = status.actionableStatus - bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) - } + override fun onViewThread(status: Status) { + val actionableStatus = status.actionableStatus + bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) } - override fun onOpenReblog(position: Int) { - searchAdapter.peek(position)?.status?.let { status -> - bottomSheetActivity?.viewAccount(status.account.id) - } + override fun onOpenReblog(status: Status) { + bottomSheetActivity?.viewAccount(status.account.id) } - override fun onExpandedChange(expanded: Boolean, position: Int) { - searchAdapter.peek(position)?.let { - viewModel.expandedChange(it, expanded) - } + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { + viewModel.expandedChange(viewData, expanded) } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - searchAdapter.peek(position)?.let { - viewModel.collapsedChange(it, isCollapsed) - } + override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { + viewModel.collapsedChange(viewData, isCollapsed) } - override fun onVoteInPoll(position: Int, choices: List) { - searchAdapter.peek(position)?.let { - viewModel.voteInPoll(it, choices) - } + override fun onVoteInPoll(viewData: StatusViewData, poll: Poll, choices: List) { + viewModel.voteInPoll(viewData, poll, choices) } - override fun clearWarningAction(position: Int) {} + override fun clearWarningAction(viewData: StatusViewData) {} - private fun removeItem(position: Int) { - searchAdapter.peek(position)?.let { - viewModel.removeItem(it) - } - } - - override fun onReblog(reblog: Boolean, position: Int) { - searchAdapter.peek(position)?.let { status -> - viewModel.reblog(status, reblog) - } + override fun onReblog(viewData: StatusViewData, reblog: Boolean) { + viewModel.reblog(viewData, reblog) } companion object { @@ -218,11 +190,12 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis bottomSheetActivity?.startActivityWithSlideInAnimation(intent) } - private fun more(status: Status, view: View, position: Int) { - val id = status.actionableId - val accountId = status.actionableStatus.account.id - val accountUsername = status.actionableStatus.account.username - val statusUrl = status.actionableStatus.url + private fun more(statusViewData: StatusViewData, view: View) { + val id = statusViewData.actionableId + val status = statusViewData.actionable + val accountId = status.account.id + val accountUsername = status.account.username + val statusUrl = status.url val loggedInAccountId = viewModel.activeAccount?.accountId val popup = PopupMenu(view.context, view) @@ -312,9 +285,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis return@setOnMenuItemClickListener true } R.id.status_mute_conversation -> { - searchAdapter.peek(position)?.let { foundStatus -> - viewModel.muteConversation(foundStatus, status.muted != true) - } + viewModel.muteConversation(statusViewData, status.muted != true) return@setOnMenuItemClickListener true } R.id.status_mute -> { @@ -330,23 +301,23 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis return@setOnMenuItemClickListener true } R.id.status_unreblog_private -> { - onReblog(false, position) + onReblog(statusViewData, false) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { - onReblog(true, position) + onReblog(statusViewData, true) return@setOnMenuItemClickListener true } R.id.status_delete -> { - showConfirmDeleteDialog(id, position) + showConfirmDeleteDialog(statusViewData) return@setOnMenuItemClickListener true } R.id.status_delete_and_redraft -> { - showConfirmEditDialog(id, position, status) + showConfirmEditDialog(statusViewData) return@setOnMenuItemClickListener true } R.id.status_edit -> { - editStatus(id, position, status) + editStatus(id, status) return@setOnMenuItemClickListener true } R.id.pin -> { @@ -426,31 +397,33 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis startActivity(ReportActivityIntent(requireContext(), accountId, accountUsername, statusId)) } - private fun showConfirmDeleteDialog(id: String, position: Int) { + // TODO: Identical to the same function in SFragment.kt + private fun showConfirmDeleteDialog(statusViewData: StatusViewData) { context?.let { AlertDialog.Builder(it) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatusAsync(id) - removeItem(position) + viewModel.deleteStatusAsync(statusViewData.id) + viewModel.removeItem(statusViewData) } .setNegativeButton(android.R.string.cancel, null) .show() } } - private fun showConfirmEditDialog(id: String, position: Int, status: Status) { + // TODO: Identical to the same function in SFragment.kt + private fun showConfirmEditDialog(statusViewData: StatusViewData) { activity?.let { AlertDialog.Builder(it) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> lifecycleScope.launch { - viewModel.deleteStatusAsync(id).await().fold( + viewModel.deleteStatusAsync(statusViewData.id).await().fold( { deletedStatus -> - removeItem(position) + viewModel.removeItem(statusViewData) val redraftStatus = if (deletedStatus.isEmpty()) { - status.toDeletedStatus() + statusViewData.status.toDeletedStatus() } else { deletedStatus } @@ -464,7 +437,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis contentWarning = redraftStatus.spoilerText, mediaAttachments = redraftStatus.attachments, sensitive = redraftStatus.sensitive, - poll = redraftStatus.poll?.toNewPoll(status.createdAt), + poll = redraftStatus.poll?.toNewPoll(redraftStatus.createdAt), language = redraftStatus.language, kind = ComposeOptions.ComposeKind.NEW, ), @@ -483,7 +456,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis } } - private fun editStatus(id: String, position: Int, status: Status) { + private fun editStatus(id: String, status: Status) { lifecycleScope.launch { mastodonApi.statusSource(id).fold( { source -> diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index e26bb3a4d..04014c72b 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -52,6 +52,7 @@ import app.pachli.components.timeline.viewmodel.UiSuccess import app.pachli.core.database.model.TranslationState import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AttachmentViewData +import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineKind import app.pachli.databinding.FragmentTimelineBinding @@ -95,9 +96,9 @@ import timber.log.Timber @AndroidEntryPoint class TimelineFragment : - SFragment(), + SFragment(), OnRefreshListener, - StatusActionListener, + StatusActionListener, ReselectableFragment, RefreshableFragment, MenuProvider { @@ -570,86 +571,58 @@ class TimelineFragment : adapter.refresh() } - override fun onReply(position: Int) { - val status = adapter.peek(position) ?: return - super.reply(status.status) + override fun onReply(viewData: StatusViewData) { + super.reply(viewData.actionable) } - override fun onReblog(reblog: Boolean, position: Int) { - val statusViewData = adapter.peek(position) ?: return - viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) + override fun onReblog(viewData: StatusViewData, reblog: Boolean) { + viewModel.accept(StatusAction.Reblog(reblog, viewData)) } - override fun onFavourite(favourite: Boolean, position: Int) { - val statusViewData = adapter.peek(position) ?: return - viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) + override fun onFavourite(viewData: StatusViewData, favourite: Boolean) { + viewModel.accept(StatusAction.Favourite(favourite, viewData)) } - override fun onBookmark(bookmark: Boolean, position: Int) { - val statusViewData = adapter.peek(position) ?: return - viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) + override fun onBookmark(viewData: StatusViewData, bookmark: Boolean) { + viewModel.accept(StatusAction.Bookmark(bookmark, viewData)) } - override fun onVoteInPoll(position: Int, choices: List) { - val statusViewData = adapter.peek(position) ?: run { - Snackbar.make( - binding.root, - "null at adapter.peek($position)", - Snackbar.LENGTH_INDEFINITE, - ).show() - null - } ?: return - val poll = statusViewData.actionable.poll ?: run { - Snackbar.make( - binding.root, - "statusViewData had null poll", - Snackbar.LENGTH_INDEFINITE, - ).show() - null - } ?: return - viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) + override fun onVoteInPoll(viewData: StatusViewData, poll: Poll, choices: List) { + viewModel.accept(StatusAction.VoteInPoll(poll, choices, viewData)) } - override fun clearWarningAction(position: Int) { - val status = adapter.peek(position) ?: return - viewModel.clearWarning(status) + override fun clearWarningAction(viewData: StatusViewData) { + viewModel.clearWarning(viewData) } - override fun onMore(view: View, position: Int) { - val statusViewData = adapter.peek(position) ?: return - super.more(statusViewData, view, position) + override fun onMore(view: View, viewData: StatusViewData) { + super.more(view, viewData) } - override fun onOpenReblog(position: Int) { - val status = adapter.peek(position) ?: return - super.openReblog(status.status) + override fun onOpenReblog(status: Status) { + super.openReblog(status) } - override fun onExpandedChange(expanded: Boolean, position: Int) { - val status = adapter.peek(position) ?: return - viewModel.changeExpanded(expanded, status) + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { + viewModel.changeExpanded(expanded, viewData) } - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val status = adapter.peek(position) ?: return - viewModel.changeContentShowing(isShowing, status) + override fun onContentHiddenChange(viewData: StatusViewData, isShowing: Boolean) { + viewModel.changeContentShowing(isShowing, viewData) } - override fun onShowReblogs(position: Int) { - val statusId = adapter.peek(position)?.id ?: return + override fun onShowReblogs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } - override fun onShowFavs(position: Int) { - val statusId = adapter.peek(position)?.id ?: return + override fun onShowFavs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val status = adapter.peek(position) ?: return - viewModel.changeContentCollapsed(isCollapsed, status) + override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { + viewModel.changeContentCollapsed(isCollapsed, viewData) } // Can only translate the home timeline at the moment @@ -663,26 +636,23 @@ class TimelineFragment : viewModel.accept(InfallibleUiAction.TranslateUndo(statusViewData)) } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val statusViewData = adapter.peek(position) ?: return - + override fun onViewMedia(viewData: StatusViewData, attachmentIndex: Int, view: View?) { // Pass the translated media descriptions through (if appropriate) - val actionable = if (statusViewData.translationState == TranslationState.SHOW_TRANSLATION) { - statusViewData.actionable.copy( - attachments = statusViewData.translation?.attachments?.zip(statusViewData.actionable.attachments) { t, a -> + val actionable = if (viewData.translationState == TranslationState.SHOW_TRANSLATION) { + viewData.actionable.copy( + attachments = viewData.translation?.attachments?.zip(viewData.actionable.attachments) { t, a -> a.copy(description = t.description) - } ?: statusViewData.actionable.attachments, + } ?: viewData.actionable.attachments, ) } else { - statusViewData.actionable + viewData.actionable } super.viewMedia(attachmentIndex, AttachmentViewData.list(actionable), view) } - override fun onViewThread(position: Int) { - val status = adapter.peek(position) ?: return - super.viewThread(status.actionable.id, status.actionable.url) + override fun onViewThread(status: Status) { + super.viewThread(status.id, status.url) } override fun onViewTag(tag: String) { @@ -736,9 +706,8 @@ class TimelineFragment : } } - public override fun removeItem(position: Int) { - val status = adapter.peek(position) ?: return - viewModel.removeStatusWithId(status.id) + public override fun removeItem(viewData: StatusViewData) { + viewModel.removeStatusWithId(viewData.id) } private fun actionButtonPresent(): Boolean { diff --git a/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt index abad4f4f6..ac9bd9fd5 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelinePagingAdapter.kt @@ -33,17 +33,17 @@ import app.pachli.util.StatusDisplayOptions import app.pachli.viewdata.StatusViewData class TimelinePagingAdapter( - private val statusListener: StatusActionListener, + private val statusListener: StatusActionListener, var statusDisplayOptions: StatusDisplayOptions, ) : PagingDataAdapter(TimelineDifferCallback) { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(viewGroup.context) return when (viewType) { VIEW_TYPE_STATUS_FILTERED -> { - FilterableStatusViewHolder(ItemStatusWrapperBinding.inflate(inflater, viewGroup, false)) + FilterableStatusViewHolder(ItemStatusWrapperBinding.inflate(inflater, viewGroup, false)) } VIEW_TYPE_STATUS -> { - StatusViewHolder(ItemStatusBinding.inflate(inflater, viewGroup, false)) + StatusViewHolder(ItemStatusBinding.inflate(inflater, viewGroup, false)) } else -> return object : RecyclerView.ViewHolder(inflater.inflate(R.layout.item_placeholder, viewGroup, false)) {} } @@ -67,7 +67,7 @@ class TimelinePagingAdapter( payloads: List<*>?, ) { getItem(position)?.let { - (viewHolder as StatusViewHolder).setupWithStatus( + (viewHolder as StatusViewHolder).setupWithStatus( it, statusListener, statusDisplayOptions, diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index 2bb3ac95c..ddc9166c4 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -136,9 +136,9 @@ class CachedTimelineViewModel @Inject constructor( } } - override fun clearWarning(status: StatusViewData) { + override fun clearWarning(statusViewData: StatusViewData) { viewModelScope.launch { - repository.clearStatusWarning(status.actionableId) + repository.clearStatusWarning(statusViewData.actionableId) } } diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt index dbc82dd5f..0ec0fca98 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -195,9 +195,9 @@ class NetworkTimelineViewModel @Inject constructor( reloadKeepingReadingPosition() } - override fun clearWarning(status: StatusViewData) { + override fun clearWarning(statusViewData: StatusViewData) { viewModelScope.launch { - repository.updateActionableStatusById(status.actionableId) { + repository.updateActionableStatusById(statusViewData.actionableId) { it.copy(filtered = null) } } diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt index 9536f306f..585b5492e 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt @@ -497,7 +497,7 @@ abstract class TimelineViewModel( reload.getAndUpdate { it + 1 } } - abstract fun clearWarning(status: StatusViewData) + abstract fun clearWarning(statusViewData: StatusViewData) /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() diff --git a/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt b/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt index 75624bc43..a032ba641 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ThreadAdapter.kt @@ -34,10 +34,10 @@ import app.pachli.viewdata.StatusViewData class ThreadAdapter( private val statusDisplayOptions: StatusDisplayOptions, - private val statusActionListener: StatusActionListener, -) : ListAdapter(ThreadDifferCallback) { + private val statusActionListener: StatusActionListener, +) : ListAdapter>(ThreadDifferCallback) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_STATUS -> { @@ -53,7 +53,7 @@ class ThreadAdapter( } } - override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { + override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { val status = getItem(position) viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) } diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt index a54c29a25..107239a29 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt @@ -37,6 +37,8 @@ import app.pachli.R import app.pachli.components.viewthread.edits.ViewEditsFragment import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AttachmentViewData.Companion.list +import app.pachli.core.network.model.Poll +import app.pachli.core.network.model.Status import app.pachli.databinding.FragmentViewThreadBinding import app.pachli.fragment.SFragment import app.pachli.interfaces.StatusActionListener @@ -58,9 +60,9 @@ import timber.log.Timber @AndroidEntryPoint class ViewThreadFragment : - SFragment(), + SFragment(), OnRefreshListener, - StatusActionListener, + StatusActionListener, MenuProvider { private val viewModel: ViewThreadViewModel by viewModels() @@ -298,45 +300,40 @@ class ViewThreadFragment : viewModel.refresh(thisThreadsStatusId) } - override fun onReply(position: Int) { - super.reply(adapter.currentList[position].status) + override fun onReply(viewData: StatusViewData) { + super.reply(viewData.actionable) } - override fun onReblog(reblog: Boolean, position: Int) { - val status = adapter.currentList[position] - viewModel.reblog(reblog, status) + override fun onReblog(viewData: StatusViewData, reblog: Boolean) { + viewModel.reblog(reblog, viewData) } - override fun onFavourite(favourite: Boolean, position: Int) { - val status = adapter.currentList[position] - viewModel.favorite(favourite, status) + override fun onFavourite(viewData: StatusViewData, favourite: Boolean) { + viewModel.favorite(favourite, viewData) } - override fun onBookmark(bookmark: Boolean, position: Int) { - val status = adapter.currentList[position] - viewModel.bookmark(bookmark, status) + override fun onBookmark(viewData: StatusViewData, bookmark: Boolean) { + viewModel.bookmark(bookmark, viewData) } - override fun onMore(view: View, position: Int) { - super.more(adapter.currentList[position], view, position) + override fun onMore(view: View, viewData: StatusViewData) { + super.more(view, viewData) } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.currentList[position].status + override fun onViewMedia(viewData: StatusViewData, attachmentIndex: Int, view: View?) { super.viewMedia( attachmentIndex, - list(status, alwaysShowSensitiveMedia), + list(viewData.actionable, alwaysShowSensitiveMedia), view, ) } - override fun onViewThread(position: Int) { - val status = adapter.currentList[position] + override fun onViewThread(status: Status) { if (thisThreadsStatusId == status.id) { // If already viewing this thread, don't reopen it. return } - super.viewThread(status.actionableId, status.actionable.url) + super.viewThread(status.actionableId, status.actionableStatus.url) } override fun onViewUrl(url: String) { @@ -351,32 +348,30 @@ class ViewThreadFragment : super.onViewUrl(url) } - override fun onOpenReblog(position: Int) { + override fun onOpenReblog(status: Status) { // there are no reblogs in threads } - override fun onExpandedChange(expanded: Boolean, position: Int) { - viewModel.changeExpanded(expanded, adapter.currentList[position]) + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { + viewModel.changeExpanded(expanded, viewData) } - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - viewModel.changeContentShowing(isShowing, adapter.currentList[position]) + override fun onContentHiddenChange(viewData: StatusViewData, isShowing: Boolean) { + viewModel.changeContentShowing(isShowing, viewData) } - override fun onShowReblogs(position: Int) { - val statusId = adapter.currentList[position].id + override fun onShowReblogs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId) (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) } - override fun onShowFavs(position: Int) { - val statusId = adapter.currentList[position].id + override fun onShowFavs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId) (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position]) + override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { + viewModel.changeContentCollapsed(isCollapsed, viewData) } override fun onViewTag(tag: String) { @@ -387,25 +382,21 @@ class ViewThreadFragment : super.viewAccount(id) } - public override fun removeItem(position: Int) { - adapter.currentList.getOrNull(position)?.let { status -> - if (status.isDetailed) { - // the main status we are viewing is being removed, finish the activity - activity?.finish() - return - } - viewModel.removeStatus(status) + public override fun removeItem(viewData: StatusViewData) { + if (viewData.isDetailed) { + // the main status we are viewing is being removed, finish the activity + activity?.finish() + return } + viewModel.removeStatus(viewData) } - override fun onVoteInPoll(position: Int, choices: List) { - val status = adapter.currentList[position] - viewModel.voteInPoll(choices, status) + override fun onVoteInPoll(viewData: StatusViewData, poll: Poll, choices: List) { + viewModel.voteInPoll(poll, choices, viewData) } - override fun onShowEdits(position: Int) { - val status = adapter.currentList[position] - val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId) + override fun onShowEdits(statusId: String) { + val viewEditsFragment = ViewEditsFragment.newInstance(statusId) parentFragmentManager.commit { setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) @@ -414,8 +405,8 @@ class ViewThreadFragment : } } - override fun clearWarningAction(position: Int) { - viewModel.clearWarning(adapter.currentList[position]) + override fun clearWarningAction(viewData: StatusViewData) { + viewModel.clearWarning(viewData) } companion object { diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index 15e668911..761ddd715 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -38,6 +38,7 @@ import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.database.model.TranslationState import app.pachli.core.network.model.Filter +import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi import app.pachli.network.FilterModel @@ -289,12 +290,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun voteInPoll(choices: List, status: StatusViewData): Job = viewModelScope.launch { - val poll = status.status.actionableStatus.poll ?: run { - Timber.w("No poll on status ${status.id}") - return@launch - } - + fun voteInPoll(poll: Poll, choices: List, status: StatusViewData): Job = viewModelScope.launch { val votedPoll = poll.votedCopy(choices) updateStatus(status.id) { status -> status.copy(poll = votedPoll) diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index 214891543..4a6a3e572 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -57,11 +57,12 @@ import app.pachli.core.network.model.Status import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.retrofit.MastodonApi import app.pachli.interfaces.AccountSelectionListener +import app.pachli.interfaces.StatusActionListener import app.pachli.network.ServerRepository import app.pachli.usecase.TimelineCases import app.pachli.util.openLink import app.pachli.view.showMuteAccountDialog -import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.IStatusViewData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import com.github.michaelbull.result.onFailure @@ -72,15 +73,9 @@ import javax.inject.Inject import kotlinx.coroutines.launch import timber.log.Timber -/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an - * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature - * of that is complicated by how they're coupled with Status and Notification and the corresponding - * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also - * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear - * up what needs to be where. */ -abstract class SFragment : Fragment() { - protected abstract fun removeItem(position: Int) - protected abstract fun onReblog(reblog: Boolean, position: Int) +abstract class SFragment : Fragment(), StatusActionListener { + protected abstract fun removeItem(viewData: T) + private lateinit var bottomSheetActivity: BottomSheetActivity @Inject @@ -154,7 +149,7 @@ abstract class SFragment : Fragment() { bottomSheetActivity.viewAccount(accountId!!) } - open fun onViewUrl(url: String) { + override fun onViewUrl(url: String) { bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } @@ -189,12 +184,12 @@ abstract class SFragment : Fragment() { * Handles the user clicking the "..." (more) button typically at the bottom-right of * the status. */ - protected fun more(statusViewData: StatusViewData, view: View, position: Int) { - val status = statusViewData.status - val actionableId = status.actionableId - val accountId = status.actionableStatus.account.id - val accountUsername = status.actionableStatus.account.username - val statusUrl = status.actionableStatus.url + protected fun more(view: View, viewData: T) { + val status = viewData.status + val actionableId = viewData.actionableId + val accountId = viewData.actionable.account.id + val accountUsername = viewData.actionable.account.username + val statusUrl = viewData.actionable.url var loggedInAccountId: String? = null val activeAccount = accountManager.activeAccount if (activeAccount != null) { @@ -221,8 +216,8 @@ abstract class SFragment : Fragment() { popup.inflate(R.menu.status_more) popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() if (serverCanTranslate && canTranslate() && status.visibility != Status.Visibility.PRIVATE && status.visibility != Status.Visibility.DIRECT) { - popup.menu.findItem(R.id.status_translate).isVisible = statusViewData.translationState == TranslationState.SHOW_ORIGINAL - popup.menu.findItem(R.id.status_translate_undo).isVisible = statusViewData.translationState == TranslationState.SHOW_TRANSLATION + popup.menu.findItem(R.id.status_translate).isVisible = viewData.translationState == TranslationState.SHOW_ORIGINAL + popup.menu.findItem(R.id.status_translate_undo).isVisible = viewData.translationState == TranslationState.SHOW_TRANSLATION } else { popup.menu.findItem(R.id.status_translate).isVisible = false popup.menu.findItem(R.id.status_translate_undo).isVisible = false @@ -310,19 +305,19 @@ abstract class SFragment : Fragment() { return@setOnMenuItemClickListener true } R.id.status_unreblog_private -> { - onReblog(false, position) + onReblog(viewData, false) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { - onReblog(true, position) + onReblog(viewData, true) return@setOnMenuItemClickListener true } R.id.status_delete -> { - showConfirmDeleteDialog(actionableId, position) + showConfirmDeleteDialog(viewData) return@setOnMenuItemClickListener true } R.id.status_delete_and_redraft -> { - showConfirmEditDialog(actionableId, position, status) + showConfirmEditDialog(viewData) return@setOnMenuItemClickListener true } R.id.status_edit -> { @@ -346,11 +341,11 @@ abstract class SFragment : Fragment() { return@setOnMenuItemClickListener true } R.id.status_translate -> { - onTranslate(statusViewData) + onTranslate(viewData) return@setOnMenuItemClickListener true } R.id.status_translate_undo -> { - onTranslateUndo(statusViewData) + onTranslateUndo(viewData) return@setOnMenuItemClickListener true } } @@ -366,9 +361,9 @@ abstract class SFragment : Fragment() { */ open fun canTranslate() = false - open fun onTranslate(statusViewData: StatusViewData) {} + open fun onTranslate(statusViewData: T) {} - open fun onTranslateUndo(statusViewData: StatusViewData) {} + open fun onTranslateUndo(statusViewData: T) {} private fun onMute(accountId: String, accountUsername: String) { showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? -> @@ -422,12 +417,12 @@ abstract class SFragment : Fragment() { startActivity(ReportActivityIntent(requireContext(), accountId, accountUsername, statusId)) } - private fun showConfirmDeleteDialog(id: String, position: Int) { + private fun showConfirmDeleteDialog(viewData: T) { AlertDialog.Builder(requireActivity()) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> lifecycleScope.launch { - val result = timelineCases.delete(id).exceptionOrNull() + val result = timelineCases.delete(viewData.status.id).exceptionOrNull() if (result != null) { Timber.w("error deleting status", result) Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() @@ -437,14 +432,14 @@ abstract class SFragment : Fragment() { // removes the item if the timelineCases.delete() call succeeded. // // Either way, this logic should be in the view model. - removeItem(position) + removeItem(viewData) } } .setNegativeButton(android.R.string.cancel, null) .show() } - private fun showConfirmEditDialog(id: String, position: Int, status: Status) { + private fun showConfirmEditDialog(statusViewData: T) { if (activity == null) { return } @@ -452,11 +447,11 @@ abstract class SFragment : Fragment() { .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> lifecycleScope.launch { - timelineCases.delete(id).fold( + timelineCases.delete(statusViewData.status.id).fold( { deletedStatus -> - removeItem(position) + removeItem(statusViewData) val sourceStatus = if (deletedStatus.isEmpty()) { - status.toDeletedStatus() + statusViewData.status.toDeletedStatus() } else { deletedStatus } diff --git a/app/src/main/java/app/pachli/interfaces/AccountActionListener.kt b/app/src/main/java/app/pachli/interfaces/AccountActionListener.kt index 4d79b7f0b..9284d8737 100644 --- a/app/src/main/java/app/pachli/interfaces/AccountActionListener.kt +++ b/app/src/main/java/app/pachli/interfaces/AccountActionListener.kt @@ -21,5 +21,5 @@ interface AccountActionListener { fun onViewAccount(id: String) fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) fun onBlock(block: Boolean, id: String, position: Int) - fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) + fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) } diff --git a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt index 900764133..d230e2eaf 100644 --- a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt +++ b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt @@ -18,45 +18,44 @@ package app.pachli.interfaces import android.view.View +import app.pachli.core.network.model.Poll +import app.pachli.core.network.model.Status +import app.pachli.viewdata.IStatusViewData -interface StatusActionListener : LinkListener { - fun onReply(position: Int) - fun onReblog(reblog: Boolean, position: Int) - fun onFavourite(favourite: Boolean, position: Int) - fun onBookmark(bookmark: Boolean, position: Int) - fun onMore(view: View, position: Int) - fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) - fun onViewThread(position: Int) +interface StatusActionListener : LinkListener { + fun onReply(viewData: T) + fun onReblog(viewData: T, reblog: Boolean) + fun onFavourite(viewData: T, favourite: Boolean) + fun onBookmark(viewData: T, bookmark: Boolean) + fun onMore(view: View, viewData: T) + fun onViewMedia(viewData: T, attachmentIndex: Int, view: View?) + fun onViewThread(status: Status) /** * Open reblog author for the status. - * @param position At which position in the list status is located */ - fun onOpenReblog(position: Int) - fun onExpandedChange(expanded: Boolean, position: Int) - fun onContentHiddenChange(isShowing: Boolean, position: Int) + fun onOpenReblog(status: Status) + fun onExpandedChange(viewData: T, expanded: Boolean) + fun onContentHiddenChange(viewData: T, isShowing: Boolean) /** * Called when the status [android.widget.ToggleButton] responsible for collapsing long * status content is interacted with. * * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. */ - fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) + fun onContentCollapsedChange(viewData: T, isCollapsed: Boolean) /** * called when the reblog count has been clicked - * @param position The position of the status in the list. */ - fun onShowReblogs(position: Int) {} + fun onShowReblogs(statusId: String) {} /** * called when the favourite count has been clicked - * @param position The position of the status in the list. */ - fun onShowFavs(position: Int) {} - fun onVoteInPoll(position: Int, choices: List) - fun onShowEdits(position: Int) {} - fun clearWarningAction(position: Int) + fun onShowFavs(statusId: String) {} + fun onVoteInPoll(viewData: T, poll: Poll, choices: List) + fun onShowEdits(statusId: String) {} + fun clearWarningAction(viewData: T) } diff --git a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt index e89a8c099..9c7b7e5aa 100644 --- a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt @@ -18,18 +18,19 @@ import app.pachli.R import app.pachli.adapter.StatusBaseViewHolder import app.pachli.core.network.model.Status.Companion.MAX_MEDIA_ATTACHMENTS import app.pachli.interfaces.StatusActionListener +import app.pachli.viewdata.IStatusViewData import app.pachli.viewdata.StatusViewData import kotlin.math.min // Not using lambdas because there's boxing of int then -fun interface StatusProvider { - fun getStatus(pos: Int): StatusViewData? +fun interface StatusProvider { + fun getStatus(pos: Int): T? } -class ListStatusAccessibilityDelegate( +class ListStatusAccessibilityDelegate( private val recyclerView: RecyclerView, - private val statusActionListener: StatusActionListener, - private val statusProvider: StatusProvider, + private val statusActionListener: StatusActionListener, + private val statusProvider: StatusProvider, ) : RecyclerViewAccessibilityDelegate(recyclerView) { private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager @@ -100,43 +101,42 @@ class ListStatusAccessibilityDelegate( args: Bundle?, ): Boolean { val pos = recyclerView.getChildAdapterPosition(host) + val status = statusProvider.getStatus(pos) ?: return false when (action) { R.id.action_reply -> { interrupt() - statusActionListener.onReply(pos) + statusActionListener.onReply(status) } - R.id.action_favourite -> statusActionListener.onFavourite(true, pos) - R.id.action_unfavourite -> statusActionListener.onFavourite(false, pos) - R.id.action_bookmark -> statusActionListener.onBookmark(true, pos) - R.id.action_unbookmark -> statusActionListener.onBookmark(false, pos) - R.id.action_reblog -> statusActionListener.onReblog(true, pos) - R.id.action_unreblog -> statusActionListener.onReblog(false, pos) + R.id.action_favourite -> statusActionListener.onFavourite(status, true) + R.id.action_unfavourite -> statusActionListener.onFavourite(status, false) + R.id.action_bookmark -> statusActionListener.onBookmark(status, true) + R.id.action_unbookmark -> statusActionListener.onBookmark(status, false) + R.id.action_reblog -> statusActionListener.onReblog(status, true) + R.id.action_unreblog -> statusActionListener.onReblog(status, false) R.id.action_open_profile -> { interrupt() - statusActionListener.onViewAccount( - (statusProvider.getStatus(pos) as StatusViewData).actionable.account.id, - ) + statusActionListener.onViewAccount(status.actionable.account.id) } R.id.action_open_media_1 -> { interrupt() - statusActionListener.onViewMedia(pos, 0, null) + statusActionListener.onViewMedia(status, 0, null) } R.id.action_open_media_2 -> { interrupt() - statusActionListener.onViewMedia(pos, 1, null) + statusActionListener.onViewMedia(status, 1, null) } R.id.action_open_media_3 -> { interrupt() - statusActionListener.onViewMedia(pos, 2, null) + statusActionListener.onViewMedia(status, 2, null) } R.id.action_open_media_4 -> { interrupt() - statusActionListener.onViewMedia(pos, 3, null) + statusActionListener.onViewMedia(status, 3, null) } R.id.action_expand_cw -> { // Toggling it directly to avoid animations // which cannot be disabled for detailed status for some reason - val holder = recyclerView.getChildViewHolder(host) as StatusBaseViewHolder + val holder = recyclerView.getChildViewHolder(host) as StatusBaseViewHolder holder.toggleContentWarning() // Stop and restart narrator before it reads old description. // Would be nice if we could *just* read the content here but doesn't seem @@ -144,7 +144,7 @@ class ListStatusAccessibilityDelegate( forceFocus(host) } R.id.action_collapse_cw -> { - statusActionListener.onExpandedChange(false, pos) + statusActionListener.onExpandedChange(status, false) interrupt() } R.id.action_links -> showLinksDialog(host) @@ -152,18 +152,18 @@ class ListStatusAccessibilityDelegate( R.id.action_hashtags -> showHashtagsDialog(host) R.id.action_open_reblogger -> { interrupt() - statusActionListener.onOpenReblog(pos) + statusActionListener.onOpenReblog(status.actionable) } R.id.action_open_reblogged_by -> { interrupt() - statusActionListener.onShowReblogs(pos) + statusActionListener.onShowReblogs(status.actionableId) } R.id.action_open_faved_by -> { interrupt() - statusActionListener.onShowFavs(pos) + statusActionListener.onShowFavs(status.actionableId) } R.id.action_more -> { - statusActionListener.onMore(host, pos) + statusActionListener.onMore(host, status) } else -> return super.performAccessibilityAction(host, action, args) } @@ -224,7 +224,7 @@ class ListStatusAccessibilityDelegate( .let { forceFocus(it.listView) } } - private fun getStatus(childView: View): StatusViewData { + private fun getStatus(childView: View): T { return statusProvider.getStatus(recyclerView.getChildAdapterPosition(childView))!! } } diff --git a/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt b/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt index a58e51bfa..bd56e6254 100644 --- a/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt @@ -17,18 +17,30 @@ package app.pachli.viewdata +import android.text.Spanned +import app.pachli.core.database.model.TranslatedStatusEntity +import app.pachli.core.database.model.TranslationState import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Report +import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineAccount +/** + * Data necessary to show a single notification. + * + * A notification may also need to display a status (e.g., if it is a notification + * about boosting a status, the boosted status is also shown). However, not all + * notifications are related to statuses (e.g., a "Someone has followed you" + * notification) so `statusViewData` is nullable. + */ data class NotificationViewData( val type: Notification.Type, val id: String, val account: TimelineAccount, var statusViewData: StatusViewData?, val report: Report?, -) { +) : IStatusViewData { companion object { fun from( notification: Notification, @@ -52,4 +64,45 @@ data class NotificationViewData( notification.report, ) } + + // Implement properties for IStatusViewData. These can't be delegated to `statusViewData` + // as that might be null. It's up to the calling code to only check these properties if + // `statusViewData` is not null; not doing that is an illegal state, hence the exception. + + override val username: String + get() = statusViewData?.username ?: throw IllegalStateException() + override val rebloggedAvatar: String? + get() = statusViewData?.rebloggedAvatar + override var translation: TranslatedStatusEntity? + get() = statusViewData?.translation + set(value) { + statusViewData?.translation = value + } + override val isExpanded: Boolean + get() = statusViewData?.isExpanded ?: throw IllegalStateException() + override val isShowingContent: Boolean + get() = statusViewData?.isShowingContent ?: throw IllegalStateException() + override val isCollapsible: Boolean + get() = statusViewData?.isCollapsible ?: throw IllegalStateException() + override val isCollapsed: Boolean + get() = statusViewData?.isCollapsed ?: throw IllegalStateException() + override val spoilerText: String + get() = statusViewData?.spoilerText ?: throw IllegalStateException() + override val content: Spanned + get() = statusViewData?.content ?: throw IllegalStateException() + override val status: Status + get() = statusViewData?.status ?: throw IllegalStateException() + override val actionable: Status + get() = statusViewData?.actionable ?: throw IllegalStateException() + override val actionableId: String + get() = statusViewData?.actionableId ?: throw IllegalStateException() + override val rebloggingStatus: Status? + get() = statusViewData?.rebloggingStatus + override var filterAction: Filter.Action + get() = statusViewData?.filterAction ?: throw IllegalStateException() + set(value) { + statusViewData?.filterAction = value + } + override val translationState: TranslationState + get() = statusViewData?.translationState ?: throw IllegalStateException() } diff --git a/app/src/main/java/app/pachli/viewdata/StatusViewData.kt b/app/src/main/java/app/pachli/viewdata/StatusViewData.kt index 6970b8252..e2d974c35 100644 --- a/app/src/main/java/app/pachli/viewdata/StatusViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/StatusViewData.kt @@ -19,13 +19,11 @@ import android.os.Build import android.text.Spanned import android.text.SpannedString import app.pachli.BuildConfig -import app.pachli.core.database.model.ConversationAccountEntity import app.pachli.core.database.model.ConversationStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.database.model.TranslationState import app.pachli.core.network.model.Filter -import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.replaceCrashingCharacters @@ -33,11 +31,15 @@ import app.pachli.util.shouldTrimStatus import com.google.gson.Gson /** - * Data required to display a status. + * Interface for the data shown when viewing a status, or something that wraps + * a status, like [NotificationViewData] or + * [app.pachli.components.conversation.ConversationViewData]. */ -data class StatusViewData( - var status: Status, - var translation: TranslatedStatusEntity? = null, +interface IStatusViewData { + val username: String + val rebloggedAvatar: String? + + var translation: TranslatedStatusEntity? /** * If the status includes a non-empty content warning ([spoilerText]), specifies whether @@ -45,12 +47,21 @@ data class StatusViewData( * * Ignored if there is no content warning. */ - val isExpanded: Boolean, + val isExpanded: Boolean + /** * If the status contains attached media, specifies whether whether the media is shown * (true), or not (false). */ - val isShowingContent: Boolean, + val isShowingContent: Boolean + + /** + * Specifies whether the content of this status is long enough to be automatically + * collapsed or if it should show all content regardless. + * + * @return Whether the status is collapsible or never collapsed. + */ + val isCollapsible: Boolean /** * Specifies whether the content of this status is currently limited in visibility to the first @@ -58,13 +69,38 @@ data class StatusViewData( * * @return Whether the status is collapsed or fully expanded. */ - val isCollapsed: Boolean, + val isCollapsed: Boolean + + /** The content warning, may be the empty string */ + val spoilerText: String /** - * Specifies whether this status should be shown with the "detailed" layout, meaning it is - * the status that has a focus when viewing a thread. + * The content to show for this status. May be the original content, or + * translated, depending on `translationState`. */ - val isDetailed: Boolean = false, + val content: Spanned + + /** The underlying network status */ + val status: Status + + /** + * The "actionable" status; the one on which the user can perform actions + * (reblog, favourite, reply, etc). + * + * A status may refer to another status. For example, if this is status `B`, + * and `B` is a reblog of status `A`, then `A` is the "actionable" status. + * + * If this is a top-level status (e.g., it's not a reblog, etc) then `status` + * and `actionable` are the same. + */ + val actionable: Status + + /** + * The ID of the [actionable] status. + */ + val actionableId: String + + val rebloggingStatus: Status? // TODO: This means that null checks are required elsewhere in the code to deal with // the possibility that this might not be NONE, but that status.filtered is null or @@ -72,28 +108,41 @@ data class StatusViewData( // if the Filter.Action class subtypes carried the FilterResult information with them, // and it's impossible to construct them with an empty list. /** Whether this status should be filtered, and if so, how */ - var filterAction: Filter.Action = Filter.Action.NONE, + var filterAction: Filter.Action - /** True if the translated content should be shown (if it exists) */ - val translationState: TranslationState, -) { - val id: String - get() = status.id + /** The current translation state */ + val translationState: TranslationState +} + +/** + * Data required to display a status. + */ +data class StatusViewData( + override var status: Status, + override var translation: TranslatedStatusEntity? = null, + override val isExpanded: Boolean, + override val isShowingContent: Boolean, + override val isCollapsed: Boolean, + override var filterAction: Filter.Action = Filter.Action.NONE, + override val translationState: TranslationState, /** - * Specifies whether the content of this status is long enough to be automatically - * collapsed or if it should show all content regardless. - * - * @return Whether the status is collapsible or never collapsed. + * Specifies whether this status should be shown with the "detailed" layout, meaning it is + * the status that has a focus when viewing a thread. */ - val isCollapsible: Boolean + val isDetailed: Boolean = false, +) : IStatusViewData { + val id: String + get() = status.id + + override val isCollapsible: Boolean private val _content: Spanned @Suppress("ktlint:standard:property-naming") private val _translatedContent: Spanned - val content: Spanned + override val content: Spanned get() = if (translationState == TranslationState.SHOW_TRANSLATION) _translatedContent else _content private val _spoilerText: String @@ -101,26 +150,25 @@ data class StatusViewData( @Suppress("ktlint:standard:property-naming") private val _translatedSpoilerText: String - /** The content warning, may be the empty string */ - val spoilerText: String + override val spoilerText: String get() = if (translationState == TranslationState.SHOW_TRANSLATION) _translatedSpoilerText else _spoilerText - val username: String + override val username: String - val actionable: Status + override val actionable: Status get() = status.actionableStatus - val actionableId: String + override val actionableId: String get() = status.actionableStatus.id - val rebloggedAvatar: String? + override val rebloggedAvatar: String? get() = if (status.reblog != null) { status.account.avatar } else { null } - val rebloggingStatus: Status? + override val rebloggingStatus: Status? get() = if (status.reblog != null) status else null init { @@ -150,41 +198,6 @@ data class StatusViewData( /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean) = copy(isCollapsed = isCollapsed) - fun toConversationStatusEntity( - favourited: Boolean = status.favourited, - bookmarked: Boolean = status.bookmarked, - muted: Boolean = status.muted ?: false, - poll: Poll? = status.poll, - expanded: Boolean = isExpanded, - collapsed: Boolean = isCollapsed, - showingHiddenContent: Boolean = isShowingContent, - ) = ConversationStatusEntity( - id = id, - url = status.url, - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - account = ConversationAccountEntity.from(status.account), - content = status.content, - createdAt = status.createdAt, - editedAt = status.editedAt, - emojis = status.emojis, - favouritesCount = status.favouritesCount, - repliesCount = status.repliesCount, - favourited = favourited, - bookmarked = bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText, - attachments = status.attachments, - mentions = status.mentions, - tags = status.tags, - showingHiddenContent = showingHiddenContent, - expanded = expanded, - collapsed = collapsed, - muted = muted, - poll = poll, - language = status.language, - ) - companion object { fun from( status: Status, diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/ConversationsDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/ConversationsDao.kt index df6fa81c1..f74972d3f 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/ConversationsDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/ConversationsDao.kt @@ -40,4 +40,67 @@ interface ConversationsDao { @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") suspend fun deleteForAccount(accountId: Long) + + @Query( + """ +UPDATE ConversationEntity +SET s_bookmarked = :bookmarked +WHERE accountId = :accountId AND s_id = :lastStatusId +""", + ) + suspend fun setBookmarked(accountId: Long, lastStatusId: String, bookmarked: Boolean) + + @Query( + """ +UPDATE ConversationEntity +SET s_collapsed = :collapsed +WHERE accountId = :accountId AND s_id = :lastStatusId +""", + ) + suspend fun setCollapsed(accountId: Long, lastStatusId: String, collapsed: Boolean) + + @Query( + """ +UPDATE ConversationEntity +SET s_expanded = :expanded +WHERE accountId = :accountId AND s_id = :lastStatusId +""", + ) + suspend fun setExpanded(accountId: Long, lastStatusId: String, expanded: Boolean) + + @Query( + """ +UPDATE ConversationEntity +SET s_favourited = :favourited +WHERE accountId = :accountId AND s_id = :lastStatusId +""", + ) + suspend fun setFavourited(accountId: Long, lastStatusId: String, favourited: Boolean) + + @Query( + """ +UPDATE ConversationEntity +SET s_muted = :muted +WHERE accountId = :accountId AND s_id = :lastStatusId +""", + ) + suspend fun setMuted(accountId: Long, lastStatusId: String, muted: Boolean) + + @Query( + """ +UPDATE ConversationEntity +SET s_showingHiddenContent = :showingHiddenContent +WHERE accountId = :accountId AND s_id = :lastStatusId +""", + ) + suspend fun setShowingHiddenContent(accountId: Long, lastStatusId: String, showingHiddenContent: Boolean) + + @Query( + """ + UPDATE ConversationEntity + SET s_poll = :poll + WHERE accountId = :accountId AND s_id = :lastStatusId + """, + ) + suspend fun setVoted(accountId: Long, lastStatusId: String, poll: String) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt index 5f4e5285e..3251eff5d 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt @@ -43,6 +43,7 @@ data class ConversationEntity( companion object { fun from( conversation: Conversation, + /** Pachli account ID (timelineUserId in other entities) */ accountId: Long, order: Int, expanded: Boolean, diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt index 5b51b53d3..a33087ef4 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -203,11 +203,11 @@ enum class TranslationState { data class StatusViewDataEntity( val serverId: String, val timelineUserId: Long, - /** Corresponds to [app.pachli.viewdata.StatusViewData.isExpanded] */ + /** Corresponds to [app.pachli.viewdata.IStatusViewData.isExpanded] */ val expanded: Boolean, - /** Corresponds to [app.pachli.viewdata.StatusViewData.isShowingContent] */ + /** Corresponds to [app.pachli.viewdata.IStatusViewData.isShowingContent] */ val contentShowing: Boolean, - /** Corresponds to [app.pachli.viewdata.StatusViewData.isCollapsed] */ + /** Corresponds to [app.pachli.viewdata.IStatusViewData.isCollapsed] */ val contentCollapsed: Boolean, /** Show the translated version of the status (if it exists) */ @ColumnInfo(defaultValue = "SHOW_ORIGINAL")