about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Dialog/index.web.tsx2
-rw-r--r--src/components/RichText.tsx103
-rw-r--r--src/components/TagMenu/index.tsx279
-rw-r--r--src/components/TagMenu/index.web.tsx127
-rw-r--r--src/components/dialogs/Context.tsx29
-rw-r--r--src/components/dialogs/MutedWords.tsx328
-rw-r--r--src/components/forms/TextField.tsx2
-rw-r--r--src/components/forms/Toggle.tsx34
-rw-r--r--src/components/icons/Check.tsx4
-rw-r--r--src/components/icons/Clipboard.tsx5
-rw-r--r--src/components/icons/Group3.tsx2
-rw-r--r--src/components/icons/MagnifyingGlass2.tsx5
-rw-r--r--src/components/icons/Mute.tsx5
-rw-r--r--src/components/icons/PageText.tsx5
-rw-r--r--src/components/icons/Person.tsx5
15 files changed, 907 insertions, 28 deletions
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 79441fb5e..fa29fbd6c 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -188,7 +188,7 @@ export function Close() {
       <Button
         size="small"
         variant="ghost"
-        color="primary"
+        color="secondary"
         shape="round"
         onPress={close}
         label={_(msg`Close active dialog`)}>
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index c72fcabdd..22391cb24 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -1,11 +1,16 @@
 import React from 'react'
 import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
-import {atoms as a, TextStyleProp, flatten} from '#/alf'
+import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
 import {InlineLink} from '#/components/Link'
 import {Text, TextProps} from '#/components/Typography'
 import {toShortUrl} from 'lib/strings/url-helpers'
 import {getAgent} from '#/state/session'
+import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
+import {isNative} from '#/platform/detection'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
 
 const WORD_WRAP = {wordWrap: 1}
 
@@ -17,6 +22,8 @@ export function RichText({
   disableLinks,
   resolveFacets = false,
   selectable,
+  enableTags = false,
+  authorHandle,
 }: TextStyleProp &
   Pick<TextProps, 'selectable'> & {
     value: RichTextAPI | string
@@ -24,6 +31,8 @@ export function RichText({
     numberOfLines?: number
     disableLinks?: boolean
     resolveFacets?: boolean
+    enableTags?: boolean
+    authorHandle?: string
   }) {
   const detected = React.useRef(false)
   const [richText, setRichText] = React.useState<RichTextAPI>(() =>
@@ -85,6 +94,7 @@ export function RichText({
   for (const segment of richText.segments()) {
     const link = segment.link
     const mention = segment.mention
+    const tag = segment.tag
     if (
       mention &&
       AppBskyRichtextFacet.validateMention(mention).success &&
@@ -118,6 +128,21 @@ export function RichText({
           </InlineLink>,
         )
       }
+    } else if (
+      !disableLinks &&
+      enableTags &&
+      tag &&
+      AppBskyRichtextFacet.validateTag(tag).success
+    ) {
+      els.push(
+        <RichTextTag
+          key={key}
+          text={segment.text}
+          style={styles}
+          selectable={selectable}
+          authorHandle={authorHandle}
+        />,
+      )
     } else {
       els.push(segment.text)
     }
@@ -136,3 +161,79 @@ export function RichText({
     </Text>
   )
 }
+
+function RichTextTag({
+  text: tag,
+  style,
+  selectable,
+  authorHandle,
+}: {
+  text: string
+  selectable?: boolean
+  authorHandle?: string
+} & TextStyleProp) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const control = useTagMenuControl()
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  const open = React.useCallback(() => {
+    control.open()
+  }, [control])
+
+  /*
+   * N.B. On web, this is wrapped in another pressable comopnent with a11y
+   * labels, etc. That's why only some of these props are applied here.
+   */
+
+  return (
+    <React.Fragment>
+      <TagMenu control={control} tag={tag} authorHandle={authorHandle}>
+        <Text
+          selectable={selectable}
+          {...native({
+            accessibilityLabel: _(msg`Hashtag: ${tag}`),
+            accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
+            accessibilityRole: isNative ? 'button' : undefined,
+            onPress: open,
+            onPressIn: onPressIn,
+            onPressOut: onPressOut,
+          })}
+          {...web({
+            onMouseEnter: onHoverIn,
+            onMouseLeave: onHoverOut,
+          })}
+          // @ts-ignore
+          onFocus={onFocus}
+          onBlur={onBlur}
+          style={[
+            style,
+            {
+              pointerEvents: 'auto',
+              color: t.palette.primary_500,
+            },
+            web({
+              cursor: 'pointer',
+            }),
+            (hovered || focused || pressed) && {
+              ...web({outline: 0}),
+              textDecorationLine: 'underline',
+              textDecorationColor: t.palette.primary_500,
+            },
+          ]}>
+          {tag}
+        </Text>
+      </TagMenu>
+    </React.Fragment>
+  )
+}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
new file mode 100644
index 000000000..2fec7a188
--- /dev/null
+++ b/src/components/TagMenu/index.tsx
@@ -0,0 +1,279 @@
+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 {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 {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+import {Loader} from '#/components/Loader'
+import {isInvalidHandle} from '#/lib/strings/handles'
+
+export function useTagMenuControl() {
+  return Dialog.useDialogControl()
+}
+
+export function TagMenu({
+  children,
+  control,
+  tag,
+  authorHandle,
+}: React.PropsWithChildren<{
+  control: Dialog.DialogOuterProps['control']
+  tag: string
+  authorHandle?: string
+}>) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const {isLoading: isPreferencesLoading, data: preferences} =
+    usePreferencesQuery()
+  const {
+    mutateAsync: upsertMutedWord,
+    variables: optimisticUpsert,
+    reset: resetUpsert,
+  } = useUpsertMutedWordsMutation()
+  const {
+    mutateAsync: removeMutedWord,
+    variables: optimisticRemove,
+    reset: resetRemove,
+  } = useRemoveMutedWordMutation()
+
+  const sanitizedTag = tag.replace(/^#/, '')
+  const isMuted = Boolean(
+    (preferences?.mutedWords?.find(
+      m => m.value === sanitizedTag && m.targets.includes('tag'),
+    ) ??
+      optimisticUpsert?.find(
+        m => m.value === sanitizedTag && m.targets.includes('tag'),
+      )) &&
+      !(optimisticRemove?.value === sanitizedTag),
+  )
+
+  return (
+    <>
+      {children}
+
+      <Dialog.Outer control={control}>
+        <Dialog.Handle />
+
+        <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
+          {isPreferencesLoading ? (
+            <View style={[a.w_full, a.align_center]}>
+              <Loader size="lg" />
+            </View>
+          ) : (
+            <>
+              <View
+                style={[
+                  a.rounded_md,
+                  a.border,
+                  a.mb_md,
+                  t.atoms.border_contrast_low,
+                  t.atoms.bg_contrast_25,
+                ]}>
+                <Link
+                  label={_(msg`Search for all posts with tag ${tag}`)}
+                  to={makeSearchLink({query: tag})}
+                  onPress={e => {
+                    e.preventDefault()
+
+                    control.close(() => {
+                      // @ts-ignore :ron_swanson: "I know more than you"
+                      navigation.navigate('SearchTab', {
+                        screen: 'Search',
+                        params: {
+                          q: tag,
+                        },
+                      })
+                    })
+
+                    return false
+                  }}>
+                  <View
+                    style={[
+                      a.w_full,
+                      a.flex_row,
+                      a.align_center,
+                      a.justify_start,
+                      a.gap_md,
+                      a.px_lg,
+                      a.py_md,
+                    ]}>
+                    <Search size="lg" style={[t.atoms.text_contrast_medium]} />
+                    <Text
+                      numberOfLines={1}
+                      ellipsizeMode="middle"
+                      style={[
+                        a.flex_1,
+                        a.text_md,
+                        a.font_bold,
+                        native({top: 2}),
+                        t.atoms.text_contrast_medium,
+                      ]}>
+                      <Trans>
+                        See{' '}
+                        <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                          {tag}
+                        </Text>{' '}
+                        posts
+                      </Trans>
+                    </Text>
+                  </View>
+                </Link>
+
+                {authorHandle && !isInvalidHandle(authorHandle) && (
+                  <>
+                    <Divider />
+
+                    <Link
+                      label={_(
+                        msg`Search for all posts by @${authorHandle} with tag ${tag}`,
+                      )}
+                      to={makeSearchLink({query: tag, from: authorHandle})}
+                      onPress={e => {
+                        e.preventDefault()
+
+                        control.close(() => {
+                          // @ts-ignore :ron_swanson: "I know more than you"
+                          navigation.navigate('SearchTab', {
+                            screen: 'Search',
+                            params: {
+                              q:
+                                tag +
+                                (authorHandle ? ` from:${authorHandle}` : ''),
+                            },
+                          })
+                        })
+
+                        return false
+                      }}>
+                      <View
+                        style={[
+                          a.w_full,
+                          a.flex_row,
+                          a.align_center,
+                          a.justify_start,
+                          a.gap_md,
+                          a.px_lg,
+                          a.py_md,
+                        ]}>
+                        <Person
+                          size="lg"
+                          style={[t.atoms.text_contrast_medium]}
+                        />
+                        <Text
+                          numberOfLines={1}
+                          ellipsizeMode="middle"
+                          style={[
+                            a.flex_1,
+                            a.text_md,
+                            a.font_bold,
+                            native({top: 2}),
+                            t.atoms.text_contrast_medium,
+                          ]}>
+                          <Trans>
+                            See{' '}
+                            <Text
+                              style={[a.text_md, a.font_bold, t.atoms.text]}>
+                              {tag}
+                            </Text>{' '}
+                            posts by this user
+                          </Trans>
+                        </Text>
+                      </View>
+                    </Link>
+                  </>
+                )}
+
+                {preferences ? (
+                  <>
+                    <Divider />
+
+                    <Button
+                      label={
+                        isMuted
+                          ? _(msg`Unmute all ${tag} posts`)
+                          : _(msg`Mute all ${tag} posts`)
+                      }
+                      onPress={() => {
+                        control.close(() => {
+                          if (isMuted) {
+                            resetUpsert()
+                            removeMutedWord({
+                              value: sanitizedTag,
+                              targets: ['tag'],
+                            })
+                          } else {
+                            resetRemove()
+                            upsertMutedWord([
+                              {value: sanitizedTag, targets: ['tag']},
+                            ])
+                          }
+                        })
+                      }}>
+                      <View
+                        style={[
+                          a.w_full,
+                          a.flex_row,
+                          a.align_center,
+                          a.justify_start,
+                          a.gap_md,
+                          a.px_lg,
+                          a.py_md,
+                        ]}>
+                        <Mute
+                          size="lg"
+                          style={[t.atoms.text_contrast_medium]}
+                        />
+                        <Text
+                          numberOfLines={1}
+                          ellipsizeMode="middle"
+                          style={[
+                            a.flex_1,
+                            a.text_md,
+                            a.font_bold,
+                            native({top: 2}),
+                            t.atoms.text_contrast_medium,
+                          ]}>
+                          {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
+                          <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                            {tag}
+                          </Text>{' '}
+                          <Trans>posts</Trans>
+                        </Text>
+                      </View>
+                    </Button>
+                  </>
+                ) : null}
+              </View>
+
+              <Button
+                label={_(msg`Close this dialog`)}
+                size="small"
+                variant="ghost"
+                color="secondary"
+                onPress={() => control.close()}>
+                <ButtonText>Cancel</ButtonText>
+              </Button>
+            </>
+          )}
+        </Dialog.Inner>
+      </Dialog.Outer>
+    </>
+  )
+}
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
new file mode 100644
index 000000000..930e47a1a
--- /dev/null
+++ b/src/components/TagMenu/index.web.tsx
@@ -0,0 +1,127 @@
+import React from 'react'
+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 {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+
+export function useTagMenuControl() {}
+
+export function TagMenu({
+  children,
+  tag,
+  authorHandle,
+}: React.PropsWithChildren<{
+  tag: string
+  authorHandle?: string
+}>) {
+  const sanitizedTag = tag.replace(/^#/, '')
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+  const {data: preferences} = usePreferencesQuery()
+  const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
+    useUpsertMutedWordsMutation()
+  const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
+    useRemoveMutedWordMutation()
+  const isMuted = Boolean(
+    (preferences?.mutedWords?.find(
+      m => m.value === sanitizedTag && m.targets.includes('tag'),
+    ) ??
+      optimisticUpsert?.find(
+        m => m.value === sanitizedTag && m.targets.includes('tag'),
+      )) &&
+      !(optimisticRemove?.value === sanitizedTag),
+  )
+
+  const dropdownItems = React.useMemo(() => {
+    return [
+      {
+        label: _(msg`See ${tag} posts`),
+        onPress() {
+          navigation.navigate('Search', {
+            q: tag,
+          })
+        },
+        testID: 'tagMenuSearch',
+        icon: {
+          ios: {
+            name: 'magnifyingglass',
+          },
+          android: '',
+          web: 'magnifying-glass',
+        },
+      },
+      authorHandle &&
+        !isInvalidHandle(authorHandle) && {
+          label: _(msg`See ${tag} posts by this user`),
+          onPress() {
+            navigation.navigate({
+              name: 'Search',
+              params: {
+                q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
+              },
+            })
+          },
+          testID: 'tagMenuSeachByUser',
+          icon: {
+            ios: {
+              name: 'magnifyingglass',
+            },
+            android: '',
+            web: ['far', 'user'],
+          },
+        },
+      preferences && {
+        label: 'separator',
+      },
+      preferences && {
+        label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`),
+        onPress() {
+          if (isMuted) {
+            removeMutedWord({value: sanitizedTag, targets: ['tag']})
+          } else {
+            upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
+          }
+        },
+        testID: 'tagMenuMute',
+        icon: {
+          ios: {
+            name: 'speaker.slash',
+          },
+          android: 'ic_menu_sort_alphabetically',
+          web: isMuted ? 'eye' : ['far', 'eye-slash'],
+        },
+      },
+    ].filter(Boolean)
+  }, [
+    _,
+    authorHandle,
+    isMuted,
+    navigation,
+    preferences,
+    tag,
+    sanitizedTag,
+    upsertMutedWord,
+    removeMutedWord,
+  ])
+
+  return (
+    <EventStopper>
+      <NativeDropdown
+        accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
+        accessibilityHint=""
+        // @ts-ignore
+        items={dropdownItems}>
+        {children}
+      </NativeDropdown>
+    </EventStopper>
+  )
+}
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx
new file mode 100644
index 000000000..d86c90a92
--- /dev/null
+++ b/src/components/dialogs/Context.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+
+import * as Dialog from '#/components/Dialog'
+
+type Control = Dialog.DialogOuterProps['control']
+
+type ControlsContext = {
+  mutedWordsDialogControl: Control
+}
+
+const ControlsContext = React.createContext({
+  mutedWordsDialogControl: {} as Control,
+})
+
+export function useGlobalDialogsControlContext() {
+  return React.useContext(ControlsContext)
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const mutedWordsDialogControl = Dialog.useDialogControl()
+  const ctx = React.useMemo(
+    () => ({mutedWordsDialogControl}),
+    [mutedWordsDialogControl],
+  )
+
+  return (
+    <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
+  )
+}
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
new file mode 100644
index 000000000..138cc5330
--- /dev/null
+++ b/src/components/dialogs/MutedWords.tsx
@@ -0,0 +1,328 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyActorDefs} from '@atproto/api'
+
+import {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+import {isNative} from '#/platform/detection'
+import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
+import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
+import {Divider} from '#/components/Divider'
+import {Loader} from '#/components/Loader'
+import {logger} from '#/logger'
+import * as Dialog from '#/components/Dialog'
+import * as Toggle from '#/components/forms/Toggle'
+import * as Prompt from '#/components/Prompt'
+
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+
+export function MutedWordsDialog() {
+  const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <MutedWordsInner control={control} />
+    </Dialog.Outer>
+  )
+}
+
+function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const {
+    isLoading: isPreferencesLoading,
+    data: preferences,
+    error: preferencesError,
+  } = usePreferencesQuery()
+  const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
+  const [field, setField] = React.useState('')
+  const [options, setOptions] = React.useState(['content'])
+  const [_error, setError] = React.useState('')
+
+  const submit = React.useCallback(async () => {
+    const value = field.trim()
+    const targets = ['tag', options.includes('content') && 'content'].filter(
+      Boolean,
+    ) as AppBskyActorDefs.MutedWord['targets']
+
+    if (!value || !targets.length) return
+
+    try {
+      await addMutedWord([{value, targets}])
+      setField('')
+    } catch (e: any) {
+      logger.error(`Failed to save muted word`, {message: e.message})
+      setError(e.message)
+    }
+  }, [field, options, addMutedWord, setField])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
+      <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.
+        </Trans>
+      </Text>
+
+      <View style={[a.pb_lg]}>
+        <Dialog.Input
+          autoCorrect={false}
+          autoCapitalize="none"
+          autoComplete="off"
+          label={_(msg`Enter a word or tag`)}
+          placeholder={_(msg`Enter a word or tag`)}
+          value={field}
+          onChangeText={setField}
+          onSubmitEditing={submit}
+        />
+
+        <Toggle.Group
+          label={_(msg`Toggle between muted word options.`)}
+          type="radio"
+          values={options}
+          onChange={setOptions}>
+          <View
+            style={[
+              a.pt_sm,
+              a.pb_md,
+              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]]}>
+              <TargetToggle>
+                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                  <Toggle.Radio />
+                  <Toggle.Label>
+                    <Trans>Mute in text & tags</Trans>
+                  </Toggle.Label>
+                </View>
+                <PageText size="sm" />
+              </TargetToggle>
+            </Toggle.Item>
+
+            <Toggle.Item
+              label={_(msg`Mute this word in tags only`)}
+              name="tag"
+              style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+              <TargetToggle>
+                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                  <Toggle.Radio />
+                  <Toggle.Label>
+                    <Trans>Mute in tags only</Trans>
+                  </Toggle.Label>
+                </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>
+
+        <Text
+          style={[
+            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 />
+
+      <View style={[a.pt_2xl]}>
+        <Text
+          style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
+          <Trans>Your muted words</Trans>
+        </Text>
+
+        {isPreferencesLoading ? (
+          <Loader />
+        ) : preferencesError || !preferences ? (
+          <View
+            style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
+            <Text style={[a.italic, t.atoms.text_contrast_high]}>
+              <Trans>
+                We're sorry, but we weren't able to load your muted words at
+                this time. Please try again.
+              </Trans>
+            </Text>
+          </View>
+        ) : preferences.mutedWords.length ? (
+          [...preferences.mutedWords]
+            .reverse()
+            .map((word, i) => (
+              <MutedWordRow
+                key={word.value + i}
+                word={word}
+                style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
+              />
+            ))
+        ) : (
+          <View
+            style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
+            <Text style={[a.italic, t.atoms.text_contrast_high]}>
+              <Trans>You haven't muted any words or tags yet</Trans>
+            </Text>
+          </View>
+        )}
+      </View>
+
+      {isNative && <View style={{height: 20}} />}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+function MutedWordRow({
+  style,
+  word,
+}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
+  const control = Prompt.usePromptControl()
+
+  const remove = React.useCallback(async () => {
+    control.close()
+    removeMutedWord(word)
+  }, [removeMutedWord, word, control])
+
+  return (
+    <>
+      <Prompt.Outer control={control}>
+        <Prompt.Title>
+          <Trans>Are you sure?</Trans>
+        </Prompt.Title>
+        <Prompt.Description>
+          <Trans>
+            This will delete {word.value} from your muted words. You can always
+            add it back later.
+          </Trans>
+        </Prompt.Description>
+        <Prompt.Actions>
+          <Prompt.Cancel>
+            <ButtonText>
+              <Trans>Nevermind</Trans>
+            </ButtonText>
+          </Prompt.Cancel>
+          <Prompt.Action onPress={remove}>
+            <ButtonText>
+              <Trans>Remove</Trans>
+            </ButtonText>
+          </Prompt.Action>
+        </Prompt.Actions>
+      </Prompt.Outer>
+
+      <View
+        style={[
+          a.py_md,
+          a.px_lg,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.rounded_md,
+          style,
+        ]}>
+        <Text style={[a.font_bold, t.atoms.text_contrast_high]}>
+          {word.value}
+        </Text>
+
+        <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]}>
+              <Text
+                style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
+                {target === 'content' ? _(msg`text`) : _(msg`tag`)}
+              </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>
+      </View>
+    </>
+  )
+}
+
+function TargetToggle({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+  const {gtMobile} = useBreakpoints()
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.gap_xs,
+        a.flex_1,
+        a.py_sm,
+        a.px_sm,
+        gtMobile && a.px_md,
+        a.rounded_sm,
+        t.atoms.bg_contrast_50,
+        (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
+        ctx.selected && [
+          {
+            backgroundColor:
+              t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
+          },
+        ],
+        ctx.disabled && {
+          opacity: 0.8,
+        },
+      ]}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index ebf2e4750..a781bdd18 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
   return (
     <Context.Provider value={context}>
       <View
-        style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
+        style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
         {...web({
           onClick: () => inputRef.current?.focus(),
           onMouseOver: onHoverIn,
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 9369423f2..140740f70 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
 import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
 
 export type ItemState = {
   name: string
@@ -331,15 +332,14 @@ export function createSharedToggleStyles({
 export function Checkbox() {
   const t = useTheme()
   const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
-  const {baseStyles, baseHoverStyles, indicatorStyles} =
-    createSharedToggleStyles({
-      theme: t,
-      hovered,
-      focused,
-      selected,
-      disabled,
-      isInvalid,
-    })
+  const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
+    theme: t,
+    hovered,
+    focused,
+    selected,
+    disabled,
+    isInvalid,
+  })
   return (
     <View
       style={[
@@ -355,21 +355,7 @@ export function Checkbox() {
         baseStyles,
         hovered || focused ? baseHoverStyles : {},
       ]}>
-      {selected ? (
-        <View
-          style={[
-            a.absolute,
-            a.rounded_2xs,
-            {height: 12, width: 12},
-            selected
-              ? {
-                  backgroundColor: t.palette.primary_500,
-                }
-              : {},
-            indicatorStyles,
-          ]}
-        />
-      ) : null}
+      {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
     </View>
   )
 }
diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx
index 24316c784..fe9883baf 100644
--- a/src/components/icons/Check.tsx
+++ b/src/components/icons/Check.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
 export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
 })
+
+export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
+})
diff --git a/src/components/icons/Clipboard.tsx b/src/components/icons/Clipboard.tsx
new file mode 100644
index 000000000..0135992b4
--- /dev/null
+++ b/src/components/icons/Clipboard.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
+})
diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx
index 2bb16ba87..9e5ab8893 100644
--- a/src/components/icons/Group3.tsx
+++ b/src/components/icons/Group3.tsx
@@ -1,5 +1,5 @@
 import {createSinglePathSVG} from './TEMPLATE'
 
 export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
-  path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
+  path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
 })
diff --git a/src/components/icons/MagnifyingGlass2.tsx b/src/components/icons/MagnifyingGlass2.tsx
new file mode 100644
index 000000000..3ca403400
--- /dev/null
+++ b/src/components/icons/MagnifyingGlass2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
+})
diff --git a/src/components/icons/Mute.tsx b/src/components/icons/Mute.tsx
new file mode 100644
index 000000000..006570787
--- /dev/null
+++ b/src/components/icons/Mute.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
+})
diff --git a/src/components/icons/PageText.tsx b/src/components/icons/PageText.tsx
new file mode 100644
index 000000000..25fbde339
--- /dev/null
+++ b/src/components/icons/PageText.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
+})
diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx
new file mode 100644
index 000000000..6d09148c9
--- /dev/null
+++ b/src/components/icons/Person.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
+})