about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-02-26 22:33:48 -0600
committerGitHub <noreply@github.com>2024-02-26 20:33:48 -0800
commit58aaad704aa971c5ebbf5a5f330a2e2129b557f6 (patch)
tree74a448e61e83ca9292b0c6bf8d638bcfabd11eec
parentc8582924e2421e5383050c4f60a80d2e74287c07 (diff)
downloadvoidsky-58aaad704aa971c5ebbf5a5f330a2e2129b557f6.tar.zst
Add tags and mute words (#2968)
* Add bare minimum hashtags support (#2804)

* Add bare minimum hashtags support

As atproto/api already parses hashtags, this is as simple as hooking it
up like link segments.

This is "bare minimum" because:

- Opening hashtag "#foo" is actually just a search for "foo" right now
  to work around #2491.
- There is no integration in the composer. This hasn't stopped people
  from using hashtags already, and can be added later.
- This change itself only had to hook things up - thank you for having
  already put the hashtag parsing in place.

* Remove workaround for hash search not working now that it's fixed

* Add RichTextTag and TagMenu

* Sketch

* Remove hackfix

* Some cleanup

* Sketch web

* Mobile design

* Mobile handling of tags search

* Web only

* Fix navigation woes

* Use new callback

* Hook it up

* Integrate muted tags

* Fix dropdown styles

* Type error

* Use close callback

* Fix styles

* Cleanup, install latest sdk

* Quick muted words screen

* Targets

* Dir structure

* Icons, list view

* Move to dialog

* Add removal confirmation

* Swap copy

* Improve checkboxees

* Update matching, add tests

* Moderate embeds

* Create global dialogs concept again to prevent flashing

* Add access from moderation screen

* Highlight tags on native

* Add web highlighting

* Add close to web modal

* Adjust close color

* Rename toggles and adjust logic

* Icon update

* Load states

* Improve regex

* Improve regex

* Improve regex

* Revert link test

* Hyphenated words

* Improve matching

* Enhance

* Some tweaks

* Muted words modal changes

* Handle invalid handles, handle long tags

* Remove main regex

* Better test

* Space/punct check drop to includes

* Lowercase post text before comparison

* Add better real world test case

---------

Co-authored-by: Kisaragi Hiu <mail@kisaragi-hiu.com>
-rw-r--r--assets/icons/checkThick_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/clipboard_stroke2_corner2_rounded.svg1
-rw-r--r--assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/mute_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/pageText_stroke2_corner0_rounded.svg1
-rw-r--r--bskyweb/templates/base.html5
-rw-r--r--package.json2
-rw-r--r--src/Navigation.tsx3
-rw-r--r--src/alf/atoms.ts4
-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
-rw-r--r--src/lib/__tests__/moderatePost_wrapped.test.ts578
-rw-r--r--src/lib/moderatePost_wrapped.ts156
-rw-r--r--src/lib/moderation.ts7
-rw-r--r--src/lib/routes/links.ts10
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/state/dialogs/index.tsx3
-rw-r--r--src/state/queries/preferences/const.ts2
-rw-r--r--src/state/queries/preferences/index.ts49
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx2
-rw-r--r--src/view/com/composer/text-input/web/TagDecorator.ts83
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx6
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx5
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx16
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
-rw-r--r--src/view/com/util/text/RichText.tsx66
-rw-r--r--src/view/icons/index.tsx2
-rw-r--r--src/view/screens/Moderation.tsx23
-rw-r--r--src/view/screens/Search/Search.tsx27
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx2
-rw-r--r--web/index.html5
-rw-r--r--yarn.lock14
49 files changed, 1983 insertions, 39 deletions
diff --git a/assets/icons/checkThick_stroke2_corner0_rounded.svg b/assets/icons/checkThick_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..54af3e859
--- /dev/null
+++ b/assets/icons/checkThick_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/clipboard_stroke2_corner2_rounded.svg b/assets/icons/clipboard_stroke2_corner2_rounded.svg
new file mode 100644
index 000000000..f403cfb92
--- /dev/null
+++ b/assets/icons/clipboard_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg b/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..2759aaf2c
--- /dev/null
+++ b/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/mute_stroke2_corner0_rounded.svg b/assets/icons/mute_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..8ebecb392
--- /dev/null
+++ b/assets/icons/mute_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/pageText_stroke2_corner0_rounded.svg b/assets/icons/pageText_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..826a36cd7
--- /dev/null
+++ b/assets/icons/pageText_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index e29e4032b..50fb9a2fd 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -205,6 +205,11 @@
     [data-tooltip]:hover::before {
       display:block;
     }
+
+    /* NativeDropdown component */
+    .nativeDropdown-item:focus {
+      outline: none;
+    }
   </style>
   {% include "scripts.html" %}
   <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
diff --git a/package.json b/package.json
index 3f007a238..3d1516034 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
     "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
   },
   "dependencies": {
-    "@atproto/api": "^0.9.5",
+    "@atproto/api": "^0.10.0",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@emoji-mart/react": "^1.1.1",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 6ca4212e2..dfbe816f4 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -497,7 +497,8 @@ const LINKING = {
         },
       ])
     } else {
-      return buildStateObject('Flat', name, params)
+      const res = buildStateObject('Flat', name, params)
+      return res
     }
   },
 }
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 18f492d6e..fff3a4d8b 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1,3 +1,4 @@
+import {web, native} from '#/alf/util/platform'
 import * as tokens from '#/alf/tokens'
 
 export const atoms = {
@@ -113,6 +114,9 @@ export const atoms = {
   flex_wrap: {
     flexWrap: 'wrap',
   },
+  flex_0: {
+    flex: web('0 0 auto') || (native(0) as number),
+  },
   flex_1: {
     flex: 1,
   },
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',
+})
diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts
new file mode 100644
index 000000000..1d907963f
--- /dev/null
+++ b/src/lib/__tests__/moderatePost_wrapped.test.ts
@@ -0,0 +1,578 @@
+import {describe, it, expect} from '@jest/globals'
+import {RichText} from '@atproto/api'
+
+import {hasMutedWord} from '../moderatePost_wrapped'
+
+describe(`hasMutedWord`, () => {
+  describe(`tags`, () => {
+    it(`match: outline tag`, () => {
+      const rt = new RichText({
+        text: `This is a post #inlineTag`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'outlineTag', targets: ['tag']}],
+        rt.text,
+        rt.facets,
+        ['outlineTag'],
+      )
+
+      expect(match).toBe(true)
+    })
+
+    it(`match: inline tag`, () => {
+      const rt = new RichText({
+        text: `This is a post #inlineTag`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'inlineTag', targets: ['tag']}],
+        rt.text,
+        rt.facets,
+        ['outlineTag'],
+      )
+
+      expect(match).toBe(true)
+    })
+
+    it(`match: content target matches inline tag`, () => {
+      const rt = new RichText({
+        text: `This is a post #inlineTag`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'inlineTag', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        ['outlineTag'],
+      )
+
+      expect(match).toBe(true)
+    })
+
+    it(`no match: only tag targets`, () => {
+      const rt = new RichText({
+        text: `This is a post`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'inlineTag', targets: ['tag']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(false)
+    })
+  })
+
+  describe(`early exits`, () => {
+    it(`match: single character 希`, () => {
+      /**
+       * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
+       */
+      const rt = new RichText({
+        text: `改善希望です`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: '希', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(true)
+    })
+
+    it(`no match: long muted word, short post`, () => {
+      const rt = new RichText({
+        text: `hey`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'politics', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(false)
+    })
+
+    it(`match: exact text`, () => {
+      const rt = new RichText({
+        text: `javascript`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'javascript', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(true)
+    })
+  })
+
+  describe(`general content`, () => {
+    it(`match: word within post`, () => {
+      const rt = new RichText({
+        text: `This is a post about javascript`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'javascript', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(true)
+    })
+
+    it(`no match: partial word`, () => {
+      const rt = new RichText({
+        text: `Use your brain, Eric`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'ai', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(false)
+    })
+
+    it(`match: multiline`, () => {
+      const rt = new RichText({
+        text: `Use your\n\tbrain, Eric`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: 'brain', targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(true)
+    })
+
+    it(`match: :)`, () => {
+      const rt = new RichText({
+        text: `So happy :)`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      const match = hasMutedWord(
+        [{value: `:)`, targets: ['content']}],
+        rt.text,
+        rt.facets,
+        [],
+      )
+
+      expect(match).toBe(true)
+    })
+  })
+
+  describe(`punctuation semi-fuzzy`, () => {
+    describe(`yay!`, () => {
+      const rt = new RichText({
+        text: `We're federating, yay!`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: yay!`, () => {
+        const match = hasMutedWord(
+          [{value: 'yay!', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: yay`, () => {
+        const match = hasMutedWord(
+          [{value: 'yay', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`y!ppee!!`, () => {
+      const rt = new RichText({
+        text: `We're federating, y!ppee!!`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: y!ppee`, () => {
+        const match = hasMutedWord(
+          [{value: 'y!ppee', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      // single exclamation point, source has double
+      it(`no match: y!ppee!`, () => {
+        const match = hasMutedWord(
+          [{value: 'y!ppee!', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`Why so S@assy?`, () => {
+      const rt = new RichText({
+        text: `Why so S@assy?`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: S@assy`, () => {
+        const match = hasMutedWord(
+          [{value: 'S@assy', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: s@assy`, () => {
+        const match = hasMutedWord(
+          [{value: 's@assy', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`New York Times`, () => {
+      const rt = new RichText({
+        text: `New York Times`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      // case insensitive
+      it(`match: new york times`, () => {
+        const match = hasMutedWord(
+          [{value: 'new york times', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`!command`, () => {
+      const rt = new RichText({
+        text: `Idk maybe a bot !command`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: !command`, () => {
+        const match = hasMutedWord(
+          [{value: `!command`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: command`, () => {
+        const match = hasMutedWord(
+          [{value: `command`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`no match: !command`, () => {
+        const rt = new RichText({
+          text: `Idk maybe a bot command`,
+        })
+        rt.detectFacetsWithoutResolution()
+
+        const match = hasMutedWord(
+          [{value: `!command`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(false)
+      })
+    })
+
+    describe(`e/acc`, () => {
+      const rt = new RichText({
+        text: `I'm e/acc pilled`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: e/acc`, () => {
+        const match = hasMutedWord(
+          [{value: `e/acc`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: acc`, () => {
+        const match = hasMutedWord(
+          [{value: `acc`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`super-bad`, () => {
+      const rt = new RichText({
+        text: `I'm super-bad`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: super-bad`, () => {
+        const match = hasMutedWord(
+          [{value: `super-bad`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: super`, () => {
+        const match = hasMutedWord(
+          [{value: `super`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: super bad`, () => {
+        const match = hasMutedWord(
+          [{value: `super bad`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: superbad`, () => {
+        const match = hasMutedWord(
+          [{value: `superbad`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(false)
+      })
+    })
+
+    describe(`idk_what_this_would_be`, () => {
+      const rt = new RichText({
+        text: `Weird post with idk_what_this_would_be`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: idk what this would be`, () => {
+        const match = hasMutedWord(
+          [{value: `idk what this would be`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`no match: idk what this would be for`, () => {
+        // extra word
+        const match = hasMutedWord(
+          [{value: `idk what this would be for`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(false)
+      })
+
+      it(`match: idk`, () => {
+        // extra word
+        const match = hasMutedWord(
+          [{value: `idk`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: idkwhatthiswouldbe`, () => {
+        const match = hasMutedWord(
+          [{value: `idkwhatthiswouldbe`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(false)
+      })
+    })
+
+    describe(`parentheses`, () => {
+      const rt = new RichText({
+        text: `Post with context(iykyk)`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: context(iykyk)`, () => {
+        const match = hasMutedWord(
+          [{value: `context(iykyk)`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: context`, () => {
+        const match = hasMutedWord(
+          [{value: `context`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: iykyk`, () => {
+        const match = hasMutedWord(
+          [{value: `iykyk`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: (iykyk)`, () => {
+        const match = hasMutedWord(
+          [{value: `(iykyk)`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+
+    describe(`🦋`, () => {
+      const rt = new RichText({
+        text: `Post with 🦋`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: 🦋`, () => {
+        const match = hasMutedWord(
+          [{value: `🦋`, targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+  })
+
+  describe(`phrases`, () => {
+    describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
+      const rt = new RichText({
+        text: `I like turtles, or how I learned to stop worrying and love the internet.`,
+      })
+      rt.detectFacetsWithoutResolution()
+
+      it(`match: stop worrying`, () => {
+        const match = hasMutedWord(
+          [{value: 'stop worrying', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+
+      it(`match: turtles, or how`, () => {
+        const match = hasMutedWord(
+          [{value: 'turtles, or how', targets: ['content']}],
+          rt.text,
+          rt.facets,
+          [],
+        )
+
+        expect(match).toBe(true)
+      })
+    })
+  })
+})
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 2195b2304..862f2de6f 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -2,18 +2,122 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   moderatePost,
+  AppBskyActorDefs,
+  AppBskyFeedPost,
+  AppBskyRichtextFacet,
+  AppBskyEmbedImages,
 } from '@atproto/api'
 
 type ModeratePost = typeof moderatePost
 type Options = Parameters<ModeratePost>[1] & {
   hiddenPosts?: string[]
+  mutedWords?: AppBskyActorDefs.MutedWord[]
+}
+
+const REGEX = {
+  LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
+  ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
+  SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
+  WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
+}
+
+export function hasMutedWord(
+  mutedWords: AppBskyActorDefs.MutedWord[],
+  text: string,
+  facets?: AppBskyRichtextFacet.Main[],
+  outlineTags?: string[],
+) {
+  const tags = ([] as string[])
+    .concat(outlineTags || [])
+    .concat(
+      facets
+        ?.filter(facet => {
+          return facet.features.find(feature =>
+            AppBskyRichtextFacet.isTag(feature),
+          )
+        })
+        .map(t => t.features[0].tag as string) || [],
+    )
+    .map(t => t.toLowerCase())
+
+  for (const mute of mutedWords) {
+    const mutedWord = mute.value.toLowerCase()
+    const postText = text.toLowerCase()
+
+    // `content` applies to tags as well
+    if (tags.includes(mutedWord)) return true
+    // rest of the checks are for `content` only
+    if (!mute.targets.includes('content')) continue
+    // single character, has to use includes
+    if (mutedWord.length === 1 && postText.includes(mutedWord)) return true
+    // too long
+    if (mutedWord.length > postText.length) continue
+    // exact match
+    if (mutedWord === postText) return true
+    // any muted phrase with space or punctuation
+    if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
+      return true
+
+    // check individual character groups
+    const words = postText.split(REGEX.WORD_BOUNDARY)
+    for (const word of words) {
+      if (word === mutedWord) return true
+
+      // compare word without leading/trailing punctuation, but allow internal
+      // punctuation (such as `s@ssy`)
+      const wordTrimmedPunctuation = word.replace(
+        REGEX.LEADING_TRAILING_PUNCTUATION,
+        '',
+      )
+
+      if (mutedWord === wordTrimmedPunctuation) return true
+      if (mutedWord.length > wordTrimmedPunctuation.length) continue
+
+      // handle hyphenated, slash separated words, etc
+      if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
+        // check against full normalized phrase
+        const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
+          REGEX.SEPARATORS,
+          ' ',
+        )
+        const mutedWordNormalizedSeparators = mutedWord.replace(
+          REGEX.SEPARATORS,
+          ' ',
+        )
+        // hyphenated (or other sep) to spaced words
+        if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
+          return true
+
+        /* Disabled for now e.g. `super-cool` to `supercool`
+        const wordNormalizedCompressed = wordNormalizedSeparators.replace(
+          REGEX.WORD_BOUNDARY,
+          '',
+        )
+        const mutedWordNormalizedCompressed =
+          mutedWordNormalizedSeparators.replace(/\s+?/g, '')
+        // hyphenated (or other sep) to non-hyphenated contiguous word
+        if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
+          return true
+        */
+
+        // then individual parts of separated phrases/words
+        const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
+        for (const wp of wordParts) {
+          // still retain internal punctuation
+          if (wp === mutedWord) return true
+        }
+      }
+    }
+  }
+
+  return false
 }
 
 export function moderatePost_wrapped(
   subject: Parameters<ModeratePost>[0],
   opts: Options,
 ) {
-  const {hiddenPosts = [], ...options} = opts
+  const {hiddenPosts = [], mutedWords = [], ...options} = opts
   const moderations = moderatePost(subject, options)
 
   if (hiddenPosts.includes(subject.uri)) {
@@ -29,15 +133,65 @@ export function moderatePost_wrapped(
     }
   }
 
+  if (AppBskyFeedPost.isRecord(subject.record)) {
+    let muted = hasMutedWord(
+      mutedWords,
+      subject.record.text,
+      subject.record.facets || [],
+      subject.record.tags || [],
+    )
+
+    if (
+      subject.record.embed &&
+      AppBskyEmbedImages.isMain(subject.record.embed)
+    ) {
+      for (const image of subject.record.embed.images) {
+        muted = muted || hasMutedWord(mutedWords, image.alt, [], [])
+      }
+    }
+
+    if (muted) {
+      moderations.content.filter = true
+      moderations.content.blur = true
+      if (!moderations.content.cause) {
+        moderations.content.cause = {
+          // @ts-ignore Temporary extension to the moderation system -prf
+          type: 'muted-word',
+          source: {type: 'user'},
+          priority: 1,
+        }
+      }
+    }
+  }
+
   if (subject.embed) {
     let embedHidden = false
     if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
       embedHidden = hiddenPosts.includes(subject.embed.record.uri)
+
+      if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
+        embedHidden =
+          embedHidden ||
+          hasMutedWord(
+            mutedWords,
+            subject.embed.record.value.text,
+            subject.embed.record.value.facets,
+            subject.embed.record.value.tags,
+          )
+
+        if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
+          for (const image of subject.embed.record.value.embed.images) {
+            embedHidden =
+              embedHidden || hasMutedWord(mutedWords, image.alt, [], [])
+          }
+        }
+      }
     }
     if (
       AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
       AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
     ) {
+      // TODO what
       embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
     }
     if (embedHidden) {
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index bf19c208a..b6ebb47a0 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -67,6 +67,13 @@ export function describeModerationCause(
       description: 'You have hidden this post',
     }
   }
+  // @ts-ignore Temporary extension to the moderation system -prf
+  if (cause.type === 'muted-word') {
+    return {
+      name: 'Post hidden by muted word',
+      description: `You've chosen to hide a word or tag within this post.`,
+    }
+  }
   return cause.labelDef.strings[context].en
 }
 
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 538f30cd3..9dfdab909 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -25,3 +25,13 @@ export function makeCustomFeedLink(
 export function makeListLink(did: string, rkey: string, ...segments: string[]) {
   return [`/profile`, did, 'lists', rkey, ...segments].join('/')
 }
+
+export function makeTagLink(did: string) {
+  return `/search?q=${encodeURIComponent(did)}`
+}
+
+export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
+  return `/search?q=${encodeURIComponent(
+    props.query + (props.from ? ` from:${props.from}` : ''),
+  )}`
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0fb36fa7c..0ec09f610 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
   PreferencesFollowingFeed: undefined
   PreferencesThreads: undefined
   PreferencesExternalEmbeds: undefined
+  Search: {q?: string}
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
index 4cafaa086..ae762bd97 100644
--- a/src/state/dialogs/index.tsx
+++ b/src/state/dialogs/index.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {DialogControlProps} from '#/components/Dialog'
+import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
 
 const DialogContext = React.createContext<{
   activeDialogs: React.MutableRefObject<
@@ -37,7 +38,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   return (
     <DialogContext.Provider value={context}>
       <DialogControlContext.Provider value={controls}>
-        {children}
+        <GlobalDialogsProvider>{children}</GlobalDialogsProvider>
       </DialogControlContext.Provider>
     </DialogContext.Provider>
   )
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 2d9d02994..25d284998 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
   userAge: 13, // TODO(pwi)
   interests: {tags: []},
+  mutedWords: [],
+  hiddenPosts: [],
 }
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 632d31a13..07198de77 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,6 +1,10 @@
 import {useMemo} from 'react'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
-import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
+import {
+  LabelPreference,
+  BskyFeedViewPreference,
+  AppBskyActorDefs,
+} from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
 import {getAge} from '#/lib/strings/time'
@@ -108,6 +112,7 @@ export function useModerationOpts() {
     return {
       ...moderationOpts,
       hiddenPosts,
+      mutedWords: prefs.data.mutedWords || [],
     }
   }, [currentAccount?.did, prefs.data, hiddenPosts])
   return opts
@@ -278,3 +283,45 @@ export function useUnpinFeedMutation() {
     },
   })
 }
+
+export function useUpsertMutedWordsMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
+      await getAgent().upsertMutedWords(mutedWords)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useUpdateMutedWordMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
+      await getAgent().updateMutedWord(mutedWord)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useRemoveMutedWordMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
+      await getAgent().removeMutedWord(mutedWord)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 17f9513b7..20be585c2 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
     let i = 0
 
     return Array.from(richtext.segments()).map(segment => {
-      const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
       return (
         <Text
           key={i++}
           style={[
-            segment.facet && !isTag ? pal.link : pal.text,
+            segment.facet ? pal.link : pal.text,
             styles.textInputFormatting,
           ]}>
           {segment.text}
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 199f1f749..c62d11201 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal'
 import {Text} from '../../util/text/Text'
 import {Trans} from '@lingui/macro'
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {TagDecorator} from './web/TagDecorator'
 
 export interface TextInputRef {
   focus: () => void
@@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     () => [
       Document,
       LinkDecorator,
+      TagDecorator,
       Mention.configure({
         HTMLAttributes: {
           class: 'mention',
diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts
new file mode 100644
index 000000000..d820ec3f0
--- /dev/null
+++ b/src/view/com/composer/text-input/web/TagDecorator.ts
@@ -0,0 +1,83 @@
+/**
+ * TipTap is a stateful rich-text editor, which is extremely useful
+ * when you _want_ it to be stateful formatting such as bold and italics.
+ *
+ * However we also use "stateless" behaviors, specifically for URLs
+ * where the text itself drives the formatting.
+ *
+ * This plugin uses a regex to detect URIs and then applies
+ * link decorations (a <span> with the "autolink") class. That avoids
+ * adding any stateful formatting to TipTap's document model.
+ *
+ * We then run the URI detection again when constructing the
+ * RichText object from TipTap's output and merge their features into
+ * the facet-set.
+ */
+
+import {Mark} from '@tiptap/core'
+import {Plugin, PluginKey} from '@tiptap/pm/state'
+import {Node as ProsemirrorNode} from '@tiptap/pm/model'
+import {Decoration, DecorationSet} from '@tiptap/pm/view'
+
+function getDecorations(doc: ProsemirrorNode) {
+  const decorations: Decoration[] = []
+
+  doc.descendants((node, pos) => {
+    if (node.isText && node.text) {
+      const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
+      const textContent = node.textContent
+
+      let match
+      while ((match = regex.exec(textContent))) {
+        const [matchedString, tag] = match
+
+        if (tag.length > 66) continue
+
+        const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || []
+
+        const from = match.index + matchedString.indexOf(tag)
+        const to = from + (tag.length - trailingPunc.length)
+
+        decorations.push(
+          Decoration.inline(pos + from, pos + to, {
+            class: 'autolink',
+          }),
+        )
+      }
+    }
+  })
+
+  return DecorationSet.create(doc, decorations)
+}
+
+const tagDecoratorPlugin: Plugin = new Plugin({
+  key: new PluginKey('link-decorator'),
+
+  state: {
+    init: (_, {doc}) => getDecorations(doc),
+    apply: (transaction, decorationSet) => {
+      if (transaction.docChanged) {
+        return getDecorations(transaction.doc)
+      }
+      return decorationSet.map(transaction.mapping, transaction.doc)
+    },
+  },
+
+  props: {
+    decorations(state) {
+      return tagDecoratorPlugin.getState(state)
+    },
+  },
+})
+
+export const TagDecorator = Mark.create({
+  name: 'tag-decorator',
+  priority: 1000,
+  keepOnSplit: false,
+  inclusive() {
+    return true
+  },
+  addProseMirrorPlugins() {
+    return [tagDecoratorPlugin]
+  },
+})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index ebd739839..949fcfea0 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({
                     styles.postTextLargeContainer,
                   ]}>
                   <RichText
+                    enableTags
+                    selectable
                     value={richText}
                     style={[a.flex_1, a.text_xl]}
-                    selectable
+                    authorHandle={post.author.handle}
                   />
                 </View>
               ) : undefined}
@@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({
                 {richText?.text ? (
                   <View style={styles.postTextContainer}>
                     <RichText
+                      enableTags
                       value={richText}
                       style={[a.flex_1, a.text_md]}
                       numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                      authorHandle={post.author.handle}
                     />
                   </View>
                 ) : undefined}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index aec916adb..5fa4da84e 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -184,10 +184,12 @@ function PostInner({
             {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
+                  enableTags
                   testID="postText"
                   value={richText}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                   style={[a.flex_1, a.text_md]}
+                  authorHandle={post.author.handle}
                 />
               </View>
             ) : undefined}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 6f64de181..47a964ab1 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -347,10 +347,12 @@ let PostContent = ({
       {richText.text ? (
         <View style={styles.postTextContainer}>
           <RichText
+            enableTags
             testID="postText"
             value={richText}
             numberOfLines={limitLines ? MAX_POST_LINES : undefined}
             style={[a.flex_1, a.text_md]}
+            authorHandle={postAuthor.handle}
           />
         </View>
       ) : undefined}
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
index 9e9888ad8..052e7ca13 100644
--- a/src/view/com/util/forms/NativeDropdown.web.tsx
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
 
   return (
     <DropdownMenu.Item
+      className="nativeDropdown-item"
       {...props}
       style={StyleSheet.flatten([
         styles.item,
@@ -232,6 +233,10 @@ const styles = StyleSheet.create({
     paddingLeft: 12,
     paddingRight: 12,
     borderRadius: 8,
+    fontFamily:
+      '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+    outline: 0,
+    border: 0,
   },
   itemTitle: {
     fontSize: 16,
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1dfb687df..09850a7f5 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 import {isWeb} from '#/platform/detection'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 
 let PostDropdownBtn = ({
   testID,
@@ -67,6 +68,7 @@ let PostDropdownBtn = ({
   const {hidePost} = useHiddenPostsApi()
   const openLink = useOpenLink()
   const navigation = useNavigation()
+  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
@@ -210,6 +212,20 @@ let PostDropdownBtn = ({
         web: 'comment-slash',
       },
     },
+    hasSession && {
+      label: _(msg`Mute words & tags`),
+      onPress() {
+        mutedWordsDialogControl.open()
+      },
+      testID: 'postDropdownMuteWordsBtn',
+      icon: {
+        ios: {
+          name: 'speaker.slash',
+        },
+        android: 'ic_lock_silent_mode',
+        web: 'filter',
+      },
+    },
     hasSession &&
       !isAuthor &&
       !isPostHidden && {
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index c128a6f00..35b091269 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -128,10 +128,12 @@ export function QuoteEmbed({
       ) : null}
       {richText ? (
         <RichText
+          enableTags
           value={richText}
           style={[a.text_md]}
           numberOfLines={20}
           disableLinks
+          authorHandle={quote.author.handle}
         />
       ) : null}
       {embed && <PostEmbeds embed={embed} moderation={{}} />}
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index b6d461224..0ec3f3181 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
 import {toShortUrl} from 'lib/strings/url-helpers'
 import {useTheme, TypographyVariant} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
+import {makeTagLink} from 'lib/routes/links'
+import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
+import {isNative} from '#/platform/detection'
 
 const WORD_WRAP = {wordWrap: 1}
 
@@ -82,6 +85,7 @@ export function RichText({
   for (const segment of richText.segments()) {
     const link = segment.link
     const mention = segment.mention
+    const tag = segment.tag
     if (
       !noLinks &&
       mention &&
@@ -115,6 +119,21 @@ export function RichText({
           />,
         )
       }
+    } else if (
+      !noLinks &&
+      tag &&
+      AppBskyRichtextFacet.validateTag(tag).success
+    ) {
+      els.push(
+        <RichTextTag
+          key={key}
+          text={segment.text}
+          type={type}
+          style={style}
+          lineHeightStyle={lineHeightStyle}
+          selectable={selectable}
+        />,
+      )
     } else {
       els.push(segment.text)
     }
@@ -133,3 +152,50 @@ export function RichText({
     </Text>
   )
 }
+
+function RichTextTag({
+  text: tag,
+  type,
+  style,
+  lineHeightStyle,
+  selectable,
+}: {
+  text: string
+  type?: TypographyVariant
+  style?: StyleProp<TextStyle>
+  lineHeightStyle?: TextStyle
+  selectable?: boolean
+}) {
+  const pal = usePalette('default')
+  const control = useTagMenuControl()
+
+  const open = React.useCallback(() => {
+    control.open()
+  }, [control])
+
+  return (
+    <React.Fragment>
+      <TagMenu control={control} tag={tag}>
+        {isNative ? (
+          <TextLink
+            type={type}
+            text={tag}
+            // segment.text has the leading "#" while tag.tag does not
+            href={makeTagLink(tag)}
+            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
+            dataSet={WORD_WRAP}
+            selectable={selectable}
+            onPress={open}
+          />
+        ) : (
+          <Text
+            selectable={selectable}
+            type={type}
+            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
+            {tag}
+          </Text>
+        )}
+      </TagMenu>
+    </React.Fragment>
+  )
+}
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index b7bbf1600..ede1e6335 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
 import {faX} from '@fortawesome/free-solid-svg-icons/faX'
 import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
 import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
+import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
 
 library.add(
   faAddressCard,
@@ -208,4 +209,5 @@ library.add(
   faX,
   faXmark,
   faChevronDown,
+  faFilter,
 )
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
index 8f1fe75b6..928766c30 100644
--- a/src/view/screens/Moderation.tsx
+++ b/src/view/screens/Moderation.tsx
@@ -31,6 +31,7 @@ import {
   useProfileUpdateMutation,
 } from '#/state/queries/profile'
 import {ScrollView} from '../com/util/Views'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
 export function ModerationScreen({}: Props) {
@@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) {
   const {screen, track} = useAnalytics()
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {openModal} = useModalControls()
+  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) {
           style={[styles.linkCard, pal.view]}
           onPress={onPressContentFiltering}
           accessibilityRole="tab"
-          accessibilityHint="Content filtering"
-          accessibilityLabel="">
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Open content filtering settings`)}>
           <View style={[styles.iconContainer, pal.btn]}>
             <FontAwesomeIcon
               icon="eye"
@@ -81,6 +83,23 @@ export function ModerationScreen({}: Props) {
             <Trans>Content filtering</Trans>
           </Text>
         </TouchableOpacity>
+        <TouchableOpacity
+          testID="mutedWordsBtn"
+          style={[styles.linkCard, pal.view]}
+          onPress={() => mutedWordsDialogControl.open()}
+          accessibilityRole="tab"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Open muted words settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="filter"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Muted words & tags</Trans>
+          </Text>
+        </TouchableOpacity>
         <Link
           testID="moderationlistsBtn"
           style={[styles.linkCard, pal.view]}
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 142726701..42eec53d3 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -16,7 +16,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {useFocusEffect} from '@react-navigation/native'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
 import {logger} from '#/logger'
 import {
@@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
 import {s} from '#/lib/styles'
 import AsyncStorage from '@react-native-async-storage/async-storage'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
+import {NavigationProp} from '#/lib/routes/types'
 
 function Loader() {
   const pal = usePalette('default')
@@ -448,6 +449,7 @@ export function SearchScreenInner({
 export function SearchScreen(
   props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 ) {
+  const navigation = useNavigation<NavigationProp>()
   const theme = useTheme()
   const textInput = React.useRef<TextInput>(null)
   const {_} = useLingui()
@@ -472,6 +474,27 @@ export function SearchScreen(
     React.useState(false)
   const [searchHistory, setSearchHistory] = React.useState<string[]>([])
 
+  /**
+   * The Search screen's `q` param
+   */
+  const queryParam = props.route?.params?.q
+
+  /**
+   * If `true`, this means we received new instructions from the router. This
+   * is handled in a effect, and used to update the value of `query` locally
+   * within this screen.
+   */
+  const routeParamsMismatch = queryParam && queryParam !== query
+
+  React.useEffect(() => {
+    if (queryParam && routeParamsMismatch) {
+      // reset immediately and let local state take over
+      navigation.setParams({q: ''})
+      // update query for next search
+      setQuery(queryParam)
+    }
+  }, [queryParam, routeParamsMismatch, navigation])
+
   React.useEffect(() => {
     const loadSearchHistory = async () => {
       try {
@@ -774,6 +797,8 @@ export function SearchScreen(
             )}
           </View>
         </CenteredView>
+      ) : routeParamsMismatch ? (
+        <ActivityIndicator />
       ) : (
         <SearchScreenInner query={query} />
       )}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 6b0cc6808..d895d8851 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
 import {useCloseAnyActiveElement} from '#/state/util'
 import * as notifications from 'lib/notifications/notifications'
 import {Outlet as PortalOutlet} from '#/components/Portal'
+import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -94,6 +95,7 @@ function ShellInner() {
       </View>
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
+      <MutedWordsDialog />
       <PortalOutlet />
       <Lightbox />
     </>
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 97c065502..71dccb8c4 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 import {Outlet as PortalOutlet} from '#/components/Portal'
+import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -40,6 +41,7 @@ function ShellInner() {
       </ErrorBoundary>
       <Composer winHeight={0} />
       <ModalsContainer />
+      <MutedWordsDialog />
       <PortalOutlet />
       <Lightbox />
       {!isDesktop && isDrawerOpen && (
diff --git a/web/index.html b/web/index.html
index 992e69e05..78090591c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -209,6 +209,11 @@
       [data-tooltip]:hover::before {
         display:block;
       }
+
+      /* NativeDropdown component */
+      .nativeDropdown-item:focus {
+        outline: none;
+      }
     </style>
   </head>
 
diff --git a/yarn.lock b/yarn.lock
index a85ea79b8..3cec585ba 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,6 +34,20 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
+"@atproto/api@^0.10.0":
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044"
+  integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ==
+  dependencies:
+    "@atproto/common-web" "^0.2.3"
+    "@atproto/lexicon" "^0.3.1"
+    "@atproto/syntax" "^0.1.5"
+    "@atproto/xrpc" "^0.4.1"
+    multiformats "^9.9.0"
+    tlds "^1.234.0"
+    typed-emitter "^2.1.0"
+    zod "^3.21.4"
+
 "@atproto/api@^0.9.5":
   version "0.9.5"
   resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"