Support the mastodon 4 filter api (#3188)

* Replace "warn"-filtered posts in timelines and thread view with placeholders

* Adapt hashtag muting interface

* Rework filter UI

* Add icon for account preferences

* Clean up UI

* WIP: Use chips instead of a list. Adjust padding

* Scroll the filter edit activity

Nested scrolling views (e.g., an activity that scrolls with an embedded list
that also scrolls) can be difficult UI.

Since the list of contexts is fixed, replace it with a fixed collection of
switches, so there's no need to scroll the list.

Since the list of actions is only two (warn, hide), and are mutually
exclusive, replace the spinner with two radio buttons.

Use the accent colour and title styles on the different heading titles in
the layout, to match the presentation in Preferences.

Add an explicit "Cancel" button.

The layout is a straightforward LinearLayout, so use that instead of
ConstraintLayout, and remove some unncessary IDs.

Update EditFilterActivity to handle the new layout.

* Cleanup

* Add more information to the filter list view

* First pass on code review comments

* Add view model to filters activity

* Add view model to edit filters activity

* Only use the status wrapper for filtered statuses

* Relint

---------

Co-authored-by: Nik Clayton <nik@ngo.org.uk>
This commit is contained in:
Levi Bard 2023-03-11 13:12:50 +01:00 committed by GitHub
commit ff8dd37855
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 2770 additions and 631 deletions

View file

@ -130,10 +130,11 @@ data class ConversationStatusEntity(
poll = poll,
card = null,
language = language,
filtered = null,
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
isCollapsed = collapsed
isCollapsed = collapsed,
)
}
}

View file

@ -352,6 +352,9 @@ class ConversationsFragment :
}
}
override fun clearWarningAction(position: Int) {
}
override fun onReselect() {
if (isAdded) {
binding.recyclerView.layoutManager?.scrollToPosition(0)

View file

@ -0,0 +1,272 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import java.util.Date
import javax.inject.Inject
class EditFilterActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
private val viewModel: EditFilterViewModel by viewModels { viewModelFactory }
private lateinit var filter: Filter
private var originalFilter: Filter? = null
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply {
contextSwitches = mapOf(
filterContextHome to Filter.Kind.HOME,
filterContextNotifications to Filter.Kind.NOTIFICATIONS,
filterContextPublic to Filter.Kind.PUBLIC,
filterContextThread to Filter.Kind.THREAD,
filterContextAccount to Filter.Kind.ACCOUNT,
)
}
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setTitle(
if (originalFilter == null) {
R.string.filter_addition_title
} else {
R.string.filter_edit_title
}
)
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
for (switch in contextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked ->
val context = contextSwitches[switch]!!
if (isChecked) {
viewModel.addContext(context)
} else {
viewModel.removeContext(context)
}
validateSaveButton()
}
}
binding.filterTitle.doAfterTextChanged { editable ->
viewModel.setTitle(editable.toString())
validateSaveButton()
}
binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
viewModel.setAction(
if (checked) {
Filter.Action.WARN
} else {
Filter.Action.HIDE
}
)
}
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.setDuration(
if (originalFilter?.expiresAt == null) {
position
} else {
position - 1
}
)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
viewModel.setDuration(0)
}
}
validateSaveButton()
if (originalFilter == null) {
binding.filterActionWarn.isChecked = true
} else {
loadFilter()
}
observeModel()
}
private fun observeModel() {
lifecycleScope.launch {
viewModel.title.collect { title ->
if (title != binding.filterTitle.text.toString()) {
// We also get this callback when typing in the field,
// which messes with the cursor focus
binding.filterTitle.setText(title)
}
}
}
lifecycleScope.launch {
viewModel.keywords.collect { keywords ->
updateKeywords(keywords)
}
}
lifecycleScope.launch {
viewModel.contexts.collect { contexts ->
for (entry in contextSwitches) {
entry.key.isChecked = contexts.contains(entry.value)
}
}
}
lifecycleScope.launch {
viewModel.action.collect { action ->
when (action) {
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
else -> binding.filterActionWarn.isChecked = true
}
}
}
}
// Populate the UI from the filter's members
private fun loadFilter() {
viewModel.load(filter)
if (filter.expiresAt != null) {
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames)
}
}
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
newKeywords.forEachIndexed { index, filterKeyword ->
val chip = binding.keywordChips.getChildAt(index).takeUnless {
it.id == R.id.actionChip
} as Chip? ?: Chip(this).apply {
setCloseIconResource(R.drawable.ic_cancel_24dp)
isCheckable = false
binding.keywordChips.addView(this, binding.keywordChips.size - 1)
}
chip.text = if (filterKeyword.wholeWord) {
binding.root.context.getString(
R.string.filter_keyword_display_format,
filterKeyword.keyword
)
} else {
filterKeyword.keyword
}
chip.isCloseIconVisible = true
chip.setOnClickListener {
showEditKeywordDialog(newKeywords[index])
}
chip.setOnCloseIconClickListener {
viewModel.deleteKeyword(newKeywords[index])
}
}
while (binding.keywordChips.size - 1 > newKeywords.size) {
binding.keywordChips.removeViewAt(newKeywords.size)
}
filter = filter.copy(keywords = newKeywords)
validateSaveButton()
}
private fun showAddKeywordDialog() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this)
.setTitle(R.string.filter_keyword_addition_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.addKeyword(
FilterKeyword(
"",
binding.phraseEditText.text.toString(),
binding.phraseWholeWord.isChecked,
)
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showEditKeywordDialog(keyword: FilterKeyword) {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseEditText.setText(keyword.keyword)
binding.phraseWholeWord.isChecked = keyword.wholeWord
AlertDialog.Builder(this)
.setTitle(R.string.filter_edit_keyword_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
viewModel.modifyKeyword(
keyword,
keyword.copy(
keyword = binding.phraseEditText.text.toString(),
wholeWord = binding.phraseWholeWord.isChecked,
)
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun validateSaveButton() {
binding.filterSaveButton.isEnabled = viewModel.validate()
}
private fun saveChanges() {
lifecycleScope.launch {
if (viewModel.saveChanges(this@EditFilterActivity)) {
finish()
} else {
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
}
}
}
companion object {
const val FILTER_TO_EDIT = "FilterToEdit"
// Mastodon *stores* the absolute date in the filter,
// but create/edit take a number of seconds (relative to the time the operation is posted)
fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? {
return when (index) {
-1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() }
0 -> null
else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index)
}
}
}
}

View file

@ -0,0 +1,186 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import javax.inject.Inject
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
private var originalFilter: Filter? = null
val title = MutableStateFlow("")
val keywords = MutableStateFlow(listOf<FilterKeyword>())
val action = MutableStateFlow(Filter.Action.WARN)
val duration = MutableStateFlow(0)
val contexts = MutableStateFlow(listOf<Filter.Kind>())
fun load(filter: Filter) {
originalFilter = filter
title.value = filter.title
keywords.value = filter.keywords
action.value = filter.action
duration.value = if (filter.expiresAt == null) {
0
} else {
-1
}
contexts.value = filter.kinds
}
fun addKeyword(keyword: FilterKeyword) {
keywords.value += keyword
}
fun deleteKeyword(keyword: FilterKeyword) {
keywords.value = keywords.value.filterNot { it == keyword }
}
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
val index = keywords.value.indexOf(original)
if (index >= 0) {
keywords.value = keywords.value.toMutableList().apply {
set(index, updated)
}
}
}
fun setTitle(title: String) {
this.title.value = title
}
fun setDuration(index: Int) {
duration.value = index
}
fun setAction(action: Filter.Action) {
this.action.value = action
}
fun addContext(context: Filter.Kind) {
if (!contexts.value.contains(context)) {
contexts.value += context
}
}
fun removeContext(context: Filter.Kind) {
contexts.value = contexts.value.filter { it != context }
}
fun validate(): Boolean {
return title.value.isNotBlank() &&
keywords.value.isNotEmpty() &&
contexts.value.isNotEmpty()
}
suspend fun saveChanges(context: Context): Boolean {
val contexts = contexts.value.map { it.kind }
val title = title.value
val durationIndex = duration.value
val action = action.value.action
return withContext(viewModelScope.coroutineContext) {
originalFilter?.let { filter ->
updateFilter(filter, title, contexts, action, durationIndex, context)
} ?: createFilter(title, contexts, action, durationIndex, context)
}
}
private suspend fun createFilter(title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
api.createFilter(
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds,
).fold(
{ newFilter ->
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
return keywords.value.map { keyword ->
api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
}.none { it.isFailure }
},
{ throwable ->
return (
throwable is HttpException && throwable.code() == 404 &&
// Endpoint not found, fall back to v1 api
createFilterV1(contexts, expiresInSeconds)
)
}
)
}
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
api.updateFilter(
id = originalFilter.id,
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds,
).fold(
{
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
val results = keywords.value.map { keyword ->
if (keyword.id.isEmpty()) {
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
} else {
api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
}
} + originalFilter.keywords.filter { keyword ->
// Deleted keywords
keywords.value.none { it.id == keyword.id }
}.map { api.deleteFilterKeyword(it.id) }
return results.none { it.isFailure }
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
// Endpoint not found, fall back to v1 api
if (updateFilterV1(contexts, expiresInSeconds)) {
return true
}
}
return false
}
)
}
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
return keywords.value.map { keyword ->
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
}.none { it.isFailure }
}
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
val results = keywords.value.map { keyword ->
if (originalFilter == null) {
api.createFilterV1(
phrase = keyword.keyword,
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
)
} else {
api.updateFilterV1(
id = originalFilter!!.id,
phrase = keyword.keyword,
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds,
)
}
}
// Don't handle deleted keywords here because there's only one keyword per v1 filter anyway
return results.none { it.isFailure }
}
}

View file

@ -0,0 +1,106 @@
package com.keylesspalace.tusky.components.filters
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class FiltersActivity : BaseActivity(), FiltersListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityFiltersBinding::inflate)
private val viewModel: FiltersViewModel by viewModels { viewModelFactory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
binding.addFilterButton.setOnClickListener {
launchEditFilterActivity()
}
setTitle(R.string.pref_title_timeline_filters)
}
override fun onResume() {
super.onResume()
loadFilters()
observeViewModel()
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.filters.collect { filters ->
binding.filtersView.show()
binding.addFilterButton.show()
binding.filterProgressBar.hide()
refreshFilterDisplay(filters)
}
}
lifecycleScope.launch {
viewModel.error.collect { error ->
if (error is IOException) {
binding.filterMessageView.setup(
R.drawable.elephant_offline,
R.string.error_network
) { loadFilters() }
} else {
binding.filterMessageView.setup(
R.drawable.elephant_error,
R.string.error_generic
) { loadFilters() }
}
}
}
}
private fun refreshFilterDisplay(filters: List<Filter>) {
binding.filtersView.adapter = FiltersAdapter(this, filters)
}
private fun loadFilters() {
binding.filterMessageView.hide()
binding.filtersView.hide()
binding.addFilterButton.hide()
binding.filterProgressBar.show()
viewModel.load()
}
private fun launchEditFilterActivity(filter: Filter? = null) {
val intent = Intent(this, EditFilterActivity::class.java).apply {
if (filter != null) {
putExtra(EditFilterActivity.FILTER_TO_EDIT, filter)
}
}
startActivity(intent)
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
}
override fun deleteFilter(filter: Filter) {
viewModel.deleteFilter(filter, binding.root)
}
override fun updateFilter(updatedFilter: Filter) {
launchEditFilterActivity(updatedFilter)
}
}

View file

@ -0,0 +1,52 @@
package com.keylesspalace.tusky.components.filters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemRemovableBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
RecyclerView.Adapter<BindingHolder<ItemRemovableBinding>>() {
override fun getItemCount(): Int = filters.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemRemovableBinding> {
return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: BindingHolder<ItemRemovableBinding>, position: Int) {
val binding = holder.binding
val resources = binding.root.resources
val actions = resources.getStringArray(R.array.filter_actions)
val contexts = resources.getStringArray(R.array.filter_contexts)
val filter = filters[position]
val context = binding.root.context
binding.textPrimary.text = if (filter.expiresAt == null) {
filter.title
} else {
context.getString(
R.string.filter_expiration_format,
filter.title,
getRelativeTimeSpanString(binding.root.context, filter.expiresAt.time, System.currentTimeMillis())
)
}
binding.textSecondary.text = context.getString(
R.string.filter_description_format,
actions.getOrNull(filter.action.ordinal - 1),
filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/")
)
binding.delete.setOnClickListener {
listener.deleteFilter(filter)
}
binding.root.setOnClickListener {
listener.updateFilter(filter)
}
}
}

View file

@ -0,0 +1,8 @@
package com.keylesspalace.tusky.components.filters
import com.keylesspalace.tusky.entity.Filter
interface FiltersListener {
fun deleteFilter(filter: Filter)
fun updateFilter(updatedFilter: Filter)
}

View file

@ -0,0 +1,74 @@
package com.keylesspalace.tusky.components.filters
import android.view.View
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
class FiltersViewModel @Inject constructor(
private val api: MastodonApi,
private val eventHub: EventHub
) : ViewModel() {
val filters: MutableStateFlow<List<Filter>> = MutableStateFlow(listOf())
val error: MutableStateFlow<Throwable?> = MutableStateFlow(null)
fun load() {
viewModelScope.launch {
api.getFilters().fold(
{ filters ->
this@FiltersViewModel.filters.value = filters
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
api.getFiltersV1().fold(
{ filters ->
this@FiltersViewModel.filters.value = filters.map { it.toFilter() }
},
{ throwable ->
error.value = throwable
}
)
} else {
error.value = throwable
}
}
)
}
}
fun deleteFilter(filter: Filter, parent: View) {
viewModelScope.launch {
api.deleteFilter(filter.id).fold(
{
filters.value = filters.value.filter { it.id != filter.id }
for (context in filter.context) {
eventHub.dispatch(PreferenceChangedEvent(context))
}
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
api.deleteFilterV1(filter.id).fold(
{
filters.value = filters.value.filter { it.id != filter.id }
},
{
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
},
)
} else {
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
}
)
}
}
}

View file

@ -555,6 +555,9 @@ class NotificationsFragment :
onContentCollapsedChange(isCollapsed, position)
}
override fun clearWarningAction(position: Int) {
}
private fun clearNotifications() {
binding.swipeRefreshLayout.isRefreshing = false
binding.progressBar.isVisible = false

View file

@ -26,12 +26,12 @@ import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
@ -39,7 +39,6 @@ import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigra
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.AccountPreferenceHandler
@ -177,6 +176,15 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
preference {
setTitle(R.string.pref_title_timeline_filters)
setIcon(R.drawable.ic_filter_24dp)
setOnPreferenceClickListener {
launchFilterActivity()
true
}
}
preferenceCategory(R.string.pref_publishing) {
listPreference {
setTitle(R.string.pref_default_post_privacy)
@ -261,48 +269,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceDataStore = accountPreferenceHandler
}
}
preferenceCategory(R.string.pref_title_timeline_filters) {
preference {
setTitle(R.string.pref_title_public_filter_keywords)
setOnPreferenceClickListener {
launchFilterActivity(Filter.PUBLIC, R.string.pref_title_public_filter_keywords)
true
}
}
preference {
setTitle(R.string.title_notifications)
setOnPreferenceClickListener {
launchFilterActivity(Filter.NOTIFICATIONS, R.string.title_notifications)
true
}
}
preference {
setTitle(R.string.title_home)
setOnPreferenceClickListener {
launchFilterActivity(Filter.HOME, R.string.title_home)
true
}
}
preference {
setTitle(R.string.pref_title_thread_filter_keywords)
setOnPreferenceClickListener {
launchFilterActivity(Filter.THREAD, R.string.pref_title_thread_filter_keywords)
true
}
}
preference {
setTitle(R.string.title_accounts)
setOnPreferenceClickListener {
launchFilterActivity(Filter.ACCOUNT, R.string.title_accounts)
true
}
}
}
}
}
@ -383,10 +349,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
private fun launchFilterActivity(filterContext: String, titleResource: Int) {
private fun launchFilterActivity() {
val intent = Intent(context, FiltersActivity::class.java)
intent.putExtra(FiltersActivity.FILTERS_CONTEXT, filterContext)
intent.putExtra(FiltersActivity.FILTERS_TITLE, getString(titleResource))
activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
}

View file

@ -190,6 +190,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
}
override fun clearWarningAction(position: Int) {}
private fun removeItem(position: Int) {
searchAdapter.peek(position)?.let {
viewModel.removeItem(it)

View file

@ -424,6 +424,11 @@ class TimelineFragment :
viewModel.voteInPoll(choices, status)
}
override fun clearWarningAction(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.clearWarning(status)
}
override fun onMore(view: View, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.more(status.status, view, position)

View file

@ -24,6 +24,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -46,21 +47,16 @@ class TimelinePagingAdapter(
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(viewGroup.context)
return when (viewType) {
VIEW_TYPE_STATUS -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_status, viewGroup, false)
StatusViewHolder(view)
VIEW_TYPE_STATUS_FILTERED -> {
StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false))
}
VIEW_TYPE_PLACEHOLDER -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_status_placeholder, viewGroup, false)
PlaceholderViewHolder(view)
PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false))
}
else -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_status, viewGroup, false)
StatusViewHolder(view)
StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false))
}
}
}
@ -98,8 +94,11 @@ class TimelinePagingAdapter(
}
override fun getItemViewType(position: Int): Int {
return if (getItem(position) is StatusViewData.Placeholder) {
val viewData = getItem(position)
return if (viewData is StatusViewData.Placeholder) {
VIEW_TYPE_PLACEHOLDER
} else if (viewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS
}
@ -107,6 +106,7 @@ class TimelinePagingAdapter(
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_FILTERED = 1
private const val VIEW_TYPE_PLACEHOLDER = 2
val TimelineDifferCallback = object : DiffUtil.ItemCallback<StatusViewData>() {

View file

@ -105,6 +105,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
card = null,
repliesCount = 0,
language = null,
filtered = null,
)
}
@ -149,6 +150,7 @@ fun Status.toEntity(
card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount,
language = actionableStatus.language,
filtered = actionableStatus.filtered,
)
}
@ -196,6 +198,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
card = card,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
}
val status = if (reblog != null) {
@ -228,6 +231,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
card = null,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
} else {
Status(
@ -259,6 +263,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
card = card,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
}
return StatusViewData.Concrete(

View file

@ -41,6 +41,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
@ -100,7 +101,7 @@ class CachedTimelineViewModel @Inject constructor(
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson)
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData)
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
}
}
.flowOn(Dispatchers.Default)
@ -152,6 +153,12 @@ class CachedTimelineViewModel @Inject constructor(
}
}
override fun clearWarning(status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId)
}
}
override fun removeStatusWithId(id: String) {
// handled by CacheUpdater
}

View file

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
@ -82,7 +83,7 @@ class NetworkTimelineViewModel @Inject constructor(
).flow
.map { pagingData ->
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData)
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
}
}
.flowOn(Dispatchers.Default)
@ -248,6 +249,12 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource?.invalidate()
}
override fun clearWarning(status: StatusViewData.Concrete) {
updateActionableStatusById(status.actionableId) {
it.copy(filtered = null)
}
}
override suspend fun invalidate() {
currentSource?.invalidate()
}

View file

@ -20,6 +20,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent
@ -38,6 +39,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
abstract class TimelineViewModel(
private val timelineCases: TimelineCases,
@ -82,6 +85,7 @@ abstract class TimelineViewModel(
this.kind = kind
this.id = id
this.tags = tags
filterModel.kind = kind.toFilterKind()
if (kind == Kind.HOME) {
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show"
@ -178,14 +182,22 @@ abstract class TimelineViewModel(
abstract fun fullReload()
abstract fun clearWarning(status: StatusViewData.Concrete)
/** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate()
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean {
val status = statusViewData.asStatusOrNull()?.status ?: return false
return status.inReplyToId != null && filterRemoveReplies ||
status.reblog != null && filterRemoveReblogs ||
filterModel.shouldFilterStatus(status.actionableStatus)
protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action {
val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE
return if (
(status.inReplyToId != null && filterRemoveReplies) ||
(status.reblog != null && filterRemoveReblogs)
) {
return Filter.Action.HIDE
} else {
statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus)
statusViewData.filterAction
}
}
private fun onPreferenceChanged(key: String) {
@ -206,7 +218,7 @@ abstract class TimelineViewModel(
fullReload()
}
}
Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> {
FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> {
if (filterContextMatchesKind(kind, listOf(key))) {
reloadFilters()
}
@ -222,28 +234,6 @@ abstract class TimelineViewModel(
}
}
private fun filterContextMatchesKind(
kind: Kind,
filterContext: List<String>
): Boolean {
// home, notifications, public, thread
return when (kind) {
Kind.HOME, Kind.LIST -> filterContext.contains(
Filter.HOME
)
Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains(
Filter.PUBLIC
)
Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains(
Filter.NOTIFICATIONS
)
Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains(
Filter.ACCOUNT
)
else -> false
}
}
private fun handleEvent(event: Event) {
when (event) {
is FavoriteEvent -> handleFavEvent(event)
@ -288,27 +278,57 @@ abstract class TimelineViewModel(
private fun reloadFilters() {
viewModelScope.launch {
val filters = api.getFilters().getOrElse {
Log.e(TAG, "Failed to fetch filters", it)
return@launch
}
filterModel.initWithFilters(
filters.filter {
filterContextMatchesKind(kind, it.context)
}
api.getFilters().fold(
{
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
// Fallback to client-side filter code
val filters = api.getFiltersV1().getOrElse {
Log.e(TAG, "Failed to fetch filters", it)
return@launch
}
filterModel.initWithFilters(
filters.filter {
filterContextMatchesKind(kind, it.context)
}
)
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
} else {
Log.e(TAG, "Error getting filters", throwable)
}
},
)
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
}
}
companion object {
private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30
fun filterContextMatchesKind(
kind: Kind,
filterContext: List<String>
): Boolean {
return filterContext.contains(kind.toFilterKind().kind)
}
}
enum class Kind {
HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS
HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS;
fun toFilterKind(): Filter.Kind {
return when (valueOf(name)) {
HOME, LIST -> Filter.Kind.HOME
PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC
USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT
else -> Filter.Kind.PUBLIC
}
}
}
}

View file

@ -80,11 +80,15 @@ class TrendingViewModel @Inject constructor(
}
val homeFilters = deferredFilters.await().getOrNull()?.filter {
it.context.contains(Filter.HOME)
it.context.contains(Filter.Kind.HOME.kind)
}
val tags = response.body()!!
.filter { homeFilters?.none { filter -> filter.phrase.equals(it.name, ignoreCase = true) } ?: false }
.filter {
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) }
} ?: false
}
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.map { it.toViewData() }
.asReversed()

View file

@ -23,6 +23,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -33,16 +34,16 @@ class ThreadAdapter(
) : ListAdapter<StatusViewData.Concrete, StatusBaseViewHolder>(ThreadDifferCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_STATUS -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status, parent, false)
StatusViewHolder(view)
StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false))
}
VIEW_TYPE_STATUS_FILTERED -> {
StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false))
}
VIEW_TYPE_STATUS_DETAILED -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status_detailed, parent, false)
StatusDetailedViewHolder(view)
StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false))
}
else -> error("Unknown item type: $viewType")
}
@ -54,8 +55,11 @@ class ThreadAdapter(
}
override fun getItemViewType(position: Int): Int {
return if (getItem(position).isDetailed) {
val viewData = getItem(position)
return if (viewData.isDetailed) {
VIEW_TYPE_STATUS_DETAILED
} else if (viewData.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS
}
@ -65,6 +69,7 @@ class ThreadAdapter(
private const val TAG = "ThreadAdapter"
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_DETAILED = 1
private const val VIEW_TYPE_STATUS_FILTERED = 2
val ThreadDifferCallback = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areItemsTheSame(

View file

@ -436,6 +436,10 @@ class ViewThreadFragment :
}
}
override fun clearWarningAction(position: Int) {
viewModel.clearWarning(adapter.currentList[position])
}
companion object {
private const val TAG = "ViewThreadFragment"

View file

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
@ -51,6 +52,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
class ViewThreadViewModel @Inject constructor(
@ -414,30 +416,48 @@ class ViewThreadViewModel @Inject constructor(
private fun loadFilters() {
viewModelScope.launch {
val filters = api.getFilters().getOrElse {
Log.w(TAG, "Failed to fetch filters", it)
return@launch
}
api.getFilters().fold(
{
filterModel.kind = Filter.Kind.THREAD
updateStatuses()
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
val filters = api.getFiltersV1().getOrElse {
Log.w(TAG, "Failed to fetch filters", it)
return@launch
}
filterModel.initWithFilters(
filters.filter { filter ->
filter.context.contains(Filter.THREAD)
filterModel.initWithFilters(
filters.filter { filter -> filter.context.contains(FilterV1.THREAD) }
)
updateStatuses()
} else {
Log.e(TAG, "Error getting filters", throwable)
}
}
)
}
}
updateSuccess { uiState ->
val statuses = uiState.statusViewData.filter()
uiState.copy(
statusViewData = statuses,
revealButton = statuses.getRevealButtonState()
)
}
private fun updateStatuses() {
updateSuccess { uiState ->
val statuses = uiState.statusViewData.filter()
uiState.copy(
statusViewData = statuses,
revealButton = statuses.getRevealButtonState()
)
}
}
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
return filter { status ->
status.isDetailed || !filterModel.shouldFilterStatus(status.status)
if (status.isDetailed) {
true
} else {
status.filterAction = filterModel.shouldFilterStatus(status.status)
status.filterAction != Filter.Action.HIDE
}
}
}
@ -485,6 +505,12 @@ class ViewThreadViewModel @Inject constructor(
}
}
fun clearWarning(viewData: StatusViewData.Concrete) {
updateStatus(viewData.id) { status ->
status.copy(filtered = null)
}
}
companion object {
private const val TAG = "ViewThreadViewModel"
}