diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt index 9f276310..3a61df90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt @@ -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() { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index e37081c3..cb07545c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -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") diff --git a/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt new file mode 100644 index 00000000..c6cea1e2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt @@ -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) + } +} diff --git a/app/src/main/res/layout/dialog_filter.xml b/app/src/main/res/layout/dialog_filter.xml index 09c6001d..f331fed6 100644 --- a/app/src/main/res/layout/dialog_filter.xml +++ b/app/src/main/res/layout/dialog_filter.xml @@ -4,32 +4,34 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingTop="16dp"> + android:padding="24dp"> <EditText android:id="@+id/phraseEditText" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingEnd="24dp" - android:paddingStart="24dp" android:hint="@string/filter_add_description" 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 android:id="@+id/phraseWholeWord" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:paddingEnd="24dp" - android:paddingStart="24dp" android:text="@string/filter_dialog_whole_word" - app:layout_constraintTop_toBottomOf="@id/phraseEditText" + app:layout_constraintTop_toBottomOf="@id/filterDurationSpinner" app:layout_constraintLeft_toLeftOf="parent" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:paddingEnd="8dp" - android:paddingStart="8dp" + android:lineSpacingMultiplier="1.1" app:layout_constraintTop_toBottomOf="@id/phraseWholeWord" app:layout_constraintLeft_toLeftOf="parent" android:text="@string/filter_dialog_whole_word_description" diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 5232e4e5..3e1f033d 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -190,6 +190,8 @@ <item>31536000</item> </integer-array> + <string name="poll_percent_format"><!-- 15% --> <b>%1$d%%</b></string> + <string-array name="mute_duration_names"> <item>@string/duration_indefinite</item> <item>@string/duration_5_min</item> @@ -212,5 +214,25 @@ <item>604800</item> </integer-array> - <string name="poll_percent_format"><!-- 15% --> <b>%1$d%%</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> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21b0708e..72a1556c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -383,6 +383,7 @@ <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_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_description">Add new Mastodon Account</string> @@ -602,6 +603,7 @@ <string name="duration_90_days">90 days</string> <string name="duration_180_days">180 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="poll_allow_multiple_choices">Multiple choices</string> <string name="poll_new_choice_hint">Choice %d</string> diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index bb2447db..48139fc4 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -7,6 +7,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.view.getSecondsForDurationIndex import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue 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( content: String = "", spoilerText: String = "",