about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-01-21 15:56:01 -0600
committerGitHub <noreply@github.com>2025-01-21 21:56:01 +0000
commit9df5caf3c545a7a1c559c6561625d99154aa0603 (patch)
tree3f7a1b2fdb6fb7628e22b79a978b762cccdd200e /src
parentc8d062f1aef130e13a99892e7bb695b1e123c3db (diff)
downloadvoidsky-9df5caf3c545a7a1c559c6561625d99154aa0603.tar.zst
Update hashtag menu to use `Menu`, convert to native link for additional a11y and click handling (#7529)
* Make tag a normal link on web

* Replace old TagMenu with new RichTextTag component, expand and improve click utils

* Clarify intents

* Ensure we're passing down hint

* ope

* DRY
Diffstat (limited to 'src')
-rw-r--r--src/components/Link.tsx126
-rw-r--r--src/components/Menu/index.tsx9
-rw-r--r--src/components/Menu/index.web.tsx8
-rw-r--r--src/components/Menu/types.ts5
-rw-r--r--src/components/RichText.tsx94
-rw-r--r--src/components/RichTextTag.tsx160
-rw-r--r--src/components/TagMenu/index.tsx290
-rw-r--r--src/components/TagMenu/index.web.tsx163
-rw-r--r--src/platform/urls.tsx14
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx2
10 files changed, 294 insertions, 577 deletions
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 3cd593a10..50e741ea7 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -15,7 +15,6 @@ import {
   linkRequiresWarning,
 } from '#/lib/strings/url-helpers'
 import {isNative, isWeb} from '#/platform/detection'
-import {shouldClickOpenNewTab} from '#/platform/urls'
 import {useModalControls} from '#/state/modals'
 import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
 import {Button, ButtonProps} from '#/components/Button'
@@ -56,6 +55,12 @@ type BaseLinkProps = Pick<
   onPress?: (e: GestureResponderEvent) => void | false
 
   /**
+   * Callback for when the link is long pressed (on native). Prevent default
+   * and return `false` to exit early and prevent default long press hander.
+   */
+  onLongPress?: (e: GestureResponderEvent) => void | false
+
+  /**
    * Web-only attribute. Sets `download` attr on web.
    */
   download?: string
@@ -72,6 +77,7 @@ export function useLink({
   action = 'push',
   disableMismatchWarning,
   onPress: outerOnPress,
+  onLongPress: outerOnLongPress,
   shareOnLongPress,
 }: BaseLinkProps & {
   displayText: string
@@ -175,8 +181,14 @@ export function useLink({
     }
   }, [disableMismatchWarning, displayText, href, isExternal, openModal])
 
-  const onLongPress =
-    isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
+  const onLongPress = React.useCallback(
+    (e: GestureResponderEvent) => {
+      const exitEarlyIfFalse = outerOnLongPress?.(e)
+      if (exitEarlyIfFalse === false) return
+      return isNative && shareOnLongPress ? handleLongPress() : undefined
+    },
+    [outerOnLongPress, handleLongPress, shareOnLongPress],
+  )
 
   return {
     isExternal,
@@ -202,14 +214,16 @@ export function Link({
   to,
   action = 'push',
   onPress: outerOnPress,
+  onLongPress: outerOnLongPress,
   download,
   ...rest
 }: LinkProps) {
-  const {href, isExternal, onPress} = useLink({
+  const {href, isExternal, onPress, onLongPress} = useLink({
     to,
     displayText: typeof children === 'string' ? children : '',
     action,
     onPress: outerOnPress,
+    onLongPress: outerOnLongPress,
   })
 
   return (
@@ -220,6 +234,7 @@ export function Link({
       accessibilityRole="link"
       href={href}
       onPress={download ? undefined : onPress}
+      onLongPress={onLongPress}
       {...web({
         hrefAttrs: {
           target: download ? undefined : isExternal ? 'blank' : undefined,
@@ -241,7 +256,7 @@ export type InlineLinkProps = React.PropsWithChildren<
     TextStyleProp &
     Pick<TextProps, 'selectable' | 'numberOfLines'>
 > &
-  Pick<ButtonProps, 'label'> & {
+  Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
     disableUnderline?: boolean
     title?: TextProps['title']
   }
@@ -253,6 +268,7 @@ export function InlineLinkText({
   disableMismatchWarning,
   style,
   onPress: outerOnPress,
+  onLongPress: outerOnLongPress,
   download,
   selectable,
   label,
@@ -268,6 +284,7 @@ export function InlineLinkText({
     action,
     disableMismatchWarning,
     onPress: outerOnPress,
+    onLongPress: outerOnLongPress,
     shareOnLongPress,
   })
   const {
@@ -319,6 +336,21 @@ export function InlineLinkText({
   )
 }
 
+export function WebOnlyInlineLinkText({
+  children,
+  to,
+  onPress,
+  ...props
+}: Omit<InlineLinkProps, 'onLongPress'>) {
+  return isWeb ? (
+    <InlineLinkText {...props} to={to} onPress={onPress}>
+      {children}
+    </InlineLinkText>
+  ) : (
+    <Text {...props}>{children}</Text>
+  )
+}
+
 /**
  * Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
  *
@@ -327,7 +359,10 @@ export function InlineLinkText({
  */
 export function createStaticClick(
   onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
-): Pick<BaseLinkProps, 'to' | 'onPress'> {
+): {
+  to: BaseLinkProps['to']
+  onPress: Exclude<BaseLinkProps['onPress'], undefined>
+} {
   return {
     to: '#',
     onPress(e: GestureResponderEvent) {
@@ -338,17 +373,72 @@ export function createStaticClick(
   }
 }
 
-export function WebOnlyInlineLinkText({
-  children,
-  to,
-  onPress,
-  ...props
-}: InlineLinkProps) {
-  return isWeb ? (
-    <InlineLinkText {...props} to={to} onPress={onPress}>
-      {children}
-    </InlineLinkText>
-  ) : (
-    <Text {...props}>{children}</Text>
+/**
+ * Utility to create a static `onPress` handler for a `Link`, but only if the
+ * click was not modified in some way e.g. `Cmd` or a middle click.
+ *
+ * On native, this behaves the same as `createStaticClick` because there are no
+ * options to "modify" the click in this sense.
+ *
+ * Example:
+ *   `<Link {...createStaticClick(e => {...})} />`
+ */
+export function createStaticClickIfUnmodified(
+  onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
+): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
+  return {
+    onPress(e: GestureResponderEvent) {
+      if (!isWeb || !isModifiedClickEvent(e)) {
+        e.preventDefault()
+        onPressHandler(e)
+        return false
+      }
+    },
+  }
+}
+
+/**
+ * Determines if the click event has a meta key pressed, indicating the user
+ * intends to deviate from default behavior.
+ */
+export function isClickEventWithMetaKey(e: GestureResponderEvent) {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
+}
+
+/**
+ * Determines if the web click target is anything other than `_self`
+ */
+export function isClickTargetExternal(e: GestureResponderEvent) {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  const el = event.currentTarget as HTMLAnchorElement
+  return el && el.target && el.target !== '_self'
+}
+
+/**
+ * Determines if a click event has been modified in some way from its default
+ * behavior, e.g. `Cmd` or a middle click.
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
+ */
+export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  const isPrimaryButton = event.button === 0
+  return (
+    isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton
   )
 }
+
+/**
+ * Determines if a click event has been modified in a way that should indiciate
+ * that the user intends to open a new tab.
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
+ */
+export function shouldClickOpenNewTab(e: GestureResponderEvent) {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  const isMiddleClick = isWeb && event.button === 1
+  return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
+}
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 99fb2d127..9c970b051 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -47,7 +47,12 @@ export function Root({
   return <Context.Provider value={context}>{children}</Context.Provider>
 }
 
-export function Trigger({children, label, role = 'button'}: TriggerProps) {
+export function Trigger({
+  children,
+  label,
+  role = 'button',
+  hint,
+}: TriggerProps) {
   const context = useMenuContext()
   const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
   const {
@@ -65,11 +70,13 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
       pressed,
     },
     props: {
+      ref: null,
       onPress: context.control.open,
       onFocus,
       onBlur,
       onPressIn,
       onPressOut,
+      accessibilityHint: hint,
       accessibilityLabel: label,
       accessibilityRole: role,
     },
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index d1863e478..dc9116168 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -110,7 +110,12 @@ const RadixTriggerPassThrough = React.forwardRef(
 )
 RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
 
-export function Trigger({children, label, role = 'button'}: TriggerProps) {
+export function Trigger({
+  children,
+  label,
+  role = 'button',
+  hint,
+}: TriggerProps) {
   const {control} = useMenuContext()
   const {
     state: hovered,
@@ -153,6 +158,7 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
               onBlur: onBlur,
               onMouseEnter,
               onMouseLeave,
+              accessibilityHint: hint,
               accessibilityLabel: label,
               accessibilityRole: role,
             },
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
index 44171d42c..51baa24df 100644
--- a/src/components/Menu/types.ts
+++ b/src/components/Menu/types.ts
@@ -19,6 +19,7 @@ export type ItemContextType = {
 }
 
 export type RadixPassThroughTriggerProps = {
+  ref: React.RefObject<any>
   id: string
   type: 'button'
   disabled: boolean
@@ -37,6 +38,7 @@ export type RadixPassThroughTriggerProps = {
 export type TriggerProps = {
   children(props: TriggerChildProps): React.ReactNode
   label: string
+  hint?: string
   role?: AccessibilityRole
 }
 export type TriggerChildProps =
@@ -59,11 +61,13 @@ export type TriggerChildProps =
        * object is empty.
        */
       props: {
+        ref: null
         onPress: () => void
         onFocus: () => void
         onBlur: () => void
         onPressIn: () => void
         onPressOut: () => void
+        accessibilityHint?: string
         accessibilityLabel: string
         accessibilityRole: AccessibilityRole
       }
@@ -85,6 +89,7 @@ export type TriggerChildProps =
         onBlur: () => void
         onMouseEnter: () => void
         onMouseLeave: () => void
+        accessibilityHint?: string
         accessibilityLabel: string
         accessibilityRole: AccessibilityRole
       }
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 4edd9f88e..7005d0742 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -1,19 +1,13 @@
 import React from 'react'
 import {TextStyle} from 'react-native'
 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
 
-import {NavigationProp} from '#/lib/routes/types'
 import {toShortUrl} from '#/lib/strings/url-helpers'
-import {isNative} from '#/platform/detection'
-import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
+import {atoms as a, flatten, TextStyleProp} from '#/alf'
 import {isOnlyEmoji} from '#/alf/typography'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {InlineLinkText, LinkProps} from '#/components/Link'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
-import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
+import {RichTextTag} from '#/components/RichTextTag'
 import {Text, TextProps} from '#/components/Typography'
 
 const WORD_WRAP = {wordWrap: 1}
@@ -149,10 +143,9 @@ export function RichText({
       els.push(
         <RichTextTag
           key={key}
-          text={segment.text}
+          display={segment.text}
           tag={tag.tag}
-          style={interactiveStyles}
-          selectable={selectable}
+          textStyle={interactiveStyles}
           authorHandle={authorHandle}
         />,
       )
@@ -177,82 +170,3 @@ export function RichText({
     </Text>
   )
 }
-
-function RichTextTag({
-  text,
-  tag,
-  style,
-  selectable,
-  authorHandle,
-}: {
-  text: string
-  tag: 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 navigation = useNavigation<NavigationProp>()
-
-  const navigateToPage = React.useCallback(() => {
-    navigation.push('Hashtag', {
-      tag: encodeURIComponent(tag),
-    })
-  }, [navigation, tag])
-
-  const openDialog = 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
-          emoji
-          selectable={selectable}
-          {...native({
-            accessibilityLabel: _(msg`Hashtag: #${tag}`),
-            accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
-            accessibilityRole: isNative ? 'button' : undefined,
-            onPress: navigateToPage,
-            onLongPress: openDialog,
-          })}
-          {...web({
-            onMouseEnter: onHoverIn,
-            onMouseLeave: onHoverOut,
-          })}
-          // @ts-ignore
-          onFocus={onFocus}
-          onBlur={onBlur}
-          style={[
-            web({
-              cursor: 'pointer',
-            }),
-            {color: t.palette.primary_500},
-            (hovered || focused) && {
-              ...web({
-                outline: 0,
-                textDecorationLine: 'underline',
-                textDecorationColor: t.palette.primary_500,
-              }),
-            },
-            style,
-          ]}>
-          {text}
-        </Text>
-      </TagMenu>
-    </React.Fragment>
-  )
-}
diff --git a/src/components/RichTextTag.tsx b/src/components/RichTextTag.tsx
new file mode 100644
index 000000000..562d44aa6
--- /dev/null
+++ b/src/components/RichTextTag.tsx
@@ -0,0 +1,160 @@
+import React from 'react'
+import {StyleProp, Text as RNText, TextStyle} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from '#/lib/routes/types'
+import {isInvalidHandle} from '#/lib/strings/handles'
+import {isNative, isWeb} from '#/platform/detection'
+import {
+  usePreferencesQuery,
+  useRemoveMutedWordsMutation,
+  useUpsertMutedWordsMutation,
+} from '#/state/queries/preferences'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
+import {
+  createStaticClick,
+  createStaticClickIfUnmodified,
+  InlineLinkText,
+} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
+
+export function RichTextTag({
+  tag,
+  display,
+  authorHandle,
+  textStyle,
+}: {
+  tag: string
+  display: string
+  authorHandle?: string
+  textStyle: StyleProp<TextStyle>
+}) {
+  const {_} = useLingui()
+  const {isLoading: isPreferencesLoading, data: preferences} =
+    usePreferencesQuery()
+  const {
+    mutateAsync: upsertMutedWord,
+    variables: optimisticUpsert,
+    reset: resetUpsert,
+  } = useUpsertMutedWordsMutation()
+  const {
+    mutateAsync: removeMutedWords,
+    variables: optimisticRemove,
+    reset: resetRemove,
+  } = useRemoveMutedWordsMutation()
+  const navigation = useNavigation<NavigationProp>()
+  const label = _(msg`Hashtag ${tag}`)
+  const hint = isNative
+    ? _(msg`Long press to open tag menu for #${tag}`)
+    : _(msg`Click to open tag menu for ${tag}`)
+
+  const isMuted = Boolean(
+    (preferences?.moderationPrefs.mutedWords?.find(
+      m => m.value === tag && m.targets.includes('tag'),
+    ) ??
+      optimisticUpsert?.find(
+        m => m.value === tag && m.targets.includes('tag'),
+      )) &&
+      !optimisticRemove?.find(m => m?.value === tag),
+  )
+
+  /*
+   * Mute word records that exactly match the tag in question.
+   */
+  const removeableMuteWords = React.useMemo(() => {
+    return (
+      preferences?.moderationPrefs.mutedWords?.filter(word => {
+        return word.value === tag
+      }) || []
+    )
+  }, [tag, preferences?.moderationPrefs?.mutedWords])
+
+  return (
+    <Menu.Root>
+      <Menu.Trigger label={label} hint={hint}>
+        {({props: menuProps}) => (
+          <InlineLinkText
+            to={{
+              screen: 'Hashtag',
+              params: {tag: encodeURIComponent(tag)},
+            }}
+            {...menuProps}
+            onPress={e => {
+              if (isWeb) {
+                return createStaticClickIfUnmodified(() => {
+                  if (!isNative) {
+                    menuProps.onPress()
+                  }
+                }).onPress(e)
+              }
+            }}
+            onLongPress={createStaticClick(menuProps.onPress).onPress}
+            accessibilityHint={hint}
+            label={label}
+            style={textStyle}>
+            {isNative ? (
+              display
+            ) : (
+              <RNText ref={menuProps.ref}>{display}</RNText>
+            )}
+          </InlineLinkText>
+        )}
+      </Menu.Trigger>
+      <Menu.Outer>
+        <Menu.Group>
+          <Menu.Item
+            label={_(msg`See ${tag} posts`)}
+            onPress={() => {
+              navigation.push('Hashtag', {
+                tag: encodeURIComponent(tag),
+              })
+            }}>
+            <Menu.ItemText>
+              <Trans>See #{tag} posts</Trans>
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={Search} />
+          </Menu.Item>
+          {authorHandle && !isInvalidHandle(authorHandle) && (
+            <Menu.Item
+              label={_(msg`See ${tag} posts by user`)}
+              onPress={() => {
+                navigation.push('Hashtag', {
+                  tag: encodeURIComponent(tag),
+                  author: authorHandle,
+                })
+              }}>
+              <Menu.ItemText>
+                <Trans>See #{tag} posts by user</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Person} />
+            </Menu.Item>
+          )}
+        </Menu.Group>
+        <Menu.Divider />
+        <Menu.Item
+          label={isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
+          onPress={() => {
+            if (isMuted) {
+              resetUpsert()
+              removeMutedWords(removeableMuteWords)
+            } else {
+              resetRemove()
+              upsertMutedWord([
+                {value: tag, targets: ['tag'], actorTarget: 'all'},
+              ])
+            }
+          }}>
+          <Menu.ItemText>
+            {isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
+          </Menu.ItemText>
+          <Menu.ItemIcon icon={isPreferencesLoading ? Loader : Mute} />
+        </Menu.Item>
+      </Menu.Outer>
+    </Menu.Root>
+  )
+}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
deleted file mode 100644
index 310ecc4c2..000000000
--- a/src/components/TagMenu/index.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
-
-import {NavigationProp} from '#/lib/routes/types'
-import {isInvalidHandle} from '#/lib/strings/handles'
-import {
-  usePreferencesQuery,
-  useRemoveMutedWordsMutation,
-  useUpsertMutedWordsMutation,
-} from '#/state/queries/preferences'
-import {atoms as a, native, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import {Divider} from '#/components/Divider'
-import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
-import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
-import {createStaticClick, Link} from '#/components/Link'
-import {Loader} from '#/components/Loader'
-import {Text} from '#/components/Typography'
-
-export function useTagMenuControl() {
-  return Dialog.useDialogControl()
-}
-
-export function TagMenu({
-  children,
-  control,
-  tag,
-  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
-}>) {
-  const navigation = useNavigation<NavigationProp>()
-  return (
-    <>
-      {children}
-      <Dialog.Outer control={control}>
-        <Dialog.Handle />
-        <TagMenuInner
-          control={control}
-          tag={tag}
-          authorHandle={authorHandle}
-          navigation={navigation}
-        />
-      </Dialog.Outer>
-    </>
-  )
-}
-
-function TagMenuInner({
-  control,
-  tag,
-  authorHandle,
-  navigation,
-}: {
-  control: Dialog.DialogOuterProps['control']
-  tag: string
-  authorHandle?: string
-  // Passed down because on native, we don't use real portals (and context would be wrong).
-  navigation: NavigationProp
-}) {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {isLoading: isPreferencesLoading, data: preferences} =
-    usePreferencesQuery()
-  const {
-    mutateAsync: upsertMutedWord,
-    variables: optimisticUpsert,
-    reset: resetUpsert,
-  } = useUpsertMutedWordsMutation()
-  const {
-    mutateAsync: removeMutedWords,
-    variables: optimisticRemove,
-    reset: resetRemove,
-  } = useRemoveMutedWordsMutation()
-  const displayTag = '#' + tag
-
-  const isMuted = Boolean(
-    (preferences?.moderationPrefs.mutedWords?.find(
-      m => m.value === tag && m.targets.includes('tag'),
-    ) ??
-      optimisticUpsert?.find(
-        m => m.value === tag && m.targets.includes('tag'),
-      )) &&
-      !optimisticRemove?.find(m => m?.value === tag),
-  )
-
-  /*
-   * Mute word records that exactly match the tag in question.
-   */
-  const removeableMuteWords = React.useMemo(() => {
-    return (
-      preferences?.moderationPrefs.mutedWords?.filter(word => {
-        return word.value === tag
-      }) || []
-    )
-  }, [tag, preferences?.moderationPrefs?.mutedWords])
-
-  return (
-    <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
-      {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`View all posts with tag ${displayTag}`)}
-              {...createStaticClick(() => {
-                control.close(() => {
-                  navigation.push('Hashtag', {
-                    tag: encodeURIComponent(tag),
-                  })
-                })
-              })}>
-              <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]}>
-                      {displayTag}
-                    </Text>{' '}
-                    posts
-                  </Trans>
-                </Text>
-              </View>
-            </Link>
-
-            {authorHandle && !isInvalidHandle(authorHandle) && (
-              <>
-                <Divider />
-
-                <Link
-                  label={_(
-                    msg`View all posts by @${authorHandle} with tag ${displayTag}`,
-                  )}
-                  {...createStaticClick(() => {
-                    control.close(() => {
-                      navigation.push('Hashtag', {
-                        tag: encodeURIComponent(tag),
-                        author: authorHandle,
-                      })
-                    })
-                  })}>
-                  <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]}>
-                          {displayTag}
-                        </Text>{' '}
-                        posts by this user
-                      </Trans>
-                    </Text>
-                  </View>
-                </Link>
-              </>
-            )}
-
-            {preferences ? (
-              <>
-                <Divider />
-
-                <Button
-                  label={
-                    isMuted
-                      ? _(msg`Unmute all ${displayTag} posts`)
-                      : _(msg`Mute all ${displayTag} posts`)
-                  }
-                  onPress={() => {
-                    control.close(() => {
-                      if (isMuted) {
-                        resetUpsert()
-                        removeMutedWords(removeableMuteWords)
-                      } else {
-                        resetRemove()
-                        upsertMutedWord([
-                          {
-                            value: tag,
-                            targets: ['tag'],
-                            actorTarget: 'all',
-                          },
-                        ])
-                      }
-                    })
-                  }}>
-                  <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]}>
-                        {displayTag}
-                      </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>
-              <Trans>Cancel</Trans>
-            </ButtonText>
-          </Button>
-        </>
-      )}
-    </Dialog.Inner>
-  )
-}
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
deleted file mode 100644
index b6c306439..000000000
--- a/src/components/TagMenu/index.web.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import React from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
-
-import {NavigationProp} from '#/lib/routes/types'
-import {isInvalidHandle} from '#/lib/strings/handles'
-import {enforceLen} from '#/lib/strings/helpers'
-import {
-  usePreferencesQuery,
-  useRemoveMutedWordsMutation,
-  useUpsertMutedWordsMutation,
-} from '#/state/queries/preferences'
-import {EventStopper} from '#/view/com/util/EventStopper'
-import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
-import {web} from '#/alf'
-import * as Dialog from '#/components/Dialog'
-
-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 {_} = useLingui()
-  const navigation = useNavigation<NavigationProp>()
-  const {data: preferences} = usePreferencesQuery()
-  const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
-    useUpsertMutedWordsMutation()
-  const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
-    useRemoveMutedWordsMutation()
-  const isMuted = Boolean(
-    (preferences?.moderationPrefs.mutedWords?.find(
-      m => m.value === tag && m.targets.includes('tag'),
-    ) ??
-      optimisticUpsert?.find(
-        m => m.value === tag && m.targets.includes('tag'),
-      )) &&
-      !optimisticRemove?.find(m => m?.value === tag),
-  )
-  const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
-
-  /*
-   * Mute word records that exactly match the tag in question.
-   */
-  const removeableMuteWords = React.useMemo(() => {
-    return (
-      preferences?.moderationPrefs.mutedWords?.filter(word => {
-        return word.value === tag
-      }) || []
-    )
-  }, [tag, preferences?.moderationPrefs?.mutedWords])
-
-  const dropdownItems = React.useMemo(() => {
-    return [
-      {
-        label: _(msg`See ${truncatedTag} posts`),
-        onPress() {
-          navigation.push('Hashtag', {
-            tag: encodeURIComponent(tag),
-          })
-        },
-        testID: 'tagMenuSearch',
-        icon: {
-          ios: {
-            name: 'magnifyingglass',
-          },
-          android: '',
-          web: 'magnifying-glass',
-        },
-      },
-      authorHandle &&
-        !isInvalidHandle(authorHandle) && {
-          label: _(msg`See ${truncatedTag} posts by user`),
-          onPress() {
-            navigation.push('Hashtag', {
-              tag: encodeURIComponent(tag),
-              author: authorHandle,
-            })
-          },
-          testID: 'tagMenuSearchByUser',
-          icon: {
-            ios: {
-              name: 'magnifyingglass',
-            },
-            android: '',
-            web: ['far', 'user'],
-          },
-        },
-      preferences && {
-        label: 'separator',
-      },
-      preferences && {
-        label: isMuted
-          ? _(msg`Unmute ${truncatedTag}`)
-          : _(msg`Mute ${truncatedTag}`),
-        onPress() {
-          if (isMuted) {
-            removeMutedWords(removeableMuteWords)
-          } else {
-            upsertMutedWord([
-              {value: tag, targets: ['tag'], actorTarget: 'all'},
-            ])
-          }
-        },
-        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,
-    truncatedTag,
-    upsertMutedWord,
-    removeMutedWords,
-    removeableMuteWords,
-  ])
-
-  return (
-    <EventStopper>
-      <NativeDropdown
-        accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
-        accessibilityHint=""
-        // @ts-ignore
-        items={dropdownItems}
-        triggerStyle={web({
-          textAlign: 'left',
-        })}>
-        {children}
-      </NativeDropdown>
-    </EventStopper>
-  )
-}
diff --git a/src/platform/urls.tsx b/src/platform/urls.tsx
index fd9d297aa..514bde43e 100644
--- a/src/platform/urls.tsx
+++ b/src/platform/urls.tsx
@@ -1,4 +1,4 @@
-import {GestureResponderEvent, Linking} from 'react-native'
+import {Linking} from 'react-native'
 
 import {isNative, isWeb} from './detection'
 
@@ -24,15 +24,3 @@ export function clearHash() {
     window.location.hash = ''
   }
 }
-
-export function shouldClickOpenNewTab(e: GestureResponderEvent) {
-  /**
-   * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
-   * of @ts-ignore below.
-   */
-  const event = e as any
-  const isMiddleClick = isWeb && event.button === 1
-  const isMetaKey =
-    isWeb && (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
-  return isMetaKey || isMiddleClick
-}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index a59148889..b0b608f17 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -17,7 +17,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {s} from '#/lib/styles'
 import {logger} from '#/logger'
-import {shouldClickOpenNewTab} from '#/platform/urls'
 import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
 import {
   useAddSavedFeedsMutation,
@@ -29,6 +28,7 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import * as Toast from '#/view/com/util/Toast'
 import {useTheme} from '#/alf'
 import {atoms as a} from '#/alf'
+import {shouldClickOpenNewTab} from '#/components/Link'
 import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
 import {Text} from '../util/text/Text'