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

View File

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

View File

@ -4,32 +4,34 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="16dp"> android:padding="24dp">
<EditText <EditText
android:id="@+id/phraseEditText" android:id="@+id/phraseEditText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingEnd="24dp"
android:paddingStart="24dp"
android:hint="@string/filter_add_description" android:hint="@string/filter_add_description"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
/> />
<Spinner
android:id="@+id/filterDurationSpinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/phraseEditText"
app:layout_constraintLeft_toLeftOf="parent"
/>
<CheckBox <CheckBox
android:id="@+id/phraseWholeWord" android:id="@+id/phraseWholeWord"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingEnd="24dp"
android:paddingStart="24dp"
android:text="@string/filter_dialog_whole_word" android:text="@string/filter_dialog_whole_word"
app:layout_constraintTop_toBottomOf="@id/phraseEditText" app:layout_constraintTop_toBottomOf="@id/filterDurationSpinner"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
/> />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingEnd="8dp" android:lineSpacingMultiplier="1.1"
android:paddingStart="8dp"
app:layout_constraintTop_toBottomOf="@id/phraseWholeWord" app:layout_constraintTop_toBottomOf="@id/phraseWholeWord"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
android:text="@string/filter_dialog_whole_word_description" android:text="@string/filter_dialog_whole_word_description"

View File

@ -190,6 +190,8 @@
<item>31536000</item> <item>31536000</item>
</integer-array> </integer-array>
<string name="poll_percent_format"><!-- 15% --> &lt;b>%1$d%%&lt;/b></string>
<string-array name="mute_duration_names"> <string-array name="mute_duration_names">
<item>@string/duration_indefinite</item> <item>@string/duration_indefinite</item>
<item>@string/duration_5_min</item> <item>@string/duration_5_min</item>
@ -212,5 +214,25 @@
<item>604800</item> <item>604800</item>
</integer-array> </integer-array>
<string name="poll_percent_format"><!-- 15% --> &lt;b>%1$d%%&lt;/b></string> <string-array name="filter_duration_names">
<item>@string/duration_indefinite</item>
<item>@string/duration_5_min</item>
<item>@string/duration_30_min</item>
<item>@string/duration_1_hour</item>
<item>@string/duration_6_hours</item>
<item>@string/duration_1_day</item>
<item>@string/duration_3_days</item>
<item>@string/duration_7_days</item>
</string-array>
<integer-array name="filter_duration_values"> <!-- values in seconds, corresponding to mute_duration_names -->
<item>0</item>
<item>300</item>
<item>1800</item>
<item>3600</item>
<item>21600</item>
<item>86400</item>
<item>259200</item>
<item>604800</item>
</integer-array>
</resources> </resources>

View File

@ -383,6 +383,7 @@
<string name="filter_dialog_whole_word">Whole word</string> <string name="filter_dialog_whole_word">Whole word</string>
<string name="filter_dialog_whole_word_description">When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word</string> <string name="filter_dialog_whole_word_description">When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word</string>
<string name="filter_add_description">Phrase to filter</string> <string name="filter_add_description">Phrase to filter</string>
<string name="filter_expiration_format">%s (%s)</string>
<string name="add_account_name">Add Account</string> <string name="add_account_name">Add Account</string>
<string name="add_account_description">Add new Mastodon Account</string> <string name="add_account_description">Add new Mastodon Account</string>
@ -602,6 +603,7 @@
<string name="duration_90_days">90 days</string> <string name="duration_90_days">90 days</string>
<string name="duration_180_days">180 days</string> <string name="duration_180_days">180 days</string>
<string name="duration_365_days">365 days</string> <string name="duration_365_days">365 days</string>
<string name="duration_no_change">(No change)</string>
<string name="add_poll_choice">Add choice</string> <string name="add_poll_choice">Add choice</string>
<string name="poll_allow_multiple_choices">Multiple choices</string> <string name="poll_allow_multiple_choices">Multiple choices</string>
<string name="poll_new_choice_hint">Choice %d</string> <string name="poll_new_choice_hint">Choice %d</string>

View File

@ -7,6 +7,7 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.PollOption
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.view.getSecondsForDurationIndex
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -182,6 +183,23 @@ class FilterTest {
) )
) )
} }
@Test
fun unchangedExpiration_shouldBeNegative_whenFilterIsExpired() {
val expiredBySeconds = 3600
val expiredDate = Date.from(Instant.now().minusSeconds(expiredBySeconds.toLong()))
val updatedDuration = getSecondsForDurationIndex(-1, null, expiredDate)
assert(updatedDuration != null && updatedDuration <= -expiredBySeconds)
}
@Test
fun unchangedExpiration_shouldBePositive_whenFilterIsUnexpired() {
val expiresInSeconds = 3600
val expiredDate = Date.from(Instant.now().plusSeconds(expiresInSeconds.toLong()))
val updatedDuration = getSecondsForDurationIndex(-1, null, expiredDate)
assert(updatedDuration != null && updatedDuration > (expiresInSeconds - 60))
}
private fun mockStatus( private fun mockStatus(
content: String = "", content: String = "",
spoilerText: String = "", spoilerText: String = "",