about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/TagMenu/index.tsx56
-rw-r--r--src/components/TagMenu/index.web.tsx36
-rw-r--r--src/components/dialogs/MutedWords.tsx373
-rw-r--r--src/components/hooks/dates.ts69
-rw-r--r--src/state/queries/preferences/index.ts15
5 files changed, 427 insertions, 122 deletions
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 0ed703667..2c6a0b674 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -1,27 +1,27 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useNavigation} from '@react-navigation/native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
 
-import {atoms as a, native, useTheme} from '#/alf'
-import * as Dialog from '#/components/Dialog'
-import {Text} from '#/components/Typography'
-import {Button, ButtonText} from '#/components/Button'
-import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
-import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
-import {Divider} from '#/components/Divider'
-import {Link} from '#/components/Link'
 import {makeSearchLink} from '#/lib/routes/links'
 import {NavigationProp} from '#/lib/routes/types'
+import {isInvalidHandle} from '#/lib/strings/handles'
 import {
   usePreferencesQuery,
+  useRemoveMutedWordsMutation,
   useUpsertMutedWordsMutation,
-  useRemoveMutedWordMutation,
 } from '#/state/queries/preferences'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
+import {Link} from '#/components/Link'
 import {Loader} from '#/components/Loader'
-import {isInvalidHandle} from '#/lib/strings/handles'
+import {Text} from '#/components/Typography'
 
 export function useTagMenuControl() {
   return Dialog.useDialogControl()
@@ -52,10 +52,10 @@ export function TagMenu({
     reset: resetUpsert,
   } = useUpsertMutedWordsMutation()
   const {
-    mutateAsync: removeMutedWord,
+    mutateAsync: removeMutedWords,
     variables: optimisticRemove,
     reset: resetRemove,
-  } = useRemoveMutedWordMutation()
+  } = useRemoveMutedWordsMutation()
   const displayTag = '#' + tag
 
   const isMuted = Boolean(
@@ -65,9 +65,20 @@ export function TagMenu({
       optimisticUpsert?.find(
         m => m.value === tag && m.targets.includes('tag'),
       )) &&
-      !(optimisticRemove?.value === tag),
+      !optimisticRemove?.find(m => m?.value === tag),
   )
 
+  /*
+   * Mute word records that exactly match the tag in question.
+   */
+  const removeableMuteWords = React.useMemo(() => {
+    return (
+      preferences?.moderationPrefs.mutedWords?.filter(word => {
+        return word.value === tag
+      }) || []
+    )
+  }, [tag, preferences?.moderationPrefs?.mutedWords])
+
   return (
     <>
       {children}
@@ -212,13 +223,16 @@ export function TagMenu({
                         control.close(() => {
                           if (isMuted) {
                             resetUpsert()
-                            removeMutedWord({
-                              value: tag,
-                              targets: ['tag'],
-                            })
+                            removeMutedWords(removeableMuteWords)
                           } else {
                             resetRemove()
-                            upsertMutedWord([{value: tag, targets: ['tag']}])
+                            upsertMutedWord([
+                              {
+                                value: tag,
+                                targets: ['tag'],
+                                actorTarget: 'all',
+                              },
+                            ])
                           }
                         })
                       }}>
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index 433622386..b6c306439 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
-import {isInvalidHandle} from '#/lib/strings/handles'
-import {EventStopper} from '#/view/com/util/EventStopper'
-import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
 import {NavigationProp} from '#/lib/routes/types'
+import {isInvalidHandle} from '#/lib/strings/handles'
+import {enforceLen} from '#/lib/strings/helpers'
 import {
   usePreferencesQuery,
+  useRemoveMutedWordsMutation,
   useUpsertMutedWordsMutation,
-  useRemoveMutedWordMutation,
 } from '#/state/queries/preferences'
-import {enforceLen} from '#/lib/strings/helpers'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
 import {web} from '#/alf'
 import * as Dialog from '#/components/Dialog'
 
@@ -47,8 +47,8 @@ export function TagMenu({
   const {data: preferences} = usePreferencesQuery()
   const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
     useUpsertMutedWordsMutation()
-  const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
-    useRemoveMutedWordMutation()
+  const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
+    useRemoveMutedWordsMutation()
   const isMuted = Boolean(
     (preferences?.moderationPrefs.mutedWords?.find(
       m => m.value === tag && m.targets.includes('tag'),
@@ -56,10 +56,21 @@ export function TagMenu({
       optimisticUpsert?.find(
         m => m.value === tag && m.targets.includes('tag'),
       )) &&
-      !(optimisticRemove?.value === tag),
+      !optimisticRemove?.find(m => m?.value === tag),
   )
   const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
 
+  /*
+   * Mute word records that exactly match the tag in question.
+   */
+  const removeableMuteWords = React.useMemo(() => {
+    return (
+      preferences?.moderationPrefs.mutedWords?.filter(word => {
+        return word.value === tag
+      }) || []
+    )
+  }, [tag, preferences?.moderationPrefs?.mutedWords])
+
   const dropdownItems = React.useMemo(() => {
     return [
       {
@@ -105,9 +116,11 @@ export function TagMenu({
           : _(msg`Mute ${truncatedTag}`),
         onPress() {
           if (isMuted) {
-            removeMutedWord({value: tag, targets: ['tag']})
+            removeMutedWords(removeableMuteWords)
           } else {
-            upsertMutedWord([{value: tag, targets: ['tag']}])
+            upsertMutedWord([
+              {value: tag, targets: ['tag'], actorTarget: 'all'},
+            ])
           }
         },
         testID: 'tagMenuMute',
@@ -129,7 +142,8 @@ export function TagMenu({
     tag,
     truncatedTag,
     upsertMutedWord,
-    removeMutedWord,
+    removeMutedWords,
+    removeableMuteWords,
   ])
 
   return (
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 526652be9..38273aad5 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Keyboard, View} from 'react-native'
+import {View} from 'react-native'
 import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 import {Divider} from '#/components/Divider'
 import * as Toggle from '#/components/forms/Toggle'
+import {useFormatDistance} from '#/components/hooks/dates'
 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
 import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
 import * as Prompt from '#/components/Prompt'
 import {Text} from '#/components/Typography'
 
+const ONE_DAY = 24 * 60 * 60 * 1000
+
 export function MutedWordsDialog() {
   const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
   return (
@@ -53,16 +56,32 @@ function MutedWordsInner() {
   } = usePreferencesQuery()
   const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
   const [field, setField] = React.useState('')
-  const [options, setOptions] = React.useState(['content'])
+  const [targets, setTargets] = React.useState(['content'])
   const [error, setError] = React.useState('')
+  const [durations, setDurations] = React.useState(['forever'])
+  const [excludeFollowing, setExcludeFollowing] = React.useState(false)
 
   const submit = React.useCallback(async () => {
     const sanitizedValue = sanitizeMutedWordValue(field)
-    const targets = ['tag', options.includes('content') && 'content'].filter(
+    const surfaces = ['tag', targets.includes('content') && 'content'].filter(
       Boolean,
     ) as AppBskyActorDefs.MutedWord['targets']
+    const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
+
+    const now = Date.now()
+    const rawDuration = durations.at(0)
+    // undefined evaluates to 'forever'
+    let duration: string | undefined
+
+    if (rawDuration === '24_hours') {
+      duration = new Date(now + ONE_DAY).toISOString()
+    } else if (rawDuration === '7_days') {
+      duration = new Date(now + 7 * ONE_DAY).toISOString()
+    } else if (rawDuration === '30_days') {
+      duration = new Date(now + 30 * ONE_DAY).toISOString()
+    }
 
-    if (!sanitizedValue || !targets.length) {
+    if (!sanitizedValue || !surfaces.length) {
       setField('')
       setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
       return
@@ -70,28 +89,37 @@ function MutedWordsInner() {
 
     try {
       // send raw value and rely on SDK as sanitization source of truth
-      await addMutedWord([{value: field, targets}])
+      await addMutedWord([
+        {
+          value: field,
+          targets: surfaces,
+          actorTarget,
+          expiresAt: duration,
+        },
+      ])
       setField('')
     } catch (e: any) {
       logger.error(`Failed to save muted word`, {message: e.message})
       setError(e.message)
     }
-  }, [_, field, options, addMutedWord, setField])
+  }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
 
   return (
     <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
-      <View onTouchStart={Keyboard.dismiss}>
+      <View>
         <Text
           style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
           <Trans>Add muted words and tags</Trans>
         </Text>
         <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
           <Trans>
-            Posts can be muted based on their text, their tags, or both.
+            Posts can be muted based on their text, their tags, or both. We
+            recommend avoiding common words that appear in many posts, since it
+            can result in no posts being shown.
           </Trans>
         </Text>
 
-        <View style={[a.pb_lg]}>
+        <View style={[a.pb_sm]}>
           <Dialog.Input
             autoCorrect={false}
             autoCapitalize="none"
@@ -107,30 +135,135 @@ function MutedWordsInner() {
             }}
             onSubmitEditing={submit}
           />
+        </View>
 
+        <View style={[a.pb_xl, a.gap_sm]}>
           <Toggle.Group
-            label={_(msg`Toggle between muted word options.`)}
+            label={_(msg`Select how long to mute this word for.`)}
             type="radio"
-            values={options}
-            onChange={setOptions}>
+            values={durations}
+            onChange={setDurations}>
+            <Text
+              style={[
+                a.pb_xs,
+                a.text_sm,
+                a.font_bold,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>Duration:</Trans>
+            </Text>
+
             <View
               style={[
-                a.pt_sm,
-                a.py_sm,
-                a.flex_row,
-                a.align_center,
+                gtMobile && [a.flex_row, a.align_center, a.justify_start],
                 a.gap_sm,
-                a.flex_wrap,
               ]}>
+              <View
+                style={[
+                  a.flex_1,
+                  a.flex_row,
+                  a.justify_start,
+                  a.align_center,
+                  a.gap_sm,
+                ]}>
+                <Toggle.Item
+                  label={_(msg`Mute this word until you unmute it`)}
+                  name="forever"
+                  style={[a.flex_1]}>
+                  <TargetToggle>
+                    <View
+                      style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+                      <Toggle.Radio />
+                      <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                        <Trans>Forever</Trans>
+                      </Toggle.LabelText>
+                    </View>
+                  </TargetToggle>
+                </Toggle.Item>
+
+                <Toggle.Item
+                  label={_(msg`Mute this word for 24 hours`)}
+                  name="24_hours"
+                  style={[a.flex_1]}>
+                  <TargetToggle>
+                    <View
+                      style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+                      <Toggle.Radio />
+                      <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                        <Trans>24 hours</Trans>
+                      </Toggle.LabelText>
+                    </View>
+                  </TargetToggle>
+                </Toggle.Item>
+              </View>
+
+              <View
+                style={[
+                  a.flex_1,
+                  a.flex_row,
+                  a.justify_start,
+                  a.align_center,
+                  a.gap_sm,
+                ]}>
+                <Toggle.Item
+                  label={_(msg`Mute this word for 7 days`)}
+                  name="7_days"
+                  style={[a.flex_1]}>
+                  <TargetToggle>
+                    <View
+                      style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+                      <Toggle.Radio />
+                      <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                        <Trans>7 days</Trans>
+                      </Toggle.LabelText>
+                    </View>
+                  </TargetToggle>
+                </Toggle.Item>
+
+                <Toggle.Item
+                  label={_(msg`Mute this word for 30 days`)}
+                  name="30_days"
+                  style={[a.flex_1]}>
+                  <TargetToggle>
+                    <View
+                      style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+                      <Toggle.Radio />
+                      <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                        <Trans>30 days</Trans>
+                      </Toggle.LabelText>
+                    </View>
+                  </TargetToggle>
+                </Toggle.Item>
+              </View>
+            </View>
+          </Toggle.Group>
+
+          <Toggle.Group
+            label={_(msg`Select what content this mute word should apply to.`)}
+            type="radio"
+            values={targets}
+            onChange={setTargets}>
+            <Text
+              style={[
+                a.pb_xs,
+                a.text_sm,
+                a.font_bold,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>Mute in:</Trans>
+            </Text>
+
+            <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
               <Toggle.Item
                 label={_(msg`Mute this word in post text and tags`)}
                 name="content"
-                style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+                style={[a.flex_1]}>
                 <TargetToggle>
-                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                  <View
+                    style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
                     <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Mute in text & tags</Trans>
+                    <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                      <Trans>Text & tags</Trans>
                     </Toggle.LabelText>
                   </View>
                   <PageText size="sm" />
@@ -140,34 +273,64 @@ function MutedWordsInner() {
               <Toggle.Item
                 label={_(msg`Mute this word in tags only`)}
                 name="tag"
-                style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+                style={[a.flex_1]}>
                 <TargetToggle>
-                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                  <View
+                    style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
                     <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Mute in tags only</Trans>
+                    <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                      <Trans>Tags only</Trans>
                     </Toggle.LabelText>
                   </View>
                   <Hashtag size="sm" />
                 </TargetToggle>
               </Toggle.Item>
-
-              <Button
-                disabled={isPending || !field}
-                label={_(msg`Add mute word for configured settings`)}
-                size="small"
-                color="primary"
-                variant="solid"
-                style={[!gtMobile && [a.w_full, a.flex_0]]}
-                onPress={submit}>
-                <ButtonText>
-                  <Trans>Add</Trans>
-                </ButtonText>
-                <ButtonIcon icon={isPending ? Loader : Plus} />
-              </Button>
             </View>
           </Toggle.Group>
 
+          <View>
+            <Text
+              style={[
+                a.pb_xs,
+                a.text_sm,
+                a.font_bold,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>Options:</Trans>
+            </Text>
+            <Toggle.Item
+              label={_(msg`Do not apply this mute word to users you follow`)}
+              name="exclude_following"
+              style={[a.flex_row, a.justify_between]}
+              value={excludeFollowing}
+              onChange={setExcludeFollowing}>
+              <TargetToggle>
+                <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+                  <Toggle.Checkbox />
+                  <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                    <Trans>Exclude users you follow</Trans>
+                  </Toggle.LabelText>
+                </View>
+              </TargetToggle>
+            </Toggle.Item>
+          </View>
+
+          <View style={[a.pt_xs]}>
+            <Button
+              disabled={isPending || !field}
+              label={_(msg`Add mute word for configured settings`)}
+              size="medium"
+              color="primary"
+              variant="solid"
+              style={[]}
+              onPress={submit}>
+              <ButtonText>
+                <Trans>Add</Trans>
+              </ButtonText>
+              <ButtonIcon icon={isPending ? Loader : Plus} position="right" />
+            </Button>
+          </View>
+
           {error && (
             <View
               style={[
@@ -191,20 +354,6 @@ function MutedWordsInner() {
               </Text>
             </View>
           )}
-
-          <Text
-            style={[
-              a.pt_xs,
-              a.text_sm,
-              a.italic,
-              a.leading_snug,
-              t.atoms.text_contrast_medium,
-            ]}>
-            <Trans>
-              We recommend avoiding common words that appear in many posts,
-              since it can result in no posts being shown.
-            </Trans>
-          </Text>
         </View>
 
         <Divider />
@@ -268,6 +417,9 @@ function MutedWordRow({
   const {_} = useLingui()
   const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
   const control = Prompt.usePromptControl()
+  const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
+  const isExpired = expiryDate && expiryDate < new Date()
+  const formatDistance = useFormatDistance()
 
   const remove = React.useCallback(async () => {
     control.close()
@@ -280,7 +432,7 @@ function MutedWordRow({
         control={control}
         title={_(msg`Are you sure?`)}
         description={_(
-          msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
+          msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
         )}
         onConfirm={remove}
         confirmButtonCta={_(msg`Remove`)}
@@ -289,53 +441,94 @@ function MutedWordRow({
 
       <View
         style={[
-          a.py_md,
-          a.px_lg,
           a.flex_row,
-          a.align_center,
           a.justify_between,
+          a.py_md,
+          a.px_lg,
           a.rounded_md,
           a.gap_md,
           style,
         ]}>
-        <Text
-          style={[
-            a.flex_1,
-            a.leading_snug,
-            a.w_full,
-            a.font_bold,
-            t.atoms.text_contrast_high,
-            web({
-              overflowWrap: 'break-word',
-              wordBreak: 'break-word',
-            }),
-          ]}>
-          {word.value}
-        </Text>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+            <Text
+              style={[
+                a.flex_1,
+                a.leading_snug,
+                a.font_bold,
+                web({
+                  overflowWrap: 'break-word',
+                  wordBreak: 'break-word',
+                }),
+              ]}>
+              {word.targets.find(t => t === 'content') ? (
+                <Trans comment="Pattern: {wordValue} in text, tags">
+                  {word.value}{' '}
+                  <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
+                    in{' '}
+                    <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+                      text & tags
+                    </Text>
+                  </Text>
+                </Trans>
+              ) : (
+                <Trans comment="Pattern: {wordValue} in tags">
+                  {word.value}{' '}
+                  <Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
+                    in{' '}
+                    <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+                      tags
+                    </Text>
+                  </Text>
+                </Trans>
+              )}
+            </Text>
+          </View>
 
-        <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
-          {word.targets.map(target => (
-            <View
-              key={target}
-              style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
+          {(expiryDate || word.actorTarget === 'exclude-following') && (
+            <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
               <Text
-                style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
-                {target === 'content' ? _(msg`text`) : _(msg`tag`)}
+                style={[
+                  a.flex_1,
+                  a.text_xs,
+                  a.leading_snug,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                {expiryDate && (
+                  <>
+                    {isExpired ? (
+                      <Trans>Expired</Trans>
+                    ) : (
+                      <Trans>
+                        Expires{' '}
+                        {formatDistance(expiryDate, new Date(), {
+                          addSuffix: true,
+                        })}
+                      </Trans>
+                    )}
+                  </>
+                )}
+                {word.actorTarget === 'exclude-following' && (
+                  <>
+                    {' • '}
+                    <Trans>Excludes users you follow</Trans>
+                  </>
+                )}
               </Text>
             </View>
-          ))}
-
-          <Button
-            label={_(msg`Remove mute word from your list`)}
-            size="tiny"
-            shape="round"
-            variant="ghost"
-            color="secondary"
-            onPress={() => control.open()}
-            style={[a.ml_sm]}>
-            <ButtonIcon icon={isPending ? Loader : X} />
-          </Button>
+          )}
         </View>
+
+        <Button
+          label={_(msg`Remove mute word from your list`)}
+          size="tiny"
+          shape="round"
+          variant="outline"
+          color="secondary"
+          onPress={() => control.open()}
+          style={[a.ml_sm]}>
+          <ButtonIcon icon={isPending ? Loader : X} />
+        </Button>
       </View>
     </>
   )
diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts
new file mode 100644
index 000000000..b0f94133b
--- /dev/null
+++ b/src/components/hooks/dates.ts
@@ -0,0 +1,69 @@
+/**
+ * Hooks for date-fns localized formatters.
+ *
+ * Our app supports some languages that are not included in date-fns by
+ * default, in which case it will fall back to English.
+ *
+ * {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
+ */
+
+import React from 'react'
+import {formatDistance, Locale} from 'date-fns'
+import {
+  ca,
+  de,
+  es,
+  fi,
+  fr,
+  hi,
+  id,
+  it,
+  ja,
+  ko,
+  ptBR,
+  tr,
+  uk,
+  zhCN,
+  zhTW,
+} from 'date-fns/locale'
+
+import {AppLanguage} from '#/locale/languages'
+import {useLanguagePrefs} from '#/state/preferences'
+
+/**
+ * {@link AppLanguage}
+ */
+const locales: Record<AppLanguage, Locale | undefined> = {
+  en: undefined,
+  ca,
+  de,
+  es,
+  fi,
+  fr,
+  ga: undefined,
+  hi,
+  id,
+  it,
+  ja,
+  ko,
+  ['pt-BR']: ptBR,
+  tr,
+  uk,
+  ['zh-CN']: zhCN,
+  ['zh-TW']: zhTW,
+}
+
+/**
+ * Returns a localized `formatDistance` function.
+ * {@link formatDistance}
+ */
+export function useFormatDistance() {
+  const {appLanguage} = useLanguagePrefs()
+  return React.useCallback<typeof formatDistance>(
+    (date, baseDate, options) => {
+      const locale = locales[appLanguage as AppLanguage]
+      return formatDistance(date, baseDate, {...options, locale: locale})
+    },
+    [appLanguage],
+  )
+}
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 9bb57fcaf..6991f8647 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() {
   })
 }
 
+export function useRemoveMutedWordsMutation() {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
+      await agent.removeMutedWords(mutedWords)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
 export function useQueueNudgesMutation() {
   const queryClient = useQueryClient()
   const agent = useAgent()