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/Button.tsx48
-rw-r--r--src/components/Dialog/index.tsx68
-rw-r--r--src/components/Link.tsx14
-rw-r--r--src/components/Lists.tsx246
-rw-r--r--src/components/RichText.tsx14
-rw-r--r--src/components/TagMenu/index.tsx60
-rw-r--r--src/components/TagMenu/index.web.tsx45
-rw-r--r--src/components/dialogs/MutedWords.tsx323
-rw-r--r--src/components/forms/TextField.tsx4
-rw-r--r--src/components/forms/Toggle.tsx4
10 files changed, 584 insertions, 242 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index e401bda2a..5361be963 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -165,7 +165,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: tokens.color.blue_500,
+            borderColor: t.palette.primary_500,
           })
           hoverStyles.push(a.border, {
             backgroundColor: light
@@ -174,7 +174,7 @@ export function Button({
           })
         } else {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
+            borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
           })
         }
       } else if (variant === 'ghost') {
@@ -191,20 +191,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_50
-              : tokens.color.gray_900,
+            backgroundColor: t.palette.contrast_50,
           })
           hoverStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_100
-              : tokens.color.gray_950,
+            backgroundColor: t.palette.contrast_100,
           })
         } else {
           baseStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_200
-              : tokens.color.gray_950,
+            backgroundColor: t.palette.contrast_200,
           })
         }
       } else if (variant === 'outline') {
@@ -214,21 +208,19 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
+            borderColor: t.palette.contrast_300,
           })
-          hoverStyles.push(a.border, t.atoms.bg_contrast_50)
+          hoverStyles.push(t.atoms.bg_contrast_50)
         } else {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
+            borderColor: t.palette.contrast_200,
           })
         }
       } else if (variant === 'ghost') {
         if (!disabled) {
           baseStyles.push(t.atoms.bg)
           hoverStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_100
-              : tokens.color.gray_900,
+            backgroundColor: t.palette.contrast_100,
           })
         }
       }
@@ -236,14 +228,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: t.palette.negative_400,
+            backgroundColor: t.palette.negative_500,
           })
           hoverStyles.push({
-            backgroundColor: t.palette.negative_500,
+            backgroundColor: t.palette.negative_600,
           })
         } else {
           baseStyles.push({
-            backgroundColor: t.palette.negative_600,
+            backgroundColor: t.palette.negative_700,
           })
         }
       } else if (variant === 'outline') {
@@ -253,7 +245,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: t.palette.negative_400,
+            borderColor: t.palette.negative_500,
           })
           hoverStyles.push(a.border, {
             backgroundColor: light
@@ -273,7 +265,7 @@ export function Button({
           hoverStyles.push({
             backgroundColor: light
               ? t.palette.negative_100
-              : t.palette.negative_950,
+              : t.palette.negative_975,
           })
         }
       }
@@ -461,31 +453,31 @@ export function useSharedButtonTextStyles() {
       if (variant === 'solid' || variant === 'gradient') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_700 : tokens.color.gray_100,
+            color: t.palette.contrast_700,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+            color: t.palette.contrast_400,
           })
         }
       } else if (variant === 'outline') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+            color: t.palette.contrast_600,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+            color: t.palette.contrast_300,
           })
         }
       } else if (variant === 'ghost') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+            color: t.palette.contrast_600,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_600,
+            color: t.palette.contrast_300,
           })
         }
       }
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 6dfc24f3b..ef4f4741b 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -1,12 +1,15 @@
 import React, {useImperativeHandle} from 'react'
-import {View, Dimensions} from 'react-native'
+import {View, Dimensions, Keyboard, Pressable} from 'react-native'
 import BottomSheet, {
-  BottomSheetBackdrop,
+  BottomSheetBackdropProps,
   BottomSheetScrollView,
   BottomSheetTextInput,
   BottomSheetView,
+  useBottomSheet,
+  WINDOW_HEIGHT,
 } from '@gorhom/bottom-sheet'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import Animated, {useAnimatedStyle} from 'react-native-reanimated'
 
 import {useTheme, atoms as a, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
@@ -26,6 +29,47 @@ export * from '#/components/Dialog/types'
 // @ts-ignore
 export const Input = createInput(BottomSheetTextInput)
 
+function Backdrop(props: BottomSheetBackdropProps) {
+  const t = useTheme()
+  const bottomSheet = useBottomSheet()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const opacity =
+      (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
+
+    return {
+      opacity: Math.min(Math.max(opacity, 0), 0.55),
+    }
+  })
+
+  const onPress = React.useCallback(() => {
+    bottomSheet.close()
+  }, [bottomSheet])
+
+  return (
+    <Animated.View
+      style={[
+        t.atoms.bg_contrast_300,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          position: 'absolute',
+        },
+        animatedStyle,
+      ]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Dialog backdrop"
+        accessibilityHint="Press the backdrop to close the dialog"
+        style={{flex: 1}}
+        onPress={onPress}
+      />
+    </Animated.View>
+  )
+}
+
 export function Outer({
   children,
   control,
@@ -78,6 +122,7 @@ export function Outer({
   const onChange = React.useCallback(
     (index: number) => {
       if (index === -1) {
+        Keyboard.dismiss()
         try {
           closeCallback.current?.()
         } catch (e: any) {
@@ -113,15 +158,7 @@ export function Outer({
           ref={sheet}
           index={openIndex}
           backgroundStyle={{backgroundColor: 'transparent'}}
-          backdropComponent={props => (
-            <BottomSheetBackdrop
-              opacity={0.4}
-              appearsOnIndex={0}
-              disappearsOnIndex={-1}
-              {...props}
-              style={[flatten(props.style), t.atoms.bg_contrast_300]}
-            />
-          )}
+          backdropComponent={Backdrop}
           handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
           handleStyle={{display: 'none'}}
           onChange={onChange}>
@@ -190,8 +227,15 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
 
 export function Handle() {
   const t = useTheme()
+
+  const onTouchStart = React.useCallback(() => {
+    Keyboard.dismiss()
+  }, [])
+
   return (
-    <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
+    <View
+      style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
+      onTouchStart={onTouchStart}>
       <View
         style={[
           a.rounded_sm,
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 0a654fed2..8c963909b 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -49,7 +49,7 @@ type BaseLinkProps = Pick<
    *
    * Note: atm this only works for `InlineLink`s with a string child.
    */
-  warnOnMismatchingTextChild?: boolean
+  disableMismatchWarning?: boolean
 
   /**
    * Callback for when the link is pressed. Prevent default and return `false`
@@ -69,7 +69,7 @@ export function useLink({
   to,
   displayText,
   action = 'push',
-  warnOnMismatchingTextChild,
+  disableMismatchWarning,
   onPress: outerOnPress,
 }: BaseLinkProps & {
   displayText: string
@@ -90,7 +90,7 @@ export function useLink({
       if (exitEarlyIfFalse === false) return
 
       const requiresWarning = Boolean(
-        warnOnMismatchingTextChild &&
+        !disableMismatchWarning &&
           displayText &&
           isExternal &&
           linkRequiresWarning(href, displayText),
@@ -148,7 +148,7 @@ export function useLink({
     },
     [
       outerOnPress,
-      warnOnMismatchingTextChild,
+      disableMismatchWarning,
       displayText,
       isExternal,
       href,
@@ -167,7 +167,7 @@ export function useLink({
   }
 }
 
-export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
+export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
   Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
 
 /**
@@ -226,7 +226,7 @@ export function InlineLink({
   children,
   to,
   action = 'push',
-  warnOnMismatchingTextChild,
+  disableMismatchWarning,
   style,
   onPress: outerOnPress,
   download,
@@ -239,7 +239,7 @@ export function InlineLink({
     to,
     displayText: stringChildren ? children : '',
     action,
-    warnOnMismatchingTextChild,
+    disableMismatchWarning,
     onPress: outerOnPress,
   })
   const {
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
new file mode 100644
index 000000000..12a935807
--- /dev/null
+++ b/src/components/Lists.tsx
@@ -0,0 +1,246 @@
+import React from 'react'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {View} from 'react-native'
+import {Loader} from '#/components/Loader'
+import {Trans} from '@lingui/macro'
+import {cleanError} from 'lib/strings/errors'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {StackActions} from '@react-navigation/native'
+import {useNavigation} from '@react-navigation/core'
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '#/routes'
+
+export function ListFooter({
+  isFetching,
+  isError,
+  error,
+  onRetry,
+}: {
+  isFetching: boolean
+  isError: boolean
+  error?: string
+  onRetry?: () => Promise<unknown>
+}) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.align_center,
+        a.justify_center,
+        a.border_t,
+        a.pb_lg,
+        t.atoms.border_contrast_low,
+        {height: 100},
+      ]}>
+      {isFetching ? (
+        <Loader size="xl" />
+      ) : (
+        <ListFooterMaybeError
+          isError={isError}
+          error={error}
+          onRetry={onRetry}
+        />
+      )}
+    </View>
+  )
+}
+
+function ListFooterMaybeError({
+  isError,
+  error,
+  onRetry,
+}: {
+  isError: boolean
+  error?: string
+  onRetry?: () => Promise<unknown>
+}) {
+  const t = useTheme()
+
+  if (!isError) return null
+
+  return (
+    <View style={[a.w_full, a.px_lg]}>
+      <View
+        style={[
+          a.flex_row,
+          a.gap_md,
+          a.p_md,
+          a.rounded_sm,
+          a.align_center,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text
+          style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]}
+          numberOfLines={2}>
+          {error ? (
+            cleanError(error)
+          ) : (
+            <Trans>Oops, something went wrong!</Trans>
+          )}
+        </Text>
+        <Button
+          variant="gradient"
+          label="Press to retry"
+          style={[
+            a.align_center,
+            a.justify_center,
+            a.rounded_sm,
+            a.overflow_hidden,
+            a.px_md,
+            a.py_sm,
+          ]}
+          onPress={onRetry}>
+          Retry
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+export function ListHeaderDesktop({
+  title,
+  subtitle,
+}: {
+  title: string
+  subtitle?: string
+}) {
+  const {gtTablet} = useBreakpoints()
+  const t = useTheme()
+
+  if (!gtTablet) return null
+
+  return (
+    <View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
+      <Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
+      {subtitle ? (
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          {subtitle}
+        </Text>
+      ) : undefined}
+    </View>
+  )
+}
+
+export function ListMaybePlaceholder({
+  isLoading,
+  isEmpty,
+  isError,
+  empty,
+  error,
+  notFoundType = 'page',
+  onRetry,
+}: {
+  isLoading: boolean
+  isEmpty: boolean
+  isError: boolean
+  empty?: string
+  error?: string
+  notFoundType?: 'page' | 'results'
+  onRetry?: () => Promise<unknown>
+}) {
+  const navigation = useNavigation<NavigationProp>()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+
+  const canGoBack = navigation.canGoBack()
+  const onGoBack = React.useCallback(() => {
+    if (canGoBack) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('HomeTab')
+
+      // Checking the state for routes ensures that web doesn't encounter errors while going back
+      if (navigation.getState()?.routes) {
+        navigation.dispatch(StackActions.push(...router.matchPath('/')))
+      } else {
+        navigation.navigate('HomeTab')
+        navigation.dispatch(StackActions.popToTop())
+      }
+    }
+  }, [navigation, canGoBack])
+
+  if (!isEmpty) return null
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.align_center,
+        !gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}>
+      {isLoading ? (
+        <View style={[a.w_full, a.align_center, {top: 100}]}>
+          <Loader size="xl" />
+        </View>
+      ) : (
+        <>
+          <View style={[a.w_full, a.align_center, a.gap_lg]}>
+            <Text style={[a.font_bold, a.text_3xl]}>
+              {isError ? (
+                <Trans>Oops!</Trans>
+              ) : isEmpty ? (
+                <>
+                  {notFoundType === 'results' ? (
+                    <Trans>No results found</Trans>
+                  ) : (
+                    <Trans>Page not found</Trans>
+                  )}
+                </>
+              ) : undefined}
+            </Text>
+
+            {isError ? (
+              <Text
+                style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
+                {error ? error : <Trans>Something went wrong!</Trans>}
+              </Text>
+            ) : isEmpty ? (
+              <Text
+                style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
+                {empty ? (
+                  empty
+                ) : (
+                  <Trans>
+                    We're sorry! We can't find the page you were looking for.
+                  </Trans>
+                )}
+              </Text>
+            ) : undefined}
+          </View>
+          <View
+            style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
+            {isError && onRetry && (
+              <Button
+                variant="solid"
+                color="primary"
+                label="Click here"
+                onPress={onRetry}
+                size="large"
+                style={[
+                  a.rounded_sm,
+                  a.overflow_hidden,
+                  {paddingVertical: 10},
+                ]}>
+                Retry
+              </Button>
+            )}
+            <Button
+              variant="solid"
+              color={isError && onRetry ? 'secondary' : 'primary'}
+              label="Click here"
+              onPress={onGoBack}
+              size="large"
+              style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+              Go Back
+            </Button>
+          </View>
+        </>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 3d5f08026..1a14415cf 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -105,8 +105,7 @@ export function RichText({
             to={link.uri}
             style={[...styles, {pointerEvents: 'auto'}]}
             // @ts-ignore TODO
-            dataSet={WORD_WRAP}
-            warnOnMismatchingLabel>
+            dataSet={WORD_WRAP}>
             {toShortUrl(segment.text)}
           </InlineLink>,
         )
@@ -121,6 +120,7 @@ export function RichText({
         <RichTextTag
           key={key}
           text={segment.text}
+          tag={tag.tag}
           style={styles}
           selectable={selectable}
           authorHandle={authorHandle}
@@ -146,12 +146,14 @@ export function RichText({
 }
 
 function RichTextTag({
-  text: tag,
+  text,
+  tag,
   style,
   selectable,
   authorHandle,
 }: {
   text: string
+  tag: string
   selectable?: boolean
   authorHandle?: string
 } & TextStyleProp) {
@@ -185,8 +187,8 @@ function RichTextTag({
         <Text
           selectable={selectable}
           {...native({
-            accessibilityLabel: _(msg`Hashtag: ${tag}`),
-            accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
+            accessibilityLabel: _(msg`Hashtag: #${tag}`),
+            accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
             accessibilityRole: isNative ? 'button' : undefined,
             onPress: open,
             onPressIn: onPressIn,
@@ -214,7 +216,7 @@ function RichTextTag({
               textDecorationColor: t.palette.primary_500,
             },
           ]}>
-          {tag}
+          {text}
         </Text>
       </TagMenu>
     </React.Fragment>
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 2fec7a188..c9ced9a54 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -34,6 +34,10 @@ export function TagMenu({
   authorHandle,
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
+  /**
+   * This should be the sanitized tag value from the facet itself, not the
+   * "display" value with a leading `#`.
+   */
   tag: string
   authorHandle?: string
 }>) {
@@ -52,16 +56,16 @@ export function TagMenu({
     variables: optimisticRemove,
     reset: resetRemove,
   } = useRemoveMutedWordMutation()
+  const displayTag = '#' + tag
 
-  const sanitizedTag = tag.replace(/^#/, '')
   const isMuted = Boolean(
     (preferences?.mutedWords?.find(
-      m => m.value === sanitizedTag && m.targets.includes('tag'),
+      m => m.value === tag && m.targets.includes('tag'),
     ) ??
       optimisticUpsert?.find(
-        m => m.value === sanitizedTag && m.targets.includes('tag'),
+        m => m.value === tag && m.targets.includes('tag'),
       )) &&
-      !(optimisticRemove?.value === sanitizedTag),
+      !(optimisticRemove?.value === tag),
   )
 
   return (
@@ -71,7 +75,7 @@ export function TagMenu({
       <Dialog.Outer control={control}>
         <Dialog.Handle />
 
-        <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
+        <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
           {isPreferencesLoading ? (
             <View style={[a.w_full, a.align_center]}>
               <Loader size="lg" />
@@ -87,18 +91,14 @@ export function TagMenu({
                   t.atoms.bg_contrast_25,
                 ]}>
                 <Link
-                  label={_(msg`Search for all posts with tag ${tag}`)}
-                  to={makeSearchLink({query: tag})}
+                  label={_(msg`Search for all posts with tag ${displayTag}`)}
+                  to={makeSearchLink({query: displayTag})}
                   onPress={e => {
                     e.preventDefault()
 
                     control.close(() => {
-                      // @ts-ignore :ron_swanson: "I know more than you"
-                      navigation.navigate('SearchTab', {
-                        screen: 'Search',
-                        params: {
-                          q: tag,
-                        },
+                      navigation.push('Hashtag', {
+                        tag: tag.replaceAll('#', '%23'),
                       })
                     })
 
@@ -128,7 +128,7 @@ export function TagMenu({
                       <Trans>
                         See{' '}
                         <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                          {tag}
+                          {displayTag}
                         </Text>{' '}
                         posts
                       </Trans>
@@ -142,21 +142,19 @@ export function TagMenu({
 
                     <Link
                       label={_(
-                        msg`Search for all posts by @${authorHandle} with tag ${tag}`,
+                        msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
                       )}
-                      to={makeSearchLink({query: tag, from: authorHandle})}
+                      to={makeSearchLink({
+                        query: displayTag,
+                        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}` : ''),
-                            },
+                          navigation.push('Hashtag', {
+                            tag: tag.replaceAll('#', '%23'),
+                            author: authorHandle,
                           })
                         })
 
@@ -190,7 +188,7 @@ export function TagMenu({
                             See{' '}
                             <Text
                               style={[a.text_md, a.font_bold, t.atoms.text]}>
-                              {tag}
+                              {displayTag}
                             </Text>{' '}
                             posts by this user
                           </Trans>
@@ -207,22 +205,20 @@ export function TagMenu({
                     <Button
                       label={
                         isMuted
-                          ? _(msg`Unmute all ${tag} posts`)
-                          : _(msg`Mute all ${tag} posts`)
+                          ? _(msg`Unmute all ${displayTag} posts`)
+                          : _(msg`Mute all ${displayTag} posts`)
                       }
                       onPress={() => {
                         control.close(() => {
                           if (isMuted) {
                             resetUpsert()
                             removeMutedWord({
-                              value: sanitizedTag,
+                              value: tag,
                               targets: ['tag'],
                             })
                           } else {
                             resetRemove()
-                            upsertMutedWord([
-                              {value: sanitizedTag, targets: ['tag']},
-                            ])
+                            upsertMutedWord([{value: tag, targets: ['tag']}])
                           }
                         })
                       }}>
@@ -252,7 +248,7 @@ export function TagMenu({
                           ]}>
                           {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
                           <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                            {tag}
+                            {displayTag}
                           </Text>{' '}
                           <Trans>posts</Trans>
                         </Text>
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index 31187112f..a0dc2bce6 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -14,18 +14,34 @@ import {
 } from '#/state/queries/preferences'
 import {enforceLen} from '#/lib/strings/helpers'
 import {web} from '#/alf'
+import * as Dialog from '#/components/Dialog'
 
-export function useTagMenuControl() {}
+export function useTagMenuControl(): Dialog.DialogControlProps {
+  return {
+    id: '',
+    // @ts-ignore
+    ref: null,
+    open: () => {
+      throw new Error(`TagMenu controls are only available on native platforms`)
+    },
+    close: () => {
+      throw new Error(`TagMenu controls are only available on native platforms`)
+    },
+  }
+}
 
 export function TagMenu({
   children,
   tag,
   authorHandle,
 }: React.PropsWithChildren<{
+  /**
+   * This should be the sanitized tag value from the facet itself, not the
+   * "display" value with a leading `#`.
+   */
   tag: string
   authorHandle?: string
 }>) {
-  const sanitizedTag = tag.replace(/^#/, '')
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
   const {data: preferences} = usePreferencesQuery()
@@ -35,22 +51,22 @@ export function TagMenu({
     useRemoveMutedWordMutation()
   const isMuted = Boolean(
     (preferences?.mutedWords?.find(
-      m => m.value === sanitizedTag && m.targets.includes('tag'),
+      m => m.value === tag && m.targets.includes('tag'),
     ) ??
       optimisticUpsert?.find(
-        m => m.value === sanitizedTag && m.targets.includes('tag'),
+        m => m.value === tag && m.targets.includes('tag'),
       )) &&
-      !(optimisticRemove?.value === sanitizedTag),
+      !(optimisticRemove?.value === tag),
   )
-  const truncatedTag = enforceLen(tag, 15, true, 'middle')
+  const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
 
   const dropdownItems = React.useMemo(() => {
     return [
       {
         label: _(msg`See ${truncatedTag} posts`),
         onPress() {
-          navigation.navigate('Search', {
-            q: tag,
+          navigation.push('Hashtag', {
+            tag: tag.replaceAll('#', '%23'),
           })
         },
         testID: 'tagMenuSearch',
@@ -66,11 +82,9 @@ export function TagMenu({
         !isInvalidHandle(authorHandle) && {
           label: _(msg`See ${truncatedTag} posts by user`),
           onPress() {
-            navigation.navigate({
-              name: 'Search',
-              params: {
-                q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
-              },
+            navigation.push('Hashtag', {
+              tag: tag.replaceAll('#', '%23'),
+              author: authorHandle,
             })
           },
           testID: 'tagMenuSeachByUser',
@@ -91,9 +105,9 @@ export function TagMenu({
           : _(msg`Mute ${truncatedTag}`),
         onPress() {
           if (isMuted) {
-            removeMutedWord({value: sanitizedTag, targets: ['tag']})
+            removeMutedWord({value: tag, targets: ['tag']})
           } else {
-            upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
+            upsertMutedWord([{value: tag, targets: ['tag']}])
           }
         },
         testID: 'tagMenuMute',
@@ -114,7 +128,6 @@ export function TagMenu({
     preferences,
     tag,
     truncatedTag,
-    sanitizedTag,
     upsertMutedWord,
     removeMutedWord,
   ])
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 7c0d4fbca..658ba2aae 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
-import {View} from 'react-native'
+import {Keyboard, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
 
 import {
   usePreferencesQuery,
@@ -10,7 +10,14 @@ import {
   useRemoveMutedWordMutation,
 } from '#/state/queries/preferences'
 import {isNative} from '#/platform/detection'
-import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf'
+import {
+  atoms as a,
+  useTheme,
+  useBreakpoints,
+  ViewStyleProp,
+  web,
+  native,
+} 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'
@@ -48,166 +55,208 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
   const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
   const [field, setField] = React.useState('')
   const [options, setOptions] = React.useState(['content'])
-  const [_error, setError] = React.useState('')
+  const [error, setError] = React.useState('')
 
   const submit = React.useCallback(async () => {
-    const value = field.trim()
+    const sanitizedValue = sanitizeMutedWordValue(field)
     const targets = ['tag', options.includes('content') && 'content'].filter(
       Boolean,
     ) as AppBskyActorDefs.MutedWord['targets']
 
-    if (!value || !targets.length) return
+    if (!sanitizedValue || !targets.length) {
+      setField('')
+      setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
+      return
+    }
 
     try {
-      await addMutedWord([{value, targets}])
+      // send raw value and rely on SDK as sanitization source of truth
+      await addMutedWord([{value: field, targets}])
       setField('')
     } catch (e: any) {
       logger.error(`Failed to save muted word`, {message: e.message})
       setError(e.message)
     }
-  }, [field, options, addMutedWord, setField])
+  }, [_, 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 onTouchStart={Keyboard.dismiss}>
+        <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}
-        />
+        <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={value => {
+              if (error) {
+                setError('')
+              }
+              setField(value)
+            }}
+            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.Group
+            label={_(msg`Toggle between muted word options.`)}
+            type="radio"
+            values={options}
+            onChange={setOptions}>
+            <View
+              style={[
+                a.pt_sm,
+                a.py_sm,
+                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>
+              <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>
+              <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>
+          {error && (
+            <View
+              style={[
+                a.mb_lg,
+                a.flex_row,
+                a.rounded_sm,
+                a.p_md,
+                a.mb_xs,
+                t.atoms.bg_contrast_25,
+                {
+                  backgroundColor: t.palette.negative_400,
+                },
+              ]}>
+              <Text
+                style={[
+                  a.italic,
+                  {color: t.palette.white},
+                  native({marginTop: 2}),
+                ]}>
+                {error}
+              </Text>
+            </View>
+          )}
 
-      <Divider />
+          <Text
+            style={[
+              a.pt_xs,
+              a.text_sm,
+              a.italic,
+              a.leading_snug,
+              t.atoms.text_contrast_medium,
+            ]}>
+            <Trans>
+              We recommend avoiding common words that appear in many posts,
+              since it can result in no posts being shown.
+            </Trans>
+          </Text>
+        </View>
 
-      <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>
+        <Divider />
 
-        {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>
+        <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}} />}
+        {isNative && <View style={{height: 20}} />}
 
-      <Dialog.Close />
+        <Dialog.Close />
+      </View>
     </Dialog.ScrollableInner>
   )
 }
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index a781bdd18..b37f4bfae 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -10,7 +10,7 @@ import {
 } from 'react-native'
 
 import {HITSLOP_20} from 'lib/constants'
-import {useTheme, atoms as a, web, tokens, android} from '#/alf'
+import {useTheme, atoms as a, web, android} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {Props as SVGIconProps} from '#/components/icons/common'
@@ -110,7 +110,7 @@ export function useSharedInputStyles() {
       {
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
-        borderColor: tokens.color.red_500,
+        borderColor: t.palette.negative_500,
       },
     ]
 
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 140740f70..a83f92a2a 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -301,7 +301,7 @@ export function createSharedToggleStyles({
   if (isInvalid) {
     base.push({
       backgroundColor:
-        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
       borderColor:
         t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
     })
@@ -310,7 +310,7 @@ export function createSharedToggleStyles({
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
-        borderColor: t.palette.negative_500,
+        borderColor: t.palette.negative_600,
       })
     }
   }