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:
parent
b9be125c95
commit
ff8dd37855
109 changed files with 2770 additions and 631 deletions
|
|
@ -130,10 +130,11 @@ data class ConversationStatusEntity(
|
|||
poll = poll,
|
||||
card = null,
|
||||
language = language,
|
||||
filtered = null,
|
||||
),
|
||||
isExpanded = expanded,
|
||||
isShowingContent = showingHiddenContent,
|
||||
isCollapsed = collapsed
|
||||
isCollapsed = collapsed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,9 @@ class ConversationsFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearWarningAction(position: Int) {
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -436,6 +436,10 @@ class ViewThreadFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearWarningAction(position: Int) {
|
||||
viewModel.clearWarning(adapter.currentList[position])
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ViewThreadFragment"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue