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% --> &lt;b>%1$d%%&lt;/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% --> &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>
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 = "",