From b0e130a4d85f2056bddcbf210aa7ea4068d41686 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Thu, 1 Aug 2024 10:29:27 -0500
Subject: Update muted words dialog with `expiresAt` and `actorTarget` (#4801)

* WIP not working dropdown

* Update MutedWords dialog

* Add i18n formatDistance

* Comments

* Handle text wrapping

* Update label copy

Co-authored-by: Hailey <me@haileyok.com>

* Fix alignment

* Improve translation output

* Revert toggle changes

* Better types for useFormatDistance

* Tweaks

* Integrate new sdk version into TagMenu

* Use ampersand

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Bump SDK

---------

Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
---
 src/components/dialogs/MutedWords.tsx | 373 ++++++++++++++++++++++++++--------
 1 file changed, 283 insertions(+), 90 deletions(-)

(limited to 'src/components/dialogs/MutedWords.tsx')

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>
     </>
   )
-- 
cgit 1.4.1

