about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/List.tsx16
-rw-r--r--src/view/com/util/List.web.tsx201
-rw-r--r--src/view/com/util/MainScrollProvider.tsx59
-rw-r--r--src/view/com/util/PostMeta.tsx65
-rw-r--r--src/view/com/util/TimeElapsed.tsx16
-rw-r--r--src/view/com/util/Views.jsx10
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx150
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx4
-rw-r--r--src/view/com/util/images/Gallery.tsx5
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx4
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx75
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
12 files changed, 440 insertions, 167 deletions
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 5729a43a5..84b401e63 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -5,15 +5,17 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useGate} from 'lib/statsig/statsig'
 import {addStyle} from 'lib/styles'
-import {isWeb} from 'platform/detection'
 import {FlatList_INTERNAL} from './Views'
 
 export type ListMethods = FlatList_INTERNAL
 export type ListProps<ItemT> = Omit<
   FlatListProps<ItemT>,
+  | 'onMomentumScrollBegin' // Use ScrollContext instead.
+  | 'onMomentumScrollEnd' // Use ScrollContext instead.
   | 'onScroll' // Use ScrollContext instead.
+  | 'onScrollBeginDrag' // Use ScrollContext instead.
+  | 'onScrollEndDrag' // Use ScrollContext instead.
   | 'refreshControl' // Pass refreshing and/or onRefresh instead.
   | 'contentOffset' // Pass headerOffset instead.
 > & {
@@ -21,6 +23,7 @@ export type ListProps<ItemT> = Omit<
   headerOffset?: number
   refreshing?: boolean
   onRefresh?: () => void
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
@@ -40,7 +43,6 @@ function ListImpl<ItemT>(
   const isScrolledDown = useSharedValue(false)
   const contextScrollHandlers = useScrollHandlers()
   const pal = usePalette('default')
-  const gate = useGate()
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     onScrolledDownChange?.(didScrollDown)
@@ -64,6 +66,11 @@ function ListImpl<ItemT>(
         }
       }
     },
+    // Note: adding onMomentumBegin here makes simulator scroll
+    // lag on Android. So either don't add it, or figure out why.
+    onMomentumEnd(e, ctx) {
+      contextScrollHandlers.onMomentumEnd?.(e, ctx)
+    },
   })
 
   let refreshControl
@@ -97,9 +104,6 @@ function ListImpl<ItemT>(
       scrollEventThrottle={1}
       style={style}
       ref={ref}
-      showsVerticalScrollIndicator={
-        isWeb || !gate('hide_vertical_scroll_indicators')
-      }
     />
   )
 }
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 936bac198..9bea2d795 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -1,11 +1,13 @@
-import React, {isValidElement, memo, useRef, startTransition} from 'react'
+import React, {isValidElement, memo, startTransition, useRef} from 'react'
 import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
-import {addStyle} from 'lib/styles'
+import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
+
+import {batchedUpdates} from '#/lib/batchedUpdates'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {useScrollHandlers} from '#/lib/ScrollContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {batchedUpdates} from '#/lib/batchedUpdates'
+import {addStyle} from 'lib/styles'
 
 export type ListMethods = any // TODO: Better types.
 export type ListProps<ItemT> = Omit<
@@ -19,6 +21,7 @@ export type ListProps<ItemT> = Omit<
   refreshing?: boolean
   onRefresh?: () => void
   desktopFixedHeight: any // TODO: Better types.
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
 
@@ -26,12 +29,15 @@ function ListImpl<ItemT>(
   {
     ListHeaderComponent,
     ListFooterComponent,
+    containWeb,
     contentContainerStyle,
     data,
     desktopFixedHeight,
     headerOffset,
     keyExtractor,
     refreshing: _unsupportedRefreshing,
+    onStartReached,
+    onStartReachedThreshold = 0,
     onEndReached,
     onEndReachedThreshold = 0,
     onRefresh: _unsupportedOnRefresh,
@@ -80,14 +86,88 @@ function ListImpl<ItemT>(
     })
   }
 
-  const nativeRef = React.useRef(null)
+  const getScrollableNode = React.useCallback(() => {
+    if (containWeb) {
+      const element = nativeRef.current as HTMLDivElement | null
+      if (!element) return
+
+      return {
+        get scrollWidth() {
+          return element.scrollWidth
+        },
+        get scrollHeight() {
+          return element.scrollHeight
+        },
+        get clientWidth() {
+          return element.clientWidth
+        },
+        get clientHeight() {
+          return element.clientHeight
+        },
+        get scrollY() {
+          return element.scrollTop
+        },
+        get scrollX() {
+          return element.scrollLeft
+        },
+        scrollTo(options?: ScrollToOptions) {
+          element.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          element.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          element.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          element.removeEventListener(event, handler)
+        },
+      }
+    } else {
+      return {
+        get scrollWidth() {
+          return document.documentElement.scrollWidth
+        },
+        get scrollHeight() {
+          return document.documentElement.scrollHeight
+        },
+        get clientWidth() {
+          return window.innerWidth
+        },
+        get clientHeight() {
+          return window.innerHeight
+        },
+        get scrollY() {
+          return window.scrollY
+        },
+        get scrollX() {
+          return window.scrollX
+        },
+        scrollTo(options: ScrollToOptions) {
+          window.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          window.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          window.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          window.removeEventListener(event, handler)
+        },
+      }
+    }
+  }, [containWeb])
+
+  const nativeRef = React.useRef<HTMLDivElement>(null)
   React.useImperativeHandle(
     ref,
     () =>
       ({
         scrollToTop() {
-          window.scrollTo({top: 0})
+          getScrollableNode()?.scrollTo({top: 0})
         },
+
         scrollToOffset({
           animated,
           offset,
@@ -95,46 +175,74 @@ function ListImpl<ItemT>(
           animated: boolean
           offset: number
         }) {
-          window.scrollTo({
+          getScrollableNode()?.scrollTo({
             left: 0,
             top: offset,
             behavior: animated ? 'smooth' : 'instant',
           })
         },
+        scrollToEnd({animated = true}: {animated?: boolean}) {
+          const element = getScrollableNode()
+          element?.scrollTo({
+            left: 0,
+            top: element.scrollHeight,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
       } as any), // TODO: Better types.
-    [],
+    [getScrollableNode],
   )
 
-  // --- onContentSizeChange ---
+  // --- onContentSizeChange, maintainVisibleContentPosition ---
   const containerRef = useRef(null)
   useResizeObserver(containerRef, onContentSizeChange)
 
   // --- onScroll ---
   const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
-  const handleWindowScroll = useNonReactiveCallback(() => {
-    if (isInsideVisibleTree) {
-      contextScrollHandlers.onScroll?.(
-        {
-          contentOffset: {
-            x: Math.max(0, window.scrollX),
-            y: Math.max(0, window.scrollY),
-          },
-        } as any, // TODO: Better types.
-        null as any,
-      )
-    }
+  const handleScroll = useNonReactiveCallback(() => {
+    if (!isInsideVisibleTree) return
+
+    const element = getScrollableNode()
+    contextScrollHandlers.onScroll?.(
+      {
+        contentOffset: {
+          x: Math.max(0, element?.scrollX ?? 0),
+          y: Math.max(0, element?.scrollY ?? 0),
+        },
+        layoutMeasurement: {
+          width: element?.clientWidth,
+          height: element?.clientHeight,
+        },
+        contentSize: {
+          width: element?.scrollWidth,
+          height: element?.scrollHeight,
+        },
+      } as Exclude<
+        ReanimatedScrollEvent,
+        | 'velocity'
+        | 'eventName'
+        | 'zoomScale'
+        | 'targetContentOffset'
+        | 'contentInset'
+      >,
+      null as any,
+    )
   })
+
   React.useEffect(() => {
     if (!isInsideVisibleTree) {
       // Prevents hidden tabs from firing scroll events.
       // Only one list is expected to be firing these at a time.
       return
     }
-    window.addEventListener('scroll', handleWindowScroll)
+
+    const element = getScrollableNode()
+
+    element?.addEventListener('scroll', handleScroll)
     return () => {
-      window.removeEventListener('scroll', handleWindowScroll)
+      element?.removeEventListener('scroll', handleScroll)
     }
-  }, [isInsideVisibleTree, handleWindowScroll])
+  }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
 
   // --- onScrolledDownChange ---
   const isScrolledDown = useRef(false)
@@ -148,6 +256,17 @@ function ListImpl<ItemT>(
     }
   }
 
+  // --- onStartReached ---
+  const onHeadVisibilityChange = useNonReactiveCallback(
+    (isHeadVisible: boolean) => {
+      if (isHeadVisible) {
+        onStartReached?.({
+          distanceFromStart: onStartReachedThreshold || 0,
+        })
+      }
+    },
+  )
+
   // --- onEndReached ---
   const onTailVisibilityChange = useNonReactiveCallback(
     (isTailVisible: boolean) => {
@@ -160,7 +279,17 @@ function ListImpl<ItemT>(
   )
 
   return (
-    <View {...props} style={style} ref={nativeRef}>
+    <View
+      {...props}
+      style={[
+        style,
+        containWeb && {
+          flex: 1,
+          // @ts-expect-error web only
+          'overflow-y': 'scroll',
+        },
+      ]}
+      ref={nativeRef as any}>
       <Visibility
         onVisibleChange={setIsInsideVisibleTree}
         style={
@@ -178,9 +307,17 @@ function ListImpl<ItemT>(
           pal.border,
         ]}>
         <Visibility
+          root={containWeb ? nativeRef : null}
           onVisibleChange={handleAboveTheFoldVisibleChange}
           style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
         />
+        {onStartReached && (
+          <Visibility
+            root={containWeb ? nativeRef : null}
+            onVisibleChange={onHeadVisibilityChange}
+            topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
+          />
+        )}
         {header}
         {(data as Array<ItemT>).map((item, index) => (
           <Row<ItemT>
@@ -193,8 +330,9 @@ function ListImpl<ItemT>(
         ))}
         {onEndReached && (
           <Visibility
-            topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
+            root={containWeb ? nativeRef : null}
             onVisibleChange={onTailVisibilityChange}
+            bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
           />
         )}
         {footer}
@@ -255,11 +393,15 @@ let Row = function RowImpl<ItemT>({
 Row = React.memo(Row)
 
 let Visibility = ({
+  root,
   topMargin = '0px',
+  bottomMargin = '0px',
   onVisibleChange,
   style,
 }: {
+  root?: React.RefObject<HTMLDivElement> | null
   topMargin?: string
+  bottomMargin?: string
   onVisibleChange: (isVisible: boolean) => void
   style?: ViewProps['style']
 }): React.ReactNode => {
@@ -281,14 +423,15 @@ let Visibility = ({
 
   React.useEffect(() => {
     const observer = new IntersectionObserver(handleIntersection, {
-      rootMargin: `${topMargin} 0px 0px 0px`,
+      root: root?.current ?? null,
+      rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
     })
     const tail: Element | null = tailRef.current!
     observer.observe(tail)
     return () => {
       observer.unobserve(tail)
     }
-  }, [handleIntersection, topMargin])
+  }, [bottomMargin, handleIntersection, topMargin, root])
 
   return (
     <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 01b8a954d..f45229dc4 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -1,11 +1,12 @@
 import React, {useCallback, useEffect} from 'react'
+import {NativeScrollEvent} from 'react-native'
+import {interpolate, useSharedValue} from 'react-native-reanimated'
 import EventEmitter from 'eventemitter3'
+
 import {ScrollProvider} from '#/lib/ScrollContext'
-import {NativeScrollEvent} from 'react-native'
-import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
+import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
 import {useShellLayout} from '#/state/shell/shell-layout'
 import {isNative, isWeb} from 'platform/detection'
-import {useSharedValue, interpolate} from 'react-native-reanimated'
 
 const WEB_HIDE_SHELL_THRESHOLD = 200
 
@@ -32,6 +33,31 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     }
   })
 
+  const snapToClosestState = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      if (isNative) {
+        if (startDragOffset.value === null) {
+          return
+        }
+        const didScrollDown = e.contentOffset.y > startDragOffset.value
+        startDragOffset.value = null
+        startMode.value = null
+        if (e.contentOffset.y < headerHeight.value) {
+          // If we're close to the top, show the shell.
+          setMode(false)
+        } else if (didScrollDown) {
+          // Showing the bar again on scroll down feels annoying, so don't.
+          setMode(true)
+        } else {
+          // Snap to whichever state is the closest.
+          setMode(Math.round(mode.value) === 1)
+        }
+      }
+    },
+    [startDragOffset, startMode, setMode, mode, headerHeight],
+  )
+
   const onBeginDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
@@ -47,18 +73,24 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     (e: NativeScrollEvent) => {
       'worklet'
       if (isNative) {
-        startDragOffset.value = null
-        startMode.value = null
-        if (e.contentOffset.y < headerHeight.value / 2) {
-          // If we're close to the top, show the shell.
-          setMode(false)
-        } else {
-          // Snap to whichever state is the closest.
-          setMode(Math.round(mode.value) === 1)
+        if (e.velocity && e.velocity.y !== 0) {
+          // If we detect a velocity, wait for onMomentumEnd to snap.
+          return
         }
+        snapToClosestState(e)
       }
     },
-    [startDragOffset, startMode, setMode, mode, headerHeight],
+    [snapToClosestState],
+  )
+
+  const onMomentumEnd = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      if (isNative) {
+        snapToClosestState(e)
+      }
+    },
+    [snapToClosestState],
   )
 
   const onScroll = useCallback(
@@ -119,7 +151,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     <ScrollProvider
       onBeginDrag={onBeginDrag}
       onEndDrag={onEndDrag}
-      onScroll={onScroll}>
+      onScroll={onScroll}
+      onMomentumEnd={onMomentumEnd}>
       {children}
     </ScrollProvider>
   )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index db16ff066..e7ce18535 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {niceDate} from 'lib/strings/time'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {isAndroid, isWeb} from 'platform/detection'
+import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {TextLinkOnWebOnly} from './Link'
 import {Text} from './text/Text'
 import {TimeElapsed} from './TimeElapsed'
@@ -58,37 +59,39 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
           />
         </View>
       )}
-      <Text
-        numberOfLines={1}
-        style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
-        <TextLinkOnWebOnly
-          type={opts.displayNameType || 'lg-bold'}
-          style={[pal.text]}
-          lineHeight={1.2}
-          disableMismatchWarning
-          text={
-            <>
-              {sanitizeDisplayName(
-                displayName,
-                opts.moderation?.ui('displayName'),
-              )}
-            </>
-          }
-          href={profileLink}
-          onBeforePress={onBeforePress}
-          onPointerEnter={onPointerEnter}
-        />
-        <TextLinkOnWebOnly
-          type="md"
-          disableMismatchWarning
-          style={[pal.textLight, {flexShrink: 4}]}
-          text={'\xa0' + sanitizeHandle(handle, '@')}
-          href={profileLink}
-          onBeforePress={onBeforePress}
-          onPointerEnter={onPointerEnter}
-          anchorNoUnderline
-        />
-      </Text>
+      <ProfileHoverCard inline did={opts.author.did}>
+        <Text
+          numberOfLines={1}
+          style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
+          <TextLinkOnWebOnly
+            type={opts.displayNameType || 'lg-bold'}
+            style={[pal.text]}
+            lineHeight={1.2}
+            disableMismatchWarning
+            text={
+              <>
+                {sanitizeDisplayName(
+                  displayName,
+                  opts.moderation?.ui('displayName'),
+                )}
+              </>
+            }
+            href={profileLink}
+            onBeforePress={onBeforePress}
+            onPointerEnter={onPointerEnter}
+          />
+          <TextLinkOnWebOnly
+            type="md"
+            disableMismatchWarning
+            style={[pal.textLight, {flexShrink: 4}]}
+            text={'\xa0' + sanitizeHandle(handle, '@')}
+            href={profileLink}
+            onBeforePress={onBeforePress}
+            onPointerEnter={onPointerEnter}
+            anchorNoUnderline
+          />
+        </Text>
+      </ProfileHoverCard>
       {!isAndroid && (
         <Text
           type="md"
diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx
index 6ea41b82b..a5d3a5372 100644
--- a/src/view/com/util/TimeElapsed.tsx
+++ b/src/view/com/util/TimeElapsed.tsx
@@ -3,21 +3,25 @@ import React from 'react'
 import {useTickEveryMinute} from '#/state/shell'
 import {ago} from 'lib/strings/time'
 
-// FIXME(dan): Figure out why the false positives
-
 export function TimeElapsed({
   timestamp,
   children,
+  timeToString = ago,
 }: {
   timestamp: string
   children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
+  timeToString?: (timeElapsed: string) => string
 }) {
   const tick = useTickEveryMinute()
-  const [timeElapsed, setTimeAgo] = React.useState(() => ago(timestamp))
+  const [timeElapsed, setTimeAgo] = React.useState(() =>
+    timeToString(timestamp),
+  )
 
-  React.useEffect(() => {
-    setTimeAgo(ago(timestamp))
-  }, [timestamp, setTimeAgo, tick])
+  const [prevTick, setPrevTick] = React.useState(tick)
+  if (prevTick !== tick) {
+    setPrevTick(tick)
+    setTimeAgo(timeToString(timestamp))
+  }
 
   return children({timeElapsed})
 }
diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx
index 75f2b5081..2984a2d2d 100644
--- a/src/view/com/util/Views.jsx
+++ b/src/view/com/util/Views.jsx
@@ -2,19 +2,11 @@ import React from 'react'
 import {View} from 'react-native'
 import Animated from 'react-native-reanimated'
 
-import {useGate} from 'lib/statsig/statsig'
-
 export const FlatList_INTERNAL = Animated.FlatList
 export function CenteredView(props) {
   return <View {...props} />
 }
 
 export function ScrollView(props) {
-  const gate = useGate()
-  return (
-    <Animated.ScrollView
-      {...props}
-      showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')}
-    />
-  )
+  return <Animated.ScrollView {...props} />
 }
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
index 94591d393..6668ac211 100644
--- a/src/view/com/util/forms/NativeDropdown.web.tsx
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -1,12 +1,13 @@
 import React from 'react'
+import {Pressable, StyleSheet, Text, View, ViewStyle} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
-import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
-import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+
+import {HITSLOP_10} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
-import {HITSLOP_10} from 'lib/constants'
 
 // Custom Dropdown Menu Components
 // ==
@@ -64,15 +65,9 @@ export function NativeDropdown({
   accessibilityHint,
   triggerStyle,
 }: React.PropsWithChildren<Props>) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const dropDownBackgroundColor =
-    theme.colorScheme === 'dark' ? pal.btn : pal.view
   const [open, setOpen] = React.useState(false)
   const buttonRef = React.useRef<HTMLButtonElement>(null)
   const menuRef = React.useRef<HTMLDivElement>(null)
-  const {borderColor: separatorColor} =
-    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
 
   React.useEffect(() => {
     function clickHandler(e: MouseEvent) {
@@ -114,14 +109,27 @@ export function NativeDropdown({
 
   return (
     <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
-      <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
+      <DropdownMenu.Trigger asChild>
         <Pressable
           ref={buttonRef as unknown as React.Ref<View>}
           testID={testID}
           accessibilityRole="button"
           accessibilityLabel={accessibilityLabel}
           accessibilityHint={accessibilityHint}
-          onPress={() => setOpen(o => !o)}
+          onPointerDown={e => {
+            // Prevent false positive that interpret mobile scroll as a tap.
+            // This requires the custom onPress handler below to compensate.
+            // https://github.com/radix-ui/primitives/issues/1912
+            e.preventDefault()
+          }}
+          onPress={() => {
+            if (window.event instanceof KeyboardEvent) {
+              // The onPointerDown hack above is not relevant to this press, so don't do anything.
+              return
+            }
+            // Compensate for the disabled onPointerDown above by triggering it manually.
+            setOpen(o => !o)
+          }}
           hitSlop={HITSLOP_10}
           style={triggerStyle}>
           {children}
@@ -129,53 +137,53 @@ export function NativeDropdown({
       </DropdownMenu.Trigger>
 
       <DropdownMenu.Portal>
-        <DropdownMenu.Content
-          ref={menuRef}
-          style={
-            StyleSheet.flatten([
-              styles.content,
-              dropDownBackgroundColor,
-            ]) as React.CSSProperties
-          }
-          loop>
-          {items.map((item, index) => {
-            if (item.label === 'separator') {
-              return (
-                <DropdownMenu.Separator
-                  key={getKey(item.label, index, item.testID)}
-                  style={
-                    StyleSheet.flatten([
-                      styles.separator,
-                      {backgroundColor: separatorColor},
-                    ]) as React.CSSProperties
-                  }
-                />
-              )
-            }
-            if (index > 1 && items[index - 1].label === 'separator') {
-              return (
-                <DropdownMenu.Group
-                  key={getKey(item.label, index, item.testID)}>
-                  <DropdownMenuItem
-                    key={getKey(item.label, index, item.testID)}
-                    onSelect={item.onPress}>
-                    <Text
-                      selectable={false}
-                      style={[pal.text, styles.itemTitle]}>
-                      {item.label}
-                    </Text>
-                    {item.icon && (
-                      <FontAwesomeIcon
-                        icon={item.icon.web}
-                        size={20}
-                        color={pal.colors.textLight}
-                      />
-                    )}
-                  </DropdownMenuItem>
-                </DropdownMenu.Group>
-              )
-            }
-            return (
+        <DropdownContent items={items} menuRef={menuRef} />
+      </DropdownMenu.Portal>
+    </DropdownMenuRoot>
+  )
+}
+
+function DropdownContent({
+  items,
+  menuRef,
+}: {
+  items: DropdownItem[]
+  menuRef: React.RefObject<HTMLDivElement>
+}) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+  const {borderColor: separatorColor} =
+    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+  return (
+    <DropdownMenu.Content
+      ref={menuRef}
+      style={
+        StyleSheet.flatten([
+          styles.content,
+          dropDownBackgroundColor,
+        ]) as React.CSSProperties
+      }
+      loop>
+      {items.map((item, index) => {
+        if (item.label === 'separator') {
+          return (
+            <DropdownMenu.Separator
+              key={getKey(item.label, index, item.testID)}
+              style={
+                StyleSheet.flatten([
+                  styles.separator,
+                  {backgroundColor: separatorColor},
+                ]) as React.CSSProperties
+              }
+            />
+          )
+        }
+        if (index > 1 && items[index - 1].label === 'separator') {
+          return (
+            <DropdownMenu.Group key={getKey(item.label, index, item.testID)}>
               <DropdownMenuItem
                 key={getKey(item.label, index, item.testID)}
                 onSelect={item.onPress}>
@@ -190,11 +198,27 @@ export function NativeDropdown({
                   />
                 )}
               </DropdownMenuItem>
-            )
-          })}
-        </DropdownMenu.Content>
-      </DropdownMenu.Portal>
-    </DropdownMenuRoot>
+            </DropdownMenu.Group>
+          )
+        }
+        return (
+          <DropdownMenuItem
+            key={getKey(item.label, index, item.testID)}
+            onSelect={item.onPress}>
+            <Text selectable={false} style={[pal.text, styles.itemTitle]}>
+              {item.label}
+            </Text>
+            {item.icon && (
+              <FontAwesomeIcon
+                icon={item.icon.web}
+                size={20}
+                color={pal.colors.textLight}
+              />
+            )}
+          </DropdownMenuItem>
+        )
+      })}
+    </DropdownMenu.Content>
   )
 }
 
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 32520182e..ac97f3da2 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,6 +1,6 @@
 import React, {memo} from 'react'
 import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
-import {setStringAsync} from 'expo-clipboard'
+import * as Clipboard from 'expo-clipboard'
 import {
   AppBskyActorDefs,
   AppBskyFeedPost,
@@ -160,7 +160,7 @@ let PostDropdownBtn = ({
   const onCopyPostText = React.useCallback(() => {
     const str = richTextToString(richText, true)
 
-    setStringAsync(str)
+    Clipboard.setStringAsync(str)
     Toast.show(_(msg`Copied to clipboard`))
   }, [_, richText])
 
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 7de3b093a..f6d2c7a1b 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,9 +1,10 @@
-import {AppBskyEmbedImages} from '@atproto/api'
 import React, {ComponentProps, FC} from 'react'
-import {StyleSheet, Text, Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, Text, View} from 'react-native'
 import {Image} from 'expo-image'
+import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+
 import {isWeb} from 'platform/detection'
 
 type EventFunction = (index: number) => void
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 1fe75c44e..b84c04b83 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -20,9 +20,11 @@ import {Text} from '../text/Text'
 export const ExternalLinkEmbed = ({
   link,
   style,
+  hideAlt,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
   style?: StyleProp<ViewStyle>
+  hideAlt?: boolean
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
@@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({
   }, [link.uri, externalEmbedPrefs])
 
   if (embedPlayerParams?.source === 'tenor') {
-    return <GifEmbed params={embedPlayerParams} link={link} />
+    return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
   }
 
   return (
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 5d21ce064..286b57992 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -1,14 +1,18 @@
 import React from 'react'
-import {Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HITSLOP_10} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
 import {EmbedPlayerParams} from 'lib/strings/embed-player'
 import {useAutoplayDisabled} from 'state/preferences'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
 import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
 import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
 
@@ -82,9 +86,11 @@ function PlaybackControls({
 export function GifEmbed({
   params,
   link,
+  hideAlt,
 }: {
   params: EmbedPlayerParams
   link: AppBskyEmbedExternal.ViewExternal
+  hideAlt?: boolean
 }) {
   const {_} = useLingui()
   const autoplayDisabled = useAutoplayDisabled()
@@ -111,7 +117,8 @@ export function GifEmbed({
   }, [])
 
   return (
-    <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
+    <View
+      style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
       <View
         style={[
           a.rounded_sm,
@@ -133,9 +140,69 @@ export function GifEmbed({
           onPlayerStateChange={onPlayerStateChange}
           ref={playerRef}
           accessibilityHint={_(msg`Animated GIF`)}
-          accessibilityLabel={link.description.replace('ALT: ', '')}
+          accessibilityLabel={link.description.replace('Alt text: ', '')}
         />
+
+        {!hideAlt && link.description.startsWith('Alt text: ') && (
+          <AltText text={link.description.replace('Alt text: ', '')} />
+        )}
       </View>
     </View>
   )
 }
+
+function AltText({text}: {text: string}) {
+  const control = Prompt.usePromptControl()
+
+  const {_} = useLingui()
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Show alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={styles.altContainer}>
+        <Text style={styles.alt} accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Alt Text</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            onPress={control.close}
+            cta={_(msg`Close`)}
+            color="secondary"
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    // Related to margin/gap hack. This keeps the alt label in the same position
+    // on all platforms
+    left: isWeb ? 8 : 5,
+    bottom: isWeb ? 8 : 5,
+    zIndex: 2,
+  },
+  alt: {
+    color: 'white',
+    fontSize: 10,
+    fontWeight: 'bold',
+  },
+})
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index e0178f34b..0e19a6ccd 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -25,7 +25,7 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import {HITSLOP_20} from '#/lib/constants'
 import {s} from '#/lib/styles'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {usePalette} from 'lib/hooks/usePalette'
 import {InfoCircleIcon} from 'lib/icons'
 import {makeProfileLink} from 'lib/routes/links'