Support setting filter expirations (#2667)

* Show filter expiration in list

* Add support for setting and updating the duration of a filter

* Add tests for duration conversion math

* Refactor network wrapper code

* Mark updated mastodon api functions as suspend

* Avoid creating unnecessary Date objects

* Apply suggestions to filter dialog layout
This commit is contained in:
Levi Bard 2022-08-17 17:50:34 +02:00 committed by GitHub
commit c47d9ef6ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 197 additions and 100 deletions

View file

@ -1,26 +1,25 @@
package com.keylesspalace.tusky
import android.os.Bundle
import android.text.format.DateUtils
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.getSecondsForDurationIndex
import com.keylesspalace.tusky.view.setupEditDialogForFilter
import com.keylesspalace.tusky.view.showAddFilterDialog
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
@ -47,7 +46,7 @@ class FiltersActivity : BaseActivity() {
setDisplayShowHomeEnabled(true)
}
binding.addFilterButton.setOnClickListener {
showAddFilterDialog()
showAddFilterDialog(this)
}
title = intent?.getStringExtra(FILTERS_TITLE)
@ -55,15 +54,10 @@ class FiltersActivity : BaseActivity() {
loadFilters()
}
private fun updateFilter(filter: Filter, itemIndex: Int) {
api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, null)
.enqueue(object : Callback<Filter> {
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val updatedFilter = response.body()!!
fun updateFilter(id: String, phrase: String, filterContext: List<String>, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) {
lifecycleScope.launch {
api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold(
{ updatedFilter ->
if (updatedFilter.context.contains(context)) {
filters[itemIndex] = updatedFilter
} else {
@ -71,25 +65,30 @@ class FiltersActivity : BaseActivity() {
}
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
})
)
}
}
private fun deleteFilter(itemIndex: Int) {
fun deleteFilter(itemIndex: Int) {
val filter = filters[itemIndex]
if (filter.context.size == 1) {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).enqueue(object : Callback<ResponseBody> {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
filters.removeAt(itemIndex)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
}
})
lifecycleScope.launch {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).fold(
{
filters.removeAt(itemIndex)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
)
}
} else {
// Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex]
@ -97,69 +96,50 @@ class FiltersActivity : BaseActivity() {
oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
)
updateFilter(newFilter, itemIndex)
updateFilter(
newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord,
getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex
)
}
}
private fun createFilter(phrase: String, wholeWord: Boolean) {
api.createFilter(phrase, listOf(context), false, wholeWord, null).enqueue(object : Callback<Filter> {
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val filterResponse = response.body()
if (response.isSuccessful && filterResponse != null) {
filters.add(filterResponse)
fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) {
lifecycleScope.launch {
api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold(
{ filter ->
filters.add(filter)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
} else {
},
{
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
}
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
})
}
private fun showAddFilterDialog() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_addition_dialog_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
}
private fun setupEditDialogForItem(itemIndex: Int) {
val binding = DialogFilterBinding.inflate(layoutInflater)
val filter = filters[itemIndex]
binding.phraseEditText.setText(filter.phrase)
binding.phraseWholeWord.isChecked = filter.wholeWord
AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_edit_dialog_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex]
val newFilter = Filter(
oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context,
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked
)
updateFilter(newFilter, itemIndex)
}
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
deleteFilter(itemIndex)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
)
}
}
private fun refreshFilterDisplay() {
binding.filtersView.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, filters.map { filter -> filter.phrase })
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForItem(position) }
binding.filtersView.adapter = ArrayAdapter(
this,
android.R.layout.simple_list_item_1,
filters.map { filter ->
if (filter.expiresAt == null) {
filter.phrase
} else {
getString(
R.string.filter_expiration_format,
filter.phrase,
DateUtils.getRelativeTimeSpanString(
filter.expiresAt.time,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
)
}
}
)
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) }
}
private fun loadFilters() {

View file

@ -531,29 +531,29 @@ interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/filters")
fun createFilter(
suspend fun createFilter(
@Field("phrase") phrase: String,
@Field("context[]") context: List<String>,
@Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresIn: Int?
): Call<Filter>
@Field("expires_in") expiresInSeconds: Int?
): NetworkResult<Filter>
@FormUrlEncoded
@PUT("api/v1/filters/{id}")
fun updateFilter(
suspend fun updateFilter(
@Path("id") id: String,
@Field("phrase") phrase: String,
@Field("context[]") context: List<String>,
@Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresIn: Int?
): Call<Filter>
@Field("expires_in") expiresInSeconds: Int?
): NetworkResult<Filter>
@DELETE("api/v1/filters/{id}")
fun deleteFilter(
suspend fun deleteFilter(
@Path("id") id: String
): Call<ResponseBody>
): NetworkResult<ResponseBody>
@FormUrlEncoded
@POST("api/v1/polls/{id}/votes")

View file

@ -0,0 +1,73 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.entity.Filter
import java.util.Date
fun showAddFilterDialog(activity: FiltersActivity) {
val binding = DialogFilterBinding.inflate(activity.layoutInflater)
binding.phraseWholeWord.isChecked = true
binding.filterDurationSpinner.adapter = ArrayAdapter(
activity,
android.R.layout.simple_list_item_1,
activity.resources.getStringArray(R.array.filter_duration_names)
)
AlertDialog.Builder(activity)
.setTitle(R.string.filter_addition_dialog_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
activity.createFilter(
binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked,
getSecondsForDurationIndex(binding.filterDurationSpinner.selectedItemPosition, activity)
)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
}
fun setupEditDialogForFilter(activity: FiltersActivity, filter: Filter, itemIndex: Int) {
val binding = DialogFilterBinding.inflate(activity.layoutInflater)
binding.phraseEditText.setText(filter.phrase)
binding.phraseWholeWord.isChecked = filter.wholeWord
val filterNames = activity.resources.getStringArray(R.array.filter_duration_names).toMutableList()
if (filter.expiresAt != null) {
filterNames.add(0, activity.getString(R.string.duration_no_change))
}
binding.filterDurationSpinner.adapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, filterNames)
AlertDialog.Builder(activity)
.setTitle(R.string.filter_edit_dialog_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
var index = binding.filterDurationSpinner.selectedItemPosition
if (filter.expiresAt != null) {
// We prepended "No changes", account for that here
--index
}
activity.updateFilter(
filter.id, binding.phraseEditText.text.toString(), filter.context,
filter.irreversible, binding.phraseWholeWord.isChecked,
getSecondsForDurationIndex(index, activity, filter.expiresAt), itemIndex
)
}
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
activity.deleteFilter(itemIndex)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
}
// 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)
}
}