From 6b55d107c1734539bc2874fcf88924b54c9cc212 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 19 Jul 2024 13:45:24 +0200 Subject: [PATCH] feat: Edit a matching filter directly from the timeline (#819) Previously, if a status was filtered with "WARN" and was shown in the timeline with the name of the filter, and the user then decided to change that filter, they had to: 1. Open the left navigation menu 2. Navigate to "Account preferences" 3. Open "Filters" 4. Find the filter they want to edit, tap it 5. Make the change, and save 6. "Back" to the list of filters 7. "Back" to "Account preferences" 8. "Back" to the timeline That's a lot of clicks for a simple action. Change this. Now the filtered status includes an "Edit filter" button that takes the user directly to step 5, and when they press "Back" they return directly to the timeline. To do this create a new filter action, `onEditFilterById`. Update the listeners to launch `EditFilterActivity` if appropriate. Modify `item_status_filtered.xml` to show the new button. Update the accessibility delegate to show just the "Show anyway" and "Edit filter" actions. Modify `FilterableStatusViewHolder` to expose the information it needs to do this. --- .../adapter/FilterableStatusViewHolder.kt | 57 +++++++++---------- .../pachli/adapter/StatusBaseViewHolder.kt | 3 +- .../conversation/ConversationsFragment.kt | 3 + .../notifications/NotificationsFragment.kt | 10 ++++ .../fragments/SearchStatusesFragment.kt | 10 ++++ .../components/timeline/TimelineFragment.kt | 10 ++++ .../viewthread/ViewThreadFragment.kt | 10 ++++ .../pachli/interfaces/StatusActionListener.kt | 3 + .../util/ListStatusAccessibilityDelegate.kt | 26 +++++++++ .../main/res/layout/item_status_filtered.xml | 45 ++++++++++----- app/src/main/res/values-ar/strings.xml | 4 +- app/src/main/res/values-be/strings.xml | 4 +- app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-cy/strings.xml | 4 +- app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-es/strings.xml | 4 +- app/src/main/res/values-fa/strings.xml | 4 +- app/src/main/res/values-fi/strings.xml | 4 +- app/src/main/res/values-fr/strings.xml | 4 +- app/src/main/res/values-gd/strings.xml | 4 +- app/src/main/res/values-gl/strings.xml | 4 +- app/src/main/res/values-hu/strings.xml | 4 +- app/src/main/res/values-is/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-ja/strings.xml | 4 +- app/src/main/res/values-nb-rNO/strings.xml | 4 +- app/src/main/res/values-nl/strings.xml | 4 +- app/src/main/res/values-oc/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 4 +- app/src/main/res/values-sv/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values-vi/strings.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values/strings.xml | 2 +- core/ui/src/main/res/values/actions.xml | 3 + 36 files changed, 185 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt index cf2e61cdd..59a1d67ae 100644 --- a/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/FilterableStatusViewHolder.kt @@ -18,6 +18,7 @@ package app.pachli.adapter import android.view.View +import androidx.core.text.HtmlCompat import app.pachli.R import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.core.network.model.Filter @@ -28,6 +29,8 @@ import app.pachli.viewdata.IStatusViewData open class FilterableStatusViewHolder( private val binding: ItemStatusWrapperBinding, ) : StatusViewHolder(binding.statusContainer, binding.root) { + /** The filter that matched the status, null if the status is not being filtered. */ + var matchedFilter: Filter? = null override fun setupWithStatus( viewData: T, @@ -44,42 +47,38 @@ open class FilterableStatusViewHolder( listener: StatusActionListener, ) { if (status.filterAction !== Filter.Action.WARN) { - showFilteredPlaceholder(false) + matchedFilter = null + setPlaceholderVisibility(false) return } - // Shouldn't be necessary given the previous test against getFilterAction(), - // but guards against a possible NPE. See the TODO in StatusViewData.filterAction - // for more details. - val filterResults = status.actionable.filtered - if (filterResults.isNullOrEmpty()) { - showFilteredPlaceholder(false) - return - } - var matchedFilter: Filter? = null - for ((filter) in filterResults) { - if (filter.action === Filter.Action.WARN) { - matchedFilter = filter - break + status.actionable.filtered?.find { it.filter.action === Filter.Action.WARN }?.let { result -> + this.matchedFilter = result.filter + setPlaceholderVisibility(true) + + val label = HtmlCompat.fromHtml( + context.getString( + R.string.status_filter_placeholder_label_format, + result.filter.title, + ), + HtmlCompat.FROM_HTML_MODE_LEGACY, + ) + binding.root.contentDescription = label + binding.statusFilteredPlaceholder.statusFilterLabel.text = label + + binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener { + listener.clearWarningAction(status) } - } - - // Guard against a possible NPE - if (matchedFilter == null) { - showFilteredPlaceholder(false) - return - } - showFilteredPlaceholder(true) - binding.statusFilteredPlaceholder.statusFilterLabel.text = context.getString( - R.string.status_filter_placeholder_label_format, - matchedFilter.title, - ) - binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener { - listener.clearWarningAction(status) + binding.statusFilteredPlaceholder.statusFilterEditFilter.setOnClickListener { + listener.onEditFilterById(result.filter.id) + } + } ?: { + matchedFilter = null + setPlaceholderVisibility(false) } } - private fun showFilteredPlaceholder(show: Boolean) { + private fun setPlaceholderVisibility(show: Boolean) { binding.statusContainer.root.visibility = if (show) View.GONE else View.VISIBLE binding.statusFilteredPlaceholder.root.visibility = if (show) View.VISIBLE else View.GONE } diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index bcced8ac1..f0f8d2785 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -11,7 +11,6 @@ import android.view.ViewGroup import android.widget.Button import android.widget.ImageButton import android.widget.ImageView -import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources @@ -100,7 +99,7 @@ abstract class StatusBaseViewHolder protected constructor(i private val contentWarningDescription: TextView = itemView.findViewById(R.id.status_content_warning_description) private val pollView: PollView = itemView.findViewById(R.id.status_poll) private val cardView: PreviewCardView? = itemView.findViewById(R.id.status_card_view) - private val filteredPlaceholder: LinearLayout? = itemView.findViewById(R.id.status_filtered_placeholder) + private val filteredPlaceholder: ConstraintLayout? = itemView.findViewById(R.id.status_filtered_placeholder) private val filteredPlaceholderLabel: TextView? = itemView.findViewById(R.id.status_filter_label) private val filteredPlaceholderShowButton: Button? = itemView.findViewById(R.id.status_filter_show_anyway) private val statusContainer: ConstraintLayout? = itemView.findViewById(R.id.status_container) 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 8cf8e8066..be7df9e00 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt @@ -346,6 +346,9 @@ class ConversationsFragment : override fun clearWarningAction(viewData: ConversationViewData) { } + // Filters don't apply in conversations + override fun onEditFilterById(filterId: String) {} + override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) 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 7c4f2cf3e..3194bd758 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -49,11 +49,14 @@ import app.pachli.R import app.pachli.adapter.StatusBaseViewHolder import app.pachli.components.timeline.TimelineLoadStateAdapter import app.pachli.core.activity.ReselectableFragment +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.activity.openLink import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding import app.pachli.core.navigation.AttachmentViewData.Companion.list +import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Poll @@ -589,6 +592,13 @@ class NotificationsFragment : } } + override fun onEditFilterById(filterId: String) { + requireActivity().startActivityWithTransition( + EditFilterActivityIntent.edit(requireContext(), filterId), + TransitionKind.SLIDE_FROM_END, + ) + } + override fun onNotificationContentCollapsedChange( isCollapsed: Boolean, viewData: NotificationViewData, 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 fb3e37866..72204a264 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 @@ -40,13 +40,16 @@ import app.pachli.R import app.pachli.components.search.adapter.SearchStatusesAdapter import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.TransitionKind import app.pachli.core.activity.extensions.startActivityWithDefaultTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.activity.openLink import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.model.AccountEntity import app.pachli.core.navigation.AttachmentViewData import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions +import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.navigation.ReportActivityIntent import app.pachli.core.navigation.ViewMediaActivityIntent import app.pachli.core.network.model.Attachment @@ -162,6 +165,13 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis viewModel.reblog(viewData, reblog) } + override fun onEditFilterById(filterId: String) { + requireActivity().startActivityWithTransition( + EditFilterActivityIntent.edit(requireContext(), filterId), + TransitionKind.SLIDE_FROM_END, + ) + } + companion object { fun newInstance() = SearchStatusesFragment() } 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 6b5737c6b..1f10aeaa1 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -51,7 +51,9 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.components.timeline.viewmodel.UiSuccess import app.pachli.core.activity.RefreshableFragment import app.pachli.core.activity.ReselectableFragment +import app.pachli.core.activity.extensions.TransitionKind import app.pachli.core.activity.extensions.startActivityWithDefaultTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -59,6 +61,7 @@ import app.pachli.core.database.model.TranslationState import app.pachli.core.model.Timeline import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AttachmentViewData +import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.ui.ActionButtonScrollListener @@ -615,6 +618,13 @@ class TimelineFragment : viewModel.clearWarning(viewData) } + override fun onEditFilterById(filterId: String) { + requireActivity().startActivityWithTransition( + EditFilterActivityIntent.edit(requireContext(), filterId), + TransitionKind.SLIDE_FROM_END, + ) + } + override fun onMore(view: View, viewData: StatusViewData) { super.more(view, viewData) } 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 be9b09653..8ea4f3406 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt @@ -33,7 +33,9 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import app.pachli.R import app.pachli.components.viewthread.edits.ViewEditsFragment +import app.pachli.core.activity.extensions.TransitionKind import app.pachli.core.activity.extensions.startActivityWithDefaultTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.activity.openLink import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show @@ -41,6 +43,7 @@ import app.pachli.core.common.extensions.viewBinding import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AttachmentViewData.Companion.list +import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.ui.extensions.getErrorString @@ -323,6 +326,13 @@ class ViewThreadFragment : // there are no reblogs in threads } + override fun onEditFilterById(filterId: String) { + requireActivity().startActivityWithTransition( + EditFilterActivityIntent.edit(requireContext(), filterId), + TransitionKind.SLIDE_FROM_END, + ) + } + override fun onExpandedChange(viewData: StatusViewData, expanded: Boolean) { viewModel.changeExpanded(expanded, viewData) } diff --git a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt index 7b644e740..3dc188dcd 100644 --- a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt +++ b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt @@ -59,4 +59,7 @@ interface StatusActionListener : LinkListener { fun onVoteInPoll(viewData: T, poll: Poll, choices: List) fun onShowEdits(statusId: String) {} fun clearWarningAction(viewData: T) + + /** Edit the filter that matched this status. */ + fun onEditFilterById(filterId: String) } diff --git a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt index 2ff4bd72e..36d13ce93 100644 --- a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt @@ -15,6 +15,7 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.Accessibilit import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate import app.pachli.R +import app.pachli.adapter.FilterableStatusViewHolder import app.pachli.adapter.StatusBaseViewHolder import app.pachli.core.activity.openLink import app.pachli.core.network.model.Status.Companion.MAX_MEDIA_ATTACHMENTS @@ -48,6 +49,13 @@ class ListStatusAccessibilityDelegate( ) { super.onInitializeAccessibilityNodeInfo(host, info) + val viewHolder = recyclerView.findContainingViewHolder(host) + if (viewHolder is FilterableStatusViewHolder<*> && viewHolder.matchedFilter != null) { + info.addAction(showAnywayAction) + info.addAction(editFilterAction) + return + } + val pos = recyclerView.getChildAdapterPosition(host) val status = statusProvider.getStatus(pos) ?: return @@ -183,6 +191,14 @@ class ListStatusAccessibilityDelegate( app.pachli.core.ui.R.id.action_more -> { statusActionListener.onMore(host, status) } + app.pachli.core.ui.R.id.action_show_anyway -> statusActionListener.clearWarningAction(status) + app.pachli.core.ui.R.id.action_edit_filter -> { + (recyclerView.findContainingViewHolder(host) as? FilterableStatusViewHolder<*>)?.matchedFilter?.let { + statusActionListener.onEditFilterById(it.id) + return@let true + } ?: false + } + else -> return super.performAccessibilityAction(host, action, args) } return true @@ -378,5 +394,15 @@ class ListStatusAccessibilityDelegate( context.getString(app.pachli.core.ui.R.string.action_more), ) + private val showAnywayAction = AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_show_anyway, + context.getString(R.string.status_filtered_show_anyway), + ) + + private val editFilterAction = AccessibilityActionCompat( + app.pachli.core.ui.R.id.action_edit_filter, + context.getString(R.string.filter_edit_title), + ) + private data class LinkSpanInfo(val text: String, val link: String) } diff --git a/app/src/main/res/layout/item_status_filtered.xml b/app/src/main/res/layout/item_status_filtered.xml index 6f13b2e27..1f28825f7 100644 --- a/app/src/main/res/layout/item_status_filtered.xml +++ b/app/src/main/res/layout/item_status_filtered.xml @@ -1,33 +1,52 @@ - + android:layout_height="wrap_content" + android:paddingStart="14dp" + android:paddingEnd="14dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" + android:minHeight="48dp"> + tools:text="Filter: MyFilter" /> + +