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/AccountDropdownBtn.tsx2
-rw-r--r--src/view/com/util/BlurView.android.tsx30
-rw-r--r--src/view/com/util/ErrorBoundary.tsx3
-rw-r--r--src/view/com/util/Link.tsx21
-rw-r--r--src/view/com/util/List.tsx6
-rw-r--r--src/view/com/util/List.web.tsx341
-rw-r--r--src/view/com/util/MainScrollProvider.tsx124
-rw-r--r--src/view/com/util/Selector.tsx7
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx16
-rw-r--r--src/view/com/util/Toast.web.tsx3
-rw-r--r--src/view/com/util/ViewHeader.tsx7
-rw-r--r--src/view/com/util/error/ErrorMessage.tsx4
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx6
-rw-r--r--src/view/com/util/fab/FABInner.tsx4
-rw-r--r--src/view/com/util/forms/DateInput.tsx13
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx8
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx241
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx34
-rw-r--r--src/view/com/util/forms/SearchInput.tsx6
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx5
-rw-r--r--src/view/com/util/images/Gallery.tsx5
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx8
-rw-r--r--src/view/com/util/moderation/PostHider.tsx8
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx18
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx7
-rw-r--r--src/view/com/util/post-embeds/ExternalGifEmbed.tsx170
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx91
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx148
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx58
-rw-r--r--src/view/com/util/post-embeds/index.tsx35
-rw-r--r--src/view/com/util/text/RichText.tsx50
-rw-r--r--src/view/com/util/text/Text.tsx16
32 files changed, 1235 insertions, 260 deletions
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx
index 76d493886..221879df7 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -22,7 +22,7 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
       label: _(msg`Remove account`),
       onPress: () => {
         removeAccount(account)
-        Toast.show('Account removed from quick access')
+        Toast.show(_(msg`Account removed from quick access`))
       },
       icon: {
         ios: {
diff --git a/src/view/com/util/BlurView.android.tsx b/src/view/com/util/BlurView.android.tsx
new file mode 100644
index 000000000..eee1d9d86
--- /dev/null
+++ b/src/view/com/util/BlurView.android.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {StyleSheet, View, ViewProps} from 'react-native'
+import {addStyle} from 'lib/styles'
+
+type BlurViewProps = ViewProps & {
+  blurType?: 'dark' | 'light'
+  blurAmount?: number
+}
+
+export const BlurView = ({
+  style,
+  blurType,
+  ...props
+}: React.PropsWithChildren<BlurViewProps>) => {
+  if (blurType === 'dark') {
+    style = addStyle(style, styles.dark)
+  } else {
+    style = addStyle(style, styles.light)
+  }
+  return <View style={style} {...props} />
+}
+
+const styles = StyleSheet.create({
+  dark: {
+    backgroundColor: '#0008',
+  },
+  light: {
+    backgroundColor: '#fff8',
+  },
+})
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 397588cfb..5ec1d0014 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -2,6 +2,7 @@ import React, {Component, ErrorInfo, ReactNode} from 'react'
 import {ErrorScreen} from './error/ErrorScreen'
 import {CenteredView} from './Views'
 import {t} from '@lingui/macro'
+import {logger} from '#/logger'
 
 interface Props {
   children?: ReactNode
@@ -23,7 +24,7 @@ export class ErrorBoundary extends Component<Props, State> {
   }
 
   public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
-    console.error('Uncaught error:', error, errorInfo)
+    logger.error(error, {errorInfo})
   }
 
   public render() {
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index dcbec7cb4..db26258d6 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,6 +1,5 @@
 import React, {ComponentProps, memo, useMemo} from 'react'
 import {
-  Linking,
   GestureResponderEvent,
   Platform,
   StyleProp,
@@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
 import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
 import {useModalControls} from '#/state/modals'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 
 type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -65,6 +65,7 @@ export const Link = memo(function Link({
   const {closeModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
+  const openLink = useOpenLink()
 
   const onPress = React.useCallback(
     (e?: Event) => {
@@ -74,11 +75,12 @@ export const Link = memo(function Link({
           navigation,
           sanitizeUrl(href),
           navigationAction,
+          openLink,
           e,
         )
       }
     },
-    [closeModal, navigation, navigationAction, href],
+    [closeModal, navigation, navigationAction, href, openLink],
   )
 
   if (noFeedback) {
@@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({
   const {...props} = useLinkProps({to: sanitizeUrl(href)})
   const navigation = useNavigation<NavigationProp>()
   const {openModal, closeModal} = useModalControls()
+  const openLink = useOpenLink()
 
   if (warnOnMismatchingLabel && typeof text !== 'string') {
     console.error('Unable to detect mismatching label')
@@ -200,6 +203,7 @@ export const TextLink = memo(function TextLink({
         navigation,
         sanitizeUrl(href),
         navigationAction,
+        openLink,
         e,
       )
     },
@@ -212,6 +216,7 @@ export const TextLink = memo(function TextLink({
       text,
       warnOnMismatchingLabel,
       navigationAction,
+      openLink,
     ],
   )
   const hrefAttrs = useMemo(() => {
@@ -301,6 +306,8 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
   )
 })
 
+const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
+
 // NOTE
 // we can't use the onPress given by useLinkProps because it will
 // match most paths to the HomeTab routes while we actually want to
@@ -317,6 +324,7 @@ function onPressInner(
   navigation: NavigationProp,
   href: string,
   navigationAction: 'push' | 'replace' | 'navigate' = 'push',
+  openLink: (href: string) => void,
   e?: Event,
 ) {
   let shouldHandle = false
@@ -344,8 +352,13 @@ function onPressInner(
 
   if (shouldHandle) {
     href = convertBskyAppUrlIfNeeded(href)
-    if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
-      Linking.openURL(href)
+    if (
+      newTab ||
+      href.startsWith('http') ||
+      href.startsWith('mailto') ||
+      EXEMPT_PATHS.some(path => href.startsWith(path))
+    ) {
+      openLink(href)
     } else {
       closeModal() // close any active modals
 
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 9abd7d35a..d30a9d805 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,4 +1,4 @@
-import React, {memo, startTransition} from 'react'
+import React, {memo} from 'react'
 import {FlatListProps, RefreshControl} from 'react-native'
 import {FlatList_INTERNAL} from './Views'
 import {addStyle} from 'lib/styles'
@@ -39,9 +39,7 @@ function ListImpl<ItemT>(
   const pal = usePalette('default')
 
   function handleScrolledDownChange(didScrollDown: boolean) {
-    startTransition(() => {
-      onScrolledDownChange?.(didScrollDown)
-    })
+    onScrolledDownChange?.(didScrollDown)
   }
 
   const scrollHandler = useAnimatedScrollHandler({
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
new file mode 100644
index 000000000..3e81a8c37
--- /dev/null
+++ b/src/view/com/util/List.web.tsx
@@ -0,0 +1,341 @@
+import React, {isValidElement, memo, useRef, startTransition} from 'react'
+import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
+import {addStyle} from 'lib/styles'
+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'
+
+export type ListMethods = any // TODO: Better types.
+export type ListProps<ItemT> = Omit<
+  FlatListProps<ItemT>,
+  | 'onScroll' // Use ScrollContext instead.
+  | 'refreshControl' // Pass refreshing and/or onRefresh instead.
+  | 'contentOffset' // Pass headerOffset instead.
+> & {
+  onScrolledDownChange?: (isScrolledDown: boolean) => void
+  headerOffset?: number
+  refreshing?: boolean
+  onRefresh?: () => void
+  desktopFixedHeight: any // TODO: Better types.
+}
+export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
+
+function ListImpl<ItemT>(
+  {
+    ListHeaderComponent,
+    ListFooterComponent,
+    contentContainerStyle,
+    data,
+    desktopFixedHeight,
+    headerOffset,
+    keyExtractor,
+    refreshing: _unsupportedRefreshing,
+    onEndReached,
+    onEndReachedThreshold = 0,
+    onRefresh: _unsupportedOnRefresh,
+    onScrolledDownChange,
+    onContentSizeChange,
+    renderItem,
+    extraData,
+    style,
+    ...props
+  }: ListProps<ItemT>,
+  ref: React.Ref<ListMethods>,
+) {
+  const contextScrollHandlers = useScrollHandlers()
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  if (!isMobile) {
+    contentContainerStyle = addStyle(
+      contentContainerStyle,
+      styles.containerScroll,
+    )
+  }
+
+  let header: JSX.Element | null = null
+  if (ListHeaderComponent != null) {
+    if (isValidElement(ListHeaderComponent)) {
+      header = ListHeaderComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      header = <ListHeaderComponent />
+    }
+  }
+
+  let footer: JSX.Element | null = null
+  if (ListFooterComponent != null) {
+    if (isValidElement(ListFooterComponent)) {
+      footer = ListFooterComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      footer = <ListFooterComponent />
+    }
+  }
+
+  if (headerOffset != null) {
+    style = addStyle(style, {
+      paddingTop: headerOffset,
+    })
+  }
+
+  const nativeRef = React.useRef(null)
+  React.useImperativeHandle(
+    ref,
+    () =>
+      ({
+        scrollToTop() {
+          window.scrollTo({top: 0})
+        },
+        scrollToOffset({
+          animated,
+          offset,
+        }: {
+          animated: boolean
+          offset: number
+        }) {
+          window.scrollTo({
+            left: 0,
+            top: offset,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
+      } as any), // TODO: Better types.
+    [],
+  )
+
+  // --- onContentSizeChange ---
+  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,
+      )
+    }
+  })
+  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)
+    return () => {
+      window.removeEventListener('scroll', handleWindowScroll)
+    }
+  }, [isInsideVisibleTree, handleWindowScroll])
+
+  // --- onScrolledDownChange ---
+  const isScrolledDown = useRef(false)
+  function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
+    const didScrollDown = !isAboveTheFold
+    if (isScrolledDown.current !== didScrollDown) {
+      isScrolledDown.current = didScrollDown
+      startTransition(() => {
+        onScrolledDownChange?.(didScrollDown)
+      })
+    }
+  }
+
+  // --- onEndReached ---
+  const onTailVisibilityChange = useNonReactiveCallback(
+    (isTailVisible: boolean) => {
+      if (isTailVisible) {
+        onEndReached?.({
+          distanceFromEnd: onEndReachedThreshold || 0,
+        })
+      }
+    },
+  )
+
+  return (
+    <View {...props} style={style} ref={nativeRef}>
+      <Visibility
+        onVisibleChange={setIsInsideVisibleTree}
+        style={
+          // This has position: fixed, so it should always report as visible
+          // unless we're within a display: none tree (like a hidden tab).
+          styles.parentTreeVisibilityDetector
+        }
+      />
+      <View
+        ref={containerRef}
+        style={[
+          styles.contentContainer,
+          contentContainerStyle,
+          desktopFixedHeight ? styles.minHeightViewport : null,
+          pal.border,
+        ]}>
+        <Visibility
+          onVisibleChange={handleAboveTheFoldVisibleChange}
+          style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
+        />
+        {header}
+        {(data as Array<ItemT>).map((item, index) => (
+          <Row<ItemT>
+            key={keyExtractor!(item, index)}
+            item={item}
+            index={index}
+            renderItem={renderItem}
+            extraData={extraData}
+          />
+        ))}
+        {onEndReached && (
+          <Visibility
+            topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
+            onVisibleChange={onTailVisibilityChange}
+          />
+        )}
+        {footer}
+      </View>
+    </View>
+  )
+}
+
+function useResizeObserver(
+  ref: React.RefObject<Element>,
+  onResize: undefined | ((w: number, h: number) => void),
+) {
+  const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
+  const isActive = !!onResize
+  React.useEffect(() => {
+    if (!isActive) {
+      return
+    }
+    const resizeObserver = new ResizeObserver(entries => {
+      batchedUpdates(() => {
+        for (let entry of entries) {
+          const rect = entry.contentRect
+          handleResize(rect.width, rect.height)
+        }
+      })
+    })
+    const node = ref.current!
+    resizeObserver.observe(node)
+    return () => {
+      resizeObserver.unobserve(node)
+    }
+  }, [handleResize, isActive, ref])
+}
+
+let Row = function RowImpl<ItemT>({
+  item,
+  index,
+  renderItem,
+  extraData: _unused,
+}: {
+  item: ItemT
+  index: number
+  renderItem:
+    | null
+    | undefined
+    | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
+  extraData: any
+}): React.ReactNode {
+  if (!renderItem) {
+    return null
+  }
+  return (
+    <View style={styles.row}>
+      {renderItem({item, index, separators: null as any})}
+    </View>
+  )
+}
+Row = React.memo(Row)
+
+let Visibility = ({
+  topMargin = '0px',
+  onVisibleChange,
+  style,
+}: {
+  topMargin?: string
+  onVisibleChange: (isVisible: boolean) => void
+  style?: ViewProps['style']
+}): React.ReactNode => {
+  const tailRef = React.useRef(null)
+  const isIntersecting = React.useRef(false)
+
+  const handleIntersection = useNonReactiveCallback(
+    (entries: IntersectionObserverEntry[]) => {
+      batchedUpdates(() => {
+        entries.forEach(entry => {
+          if (entry.isIntersecting !== isIntersecting.current) {
+            isIntersecting.current = entry.isIntersecting
+            onVisibleChange(entry.isIntersecting)
+          }
+        })
+      })
+    },
+  )
+
+  React.useEffect(() => {
+    const observer = new IntersectionObserver(handleIntersection, {
+      rootMargin: `${topMargin} 0px 0px 0px`,
+    })
+    const tail: Element | null = tailRef.current!
+    observer.observe(tail)
+    return () => {
+      observer.unobserve(tail)
+    }
+  }, [handleIntersection, topMargin])
+
+  return (
+    <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
+  )
+}
+Visibility = React.memo(Visibility)
+
+export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
+  props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
+) => React.ReactElement
+
+const styles = StyleSheet.create({
+  contentContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  containerScroll: {
+    width: '100%',
+    maxWidth: 600,
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  row: {
+    // @ts-ignore web only
+    contentVisibility: 'auto',
+  },
+  minHeightViewport: {
+    // @ts-ignore web only
+    minHeight: '100vh',
+  },
+  parentTreeVisibilityDetector: {
+    // @ts-ignore web only
+    position: 'fixed',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  aboveTheFoldDetector: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    // Bottom is dynamic.
+  },
+  visibilityDetector: {
+    pointerEvents: 'none',
+    zIndex: -1,
+  },
+})
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 31a4ef0c8..2c90e33ff 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -1,11 +1,14 @@
-import React, {useCallback} from 'react'
+import React, {useCallback, useEffect} from 'react'
+import EventEmitter from 'eventemitter3'
 import {ScrollProvider} from '#/lib/ScrollContext'
 import {NativeScrollEvent} from 'react-native'
 import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
 import {useShellLayout} from '#/state/shell/shell-layout'
-import {isWeb} from 'platform/detection'
+import {isNative, isWeb} from 'platform/detection'
 import {useSharedValue, interpolate} from 'react-native-reanimated'
 
+const WEB_HIDE_SHELL_THRESHOLD = 200
+
 function clamp(num: number, min: number, max: number) {
   'worklet'
   return Math.min(Math.max(num, min), max)
@@ -18,11 +21,22 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const startDragOffset = useSharedValue<number | null>(null)
   const startMode = useSharedValue<number | null>(null)
 
+  useEffect(() => {
+    if (isWeb) {
+      return listenToForcedWindowScroll(() => {
+        startDragOffset.value = null
+        startMode.value = null
+      })
+    }
+  })
+
   const onBeginDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      startDragOffset.value = e.contentOffset.y
-      startMode.value = mode.value
+      if (isNative) {
+        startDragOffset.value = e.contentOffset.y
+        startMode.value = mode.value
+      }
     },
     [mode, startDragOffset, startMode],
   )
@@ -30,14 +44,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const onEndDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      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 (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)
+        }
       }
     },
     [startDragOffset, startMode, setMode, mode, headerHeight],
@@ -46,41 +62,40 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const onScroll = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      if (startDragOffset.value === null || startMode.value === null) {
-        if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
-          // If we're close enough to the top, always show the shell.
-          // Even if we're not dragging.
-          setMode(false)
+      if (isNative) {
+        if (startDragOffset.value === null || startMode.value === null) {
+          if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
+            // If we're close enough to the top, always show the shell.
+            // Even if we're not dragging.
+            setMode(false)
+          }
           return
         }
-        if (isWeb) {
-          // On the web, there is no concept of "starting" the drag.
-          // When we get the first scroll event, we consider that the start.
-          startDragOffset.value = e.contentOffset.y
-          startMode.value = mode.value
-        }
-        return
-      }
 
-      // The "mode" value is always between 0 and 1.
-      // Figure out how much to move it based on the current dragged distance.
-      const dy = e.contentOffset.y - startDragOffset.value
-      const dProgress = interpolate(
-        dy,
-        [-headerHeight.value, headerHeight.value],
-        [-1, 1],
-      )
-      const newValue = clamp(startMode.value + dProgress, 0, 1)
-      if (newValue !== mode.value) {
-        // Manually adjust the value. This won't be (and shouldn't be) animated.
-        mode.value = newValue
-      }
-      if (isWeb) {
-        // On the web, there is no concept of "starting" the drag,
-        // so we don't have any specific anchor point to calculate the distance.
-        // Instead, update it continuosly along the way and diff with the last event.
+        // The "mode" value is always between 0 and 1.
+        // Figure out how much to move it based on the current dragged distance.
+        const dy = e.contentOffset.y - startDragOffset.value
+        const dProgress = interpolate(
+          dy,
+          [-headerHeight.value, headerHeight.value],
+          [-1, 1],
+        )
+        const newValue = clamp(startMode.value + dProgress, 0, 1)
+        if (newValue !== mode.value) {
+          // Manually adjust the value. This won't be (and shouldn't be) animated.
+          mode.value = newValue
+        }
+      } else {
+        // On the web, we don't try to follow the drag because we don't know when it ends.
+        // Instead, show/hide immediately based on whether we're scrolling up or down.
+        const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
         startDragOffset.value = e.contentOffset.y
-        startMode.value = mode.value
+
+        if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
+          setMode(false)
+        } else if (dy > 0) {
+          setMode(true)
+        }
       }
     },
     [headerHeight, mode, setMode, startDragOffset, startMode],
@@ -95,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     </ScrollProvider>
   )
 }
+
+const emitter = new EventEmitter()
+
+if (isWeb) {
+  const originalScroll = window.scroll
+  window.scroll = function () {
+    emitter.emit('forced-scroll')
+    return originalScroll.apply(this, arguments as any)
+  }
+
+  const originalScrollTo = window.scrollTo
+  window.scrollTo = function () {
+    emitter.emit('forced-scroll')
+    return originalScrollTo.apply(this, arguments as any)
+  }
+}
+
+function listenToForcedWindowScroll(listener: () => void) {
+  emitter.addListener('forced-scroll', listener)
+  return () => {
+    emitter.removeListener('forced-scroll', listener)
+  }
+}
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index 223a069c8..66e363cd4 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -2,6 +2,8 @@ import React, {createRef, useState, useMemo, useRef} from 'react'
 import {Animated, Pressable, StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 interface Layout {
   x: number
@@ -19,6 +21,7 @@ export function Selector({
   panX: Animated.Value
   onSelect?: (index: number) => void
 }) {
+  const {_} = useLingui()
   const containerRef = useRef<View>(null)
   const pal = usePalette('default')
   const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
@@ -100,8 +103,8 @@ export function Selector({
             testID={`selector-${i}`}
             key={item}
             onPress={() => onPressItem(i)}
-            accessibilityLabel={`Select ${item}`}
-            accessibilityHint={`Select option ${i} of ${numItems}`}>
+            accessibilityLabel={_(msg`Select ${item}`)}
+            accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}>
             <View style={styles.item} ref={itemRefs[i]}>
               <Text
                 style={
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
index e86e37565..814b2fb15 100644
--- a/src/view/com/util/SimpleViewHeader.tsx
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
 import {useSetDrawerOpen} from '#/state/shell'
+import {isWeb} from '#/platform/detection'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -47,7 +48,14 @@ export function SimpleViewHeader({
 
   const Container = isMobile ? View : CenteredView
   return (
-    <Container style={[styles.header, isMobile && styles.headerMobile, style]}>
+    <Container
+      style={[
+        styles.header,
+        isMobile && styles.headerMobile,
+        isWeb && styles.headerWeb,
+        pal.view,
+        style,
+      ]}>
       {showBackButton ? (
         <TouchableOpacity
           testID="viewHeaderDrawerBtn"
@@ -89,6 +97,12 @@ const styles = StyleSheet.create({
     paddingHorizontal: 12,
     paddingVertical: 10,
   },
+  headerWeb: {
+    // @ts-ignore web-only
+    position: 'sticky',
+    top: 0,
+    zIndex: 1,
+  },
   backBtn: {
     width: 30,
     height: 30,
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index beb67c30c..d5a843541 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
 
 const styles = StyleSheet.create({
   container: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     left: 20,
     bottom: 20,
     // @ts-ignore web only
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 082cae59c..1ccfcf56c 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -11,6 +11,8 @@ import {NavigationProp} from 'lib/routes/types'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import Animated from 'react-native-reanimated'
 import {useSetDrawerOpen} from '#/state/shell'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -32,6 +34,7 @@ export function ViewHeader({
   renderButton?: () => JSX.Element
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setDrawerOpen = useSetDrawerOpen()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
@@ -75,9 +78,9 @@ export function ViewHeader({
             hitSlop={BACK_HITSLOP}
             style={canGoBack ? styles.backBtn : styles.backBtnWide}
             accessibilityRole="button"
-            accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
+            accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
             accessibilityHint={
-              canGoBack ? '' : 'Access navigation links and settings'
+              canGoBack ? '' : _(msg`Access navigation links and settings`)
             }>
             {canGoBack ? (
               <FontAwesomeIcon
diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx
index b4adbb557..a4238b8a4 100644
--- a/src/view/com/util/error/ErrorMessage.tsx
+++ b/src/view/com/util/error/ErrorMessage.tsx
@@ -53,7 +53,9 @@ export function ErrorMessage({
           onPress={onPressTryAgain}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Retry`)}
-          accessibilityHint="Retries the last action, which errored out">
+          accessibilityHint={_(
+            msg`Retries the last action, which errored out`,
+          )}>
           <FontAwesomeIcon
             icon="arrows-rotate"
             style={{color: theme.palette.error.icon}}
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index 4cd6dd4b4..45444331c 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -63,14 +63,16 @@ export function ErrorScreen({
             style={[styles.btn]}
             onPress={onPressTryAgain}
             accessibilityLabel={_(msg`Retry`)}
-            accessibilityHint="Retries the last action, which errored out">
+            accessibilityHint={_(
+              msg`Retries the last action, which errored out`,
+            )}>
             <FontAwesomeIcon
               icon="arrows-rotate"
               style={pal.link as FontAwesomeIconStyle}
               size={16}
             />
             <Text type="button" style={[styles.btnText, pal.link]}>
-              <Trans>Try again</Trans>
+              <Trans context="action">Try again</Trans>
             </Text>
           </Button>
         </View>
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 9787d92fb..27a16117b 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {clamp} from 'lib/numbers'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import {isWeb} from '#/platform/detection'
 import Animated from 'react-native-reanimated'
 
 export interface FABProps
@@ -64,7 +65,8 @@ const styles = StyleSheet.create({
     borderRadius: 35,
   },
   outer: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     zIndex: 1,
   },
   inner: {
diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx
index 4aa5cb610..c5f0afc8f 100644
--- a/src/view/com/util/forms/DateInput.tsx
+++ b/src/view/com/util/forms/DateInput.tsx
@@ -13,6 +13,9 @@ import {Text} from '../text/Text'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
+import {getLocales} from 'expo-localization'
+
+const LOCALE = getLocales()[0]
 
 interface Props {
   testID?: string
@@ -25,6 +28,7 @@ interface Props {
   accessibilityLabel: string
   accessibilityHint: string
   accessibilityLabelledBy?: string
+  handleAsUTC?: boolean
 }
 
 export function DateInput(props: Props) {
@@ -32,6 +36,12 @@ export function DateInput(props: Props) {
   const theme = useTheme()
   const pal = usePalette('default')
 
+  const formatter = React.useMemo(() => {
+    return new Intl.DateTimeFormat(LOCALE.languageTag, {
+      timeZone: props.handleAsUTC ? 'UTC' : undefined,
+    })
+  }, [props.handleAsUTC])
+
   const onChangeInternal = useCallback(
     (event: DateTimePickerEvent, date: Date | undefined) => {
       setShow(false)
@@ -64,7 +74,7 @@ export function DateInput(props: Props) {
             <Text
               type={props.buttonLabelType}
               style={[pal.text, props.buttonLabelStyle]}>
-              {props.value.toLocaleDateString()}
+              {formatter.format(props.value)}
             </Text>
           </View>
         </Button>
@@ -73,6 +83,7 @@ export function DateInput(props: Props) {
         <DateTimePicker
           testID={props.testID ? `${props.testID}-datepicker` : undefined}
           mode="date"
+          timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined}
           display="spinner"
           // @ts-ignore applies in iOS only -prf
           themeVariant={theme.colorScheme}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index ad8f50f5e..411b77484 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -75,6 +75,8 @@ export function DropdownButton({
   bottomOffset = 0,
   accessibilityLabel,
 }: PropsWithChildren<DropdownButtonProps>) {
+  const {_} = useLingui()
+
   const ref1 = useRef<TouchableOpacity>(null)
   const ref2 = useRef<View>(null)
 
@@ -141,7 +143,9 @@ export function DropdownButton({
         hitSlop={HITSLOP_10}
         ref={ref1}
         accessibilityRole="button"
-        accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`}
+        accessibilityLabel={
+          accessibilityLabel || _(msg`Opens ${numItems} options`)
+        }
         accessibilityHint="">
         {children}
       </TouchableOpacity>
@@ -247,7 +251,7 @@ const DropdownItems = ({
                 onPress={() => onPressItem(index)}
                 accessibilityRole="button"
                 accessibilityLabel={item.label}
-                accessibilityHint={`Option ${index + 1} of ${numItems}`}>
+                accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}>
                 {item.icon && (
                   <FontAwesomeIcon
                     style={styles.icon}
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
new file mode 100644
index 000000000..9e9888ad8
--- /dev/null
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -0,0 +1,241 @@
+import React from 'react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {Pressable, StyleSheet, View, Text} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {HITSLOP_10} from 'lib/constants'
+
+// Custom Dropdown Menu Components
+// ==
+export const DropdownMenuRoot = DropdownMenu.Root
+export const DropdownMenuContent = DropdownMenu.Content
+
+type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
+export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
+  const theme = useTheme()
+  const [focused, setFocused] = React.useState(false)
+  const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001'
+
+  return (
+    <DropdownMenu.Item
+      {...props}
+      style={StyleSheet.flatten([
+        styles.item,
+        focused && {backgroundColor: backgroundColor},
+      ])}
+      onFocus={() => {
+        setFocused(true)
+      }}
+      onBlur={() => {
+        setFocused(false)
+      }}
+    />
+  )
+}
+
+// Types for Dropdown Menu and Items
+export type DropdownItem = {
+  label: string | 'separator'
+  onPress?: () => void
+  testID?: string
+  icon?: {
+    ios: MenuItemCommonProps['ios']
+    android: string
+    web: IconProp
+  }
+}
+type Props = {
+  items: DropdownItem[]
+  testID?: string
+  accessibilityLabel?: string
+  accessibilityHint?: string
+}
+
+export function NativeDropdown({
+  items,
+  children,
+  testID,
+  accessibilityLabel,
+  accessibilityHint,
+}: 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) {
+      const t = e.target
+
+      if (!open) return
+      if (!t) return
+      if (!buttonRef.current || !menuRef.current) return
+
+      if (
+        t !== buttonRef.current &&
+        !buttonRef.current.contains(t as Node) &&
+        t !== menuRef.current &&
+        !menuRef.current.contains(t as Node)
+      ) {
+        // prevent clicking through to links beneath dropdown
+        // only applies to mobile web
+        e.preventDefault()
+        e.stopPropagation()
+
+        // close menu
+        setOpen(false)
+      }
+    }
+
+    function keydownHandler(e: KeyboardEvent) {
+      if (e.key === 'Escape' && open) {
+        setOpen(false)
+      }
+    }
+
+    document.addEventListener('click', clickHandler, true)
+    window.addEventListener('keydown', keydownHandler, true)
+    return () => {
+      document.removeEventListener('click', clickHandler, true)
+      window.removeEventListener('keydown', keydownHandler, true)
+    }
+  }, [open, setOpen])
+
+  return (
+    <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
+      <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
+        <Pressable
+          ref={buttonRef as unknown as React.Ref<View>}
+          testID={testID}
+          accessibilityRole="button"
+          accessibilityLabel={accessibilityLabel}
+          accessibilityHint={accessibilityHint}
+          onPress={() => setOpen(o => !o)}
+          hitSlop={HITSLOP_10}>
+          {children}
+        </Pressable>
+      </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 (
+              <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>
+      </DropdownMenu.Portal>
+    </DropdownMenuRoot>
+  )
+}
+
+const getKey = (label: string, index: number, id?: string) => {
+  if (id) {
+    return id
+  }
+  return `${label}_${index}`
+}
+
+const styles = StyleSheet.create({
+  separator: {
+    height: 1,
+    marginTop: 4,
+    marginBottom: 4,
+  },
+  content: {
+    backgroundColor: '#f0f0f0',
+    borderRadius: 8,
+    paddingTop: 4,
+    paddingBottom: 4,
+    paddingLeft: 4,
+    paddingRight: 4,
+    marginTop: 6,
+
+    // @ts-ignore web only -prf
+    boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
+  },
+  item: {
+    display: 'flex',
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    columnGap: 20,
+    // @ts-ignore -web
+    cursor: 'pointer',
+    paddingTop: 8,
+    paddingBottom: 8,
+    paddingLeft: 12,
+    paddingRight: 12,
+    borderRadius: 8,
+  },
+  itemTitle: {
+    fontSize: 16,
+    fontWeight: '500',
+    paddingRight: 10,
+  },
+})
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1f2e067c2..b21caf2e7 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -2,7 +2,12 @@ import React, {memo} from 'react'
 import {Linking, StyleProp, View, ViewStyle} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedPost,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
@@ -24,6 +29,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 import {isWeb} from '#/platform/detection'
+import {richTextToString} from '#/lib/strings/rich-text-helpers'
 
 let PostDropdownBtn = ({
   testID,
@@ -31,6 +37,7 @@ let PostDropdownBtn = ({
   postCid,
   postUri,
   record,
+  richText,
   style,
   showAppealLabelItem,
 }: {
@@ -39,6 +46,7 @@ let PostDropdownBtn = ({
   postCid: string
   postUri: string
   record: AppBskyFeedPost.Record
+  richText: RichTextAPI
   style?: StyleProp<ViewStyle>
   showAppealLabelItem?: boolean
 }): React.ReactNode => {
@@ -71,32 +79,36 @@ let PostDropdownBtn = ({
   const onDeletePost = React.useCallback(() => {
     postDeleteMutation.mutateAsync({uri: postUri}).then(
       () => {
-        Toast.show('Post deleted')
+        Toast.show(_(msg`Post deleted`))
       },
       e => {
         logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
+        Toast.show(_(msg`Failed to delete post, please try again`))
       },
     )
-  }, [postUri, postDeleteMutation])
+  }, [postUri, postDeleteMutation, _])
 
   const onToggleThreadMute = React.useCallback(() => {
     try {
       const muted = toggleThreadMute(rootUri)
       if (muted) {
-        Toast.show('You will no longer receive notifications for this thread')
+        Toast.show(
+          _(msg`You will no longer receive notifications for this thread`),
+        )
       } else {
-        Toast.show('You will now receive notifications for this thread')
+        Toast.show(_(msg`You will now receive notifications for this thread`))
       }
     } catch (e) {
       logger.error('Failed to toggle thread mute', {error: e})
     }
-  }, [rootUri, toggleThreadMute])
+  }, [rootUri, toggleThreadMute, _])
 
   const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
+    const str = richTextToString(richText, true)
+
+    Clipboard.setString(str)
+    Toast.show(_(msg`Copied to clipboard`))
+  }, [_, richText])
 
   const onOpenTranslate = React.useCallback(() => {
     Linking.openURL(translatorUrl)
@@ -253,7 +265,7 @@ let PostDropdownBtn = ({
       <NativeDropdown
         testID={testID}
         items={dropdownItems}
-        accessibilityLabel="More post options"
+        accessibilityLabel={_(msg`More post options`)}
         accessibilityHint="">
         <View style={style}>
           <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx
index 02b462b55..a78d23c9b 100644
--- a/src/view/com/util/forms/SearchInput.tsx
+++ b/src/view/com/util/forms/SearchInput.tsx
@@ -11,6 +11,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {HITSLOP_10} from 'lib/constants'
 import {MagnifyingGlassIcon} from 'lib/icons'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -49,7 +50,7 @@ export function SearchInput({
       <TextInput
         testID="searchTextInput"
         ref={textInput}
-        placeholder="Search"
+        placeholder={_(msg`Search`)}
         placeholderTextColor={pal.colors.textLight}
         selectTextOnFocus
         returnKeyType="search"
@@ -71,7 +72,8 @@ export function SearchInput({
           onPress={onPressCancelSearchInner}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Clear search query`)}
-          accessibilityHint="">
+          accessibilityHint=""
+          hitSlop={HITSLOP_10}>
           <FontAwesomeIcon
             icon="xmark"
             size={16}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 6f203bf06..61cb6f69f 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -4,6 +4,8 @@ import {Image} from 'expo-image'
 import {clamp} from 'lib/numbers'
 import {Dimensions} from 'lib/media/types'
 import * as imageSizes from 'lib/media/image-sizes'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 const MIN_ASPECT_RATIO = 0.33 // 1/3
 const MAX_ASPECT_RATIO = 10 // 10/1
@@ -29,6 +31,7 @@ export function AutoSizedImage({
   style,
   children = null,
 }: Props) {
+  const {_} = useLingui()
   const [dim, setDim] = React.useState<Dimensions | undefined>(
     dimensionsHint || imageSizes.get(uri),
   )
@@ -64,7 +67,7 @@ export function AutoSizedImage({
           accessible={true} // Must set for `accessibilityLabel` to work
           accessibilityIgnoresInvertColors
           accessibilityLabel={alt}
-          accessibilityHint="Tap to view fully"
+          accessibilityHint={_(msg`Tap to view fully`)}
         />
         {children}
       </Pressable>
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 094b0c56c..e7110372c 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -2,6 +2,8 @@ import {AppBskyEmbedImages} from '@atproto/api'
 import React, {ComponentProps, FC} from 'react'
 import {StyleSheet, Text, Pressable, View} from 'react-native'
 import {Image} from 'expo-image'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type EventFunction = (index: number) => void
 
@@ -22,6 +24,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
   onPressIn,
   onLongPress,
 }) => {
+  const {_} = useLingui()
   const image = images[index]
   return (
     <View style={styles.fullWidth}>
@@ -31,7 +34,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
         onLongPress={onLongPress ? () => onLongPress(index) : undefined}
         style={styles.fullWidth}
         accessibilityRole="button"
-        accessibilityLabel={image.alt || 'Image'}
+        accessibilityLabel={image.alt || _(msg`Image`)}
         accessibilityHint="">
         <Image
           source={{uri: image.thumb}}
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 1269b7ebf..b3a563116 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -63,7 +63,9 @@ export function ContentHider({
           }
         }}
         accessibilityRole="button"
-        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityHint={
+          override ? _(msg`Hide the content`) : _(msg`Show the content`)
+        }
         accessibilityLabel=""
         style={[
           styles.cover,
@@ -92,7 +94,7 @@ export function ContentHider({
             <ShieldExclamation size={18} style={pal.textLight} />
           )}
         </Pressable>
-        <Text type="md" style={pal.text}>
+        <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}>
           {desc.name}
         </Text>
         <View style={styles.showBtn}>
@@ -129,7 +131,7 @@ const styles = StyleSheet.create({
   cover: {
     flexDirection: 'row',
     alignItems: 'center',
-    gap: 4,
+    gap: 6,
     borderRadius: 8,
     marginTop: 4,
     paddingVertical: 14,
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index bffb7ea1a..b1fa71d4a 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -9,7 +9,7 @@ import {addStyle} from 'lib/styles'
 import {describeModerationCause} from 'lib/moderation'
 import {ShieldExclamation} from 'lib/icons'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import {Trans, msg} from '@lingui/macro'
 import {useModalControls} from '#/state/modals'
 
 interface Props extends ComponentProps<typeof Link> {
@@ -57,7 +57,9 @@ export function PostHider({
         }
       }}
       accessibilityRole="button"
-      accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+      accessibilityHint={
+        override ? _(msg`Hide the content`) : _(msg`Show the content`)
+      }
       accessibilityLabel=""
       style={[
         styles.description,
@@ -103,7 +105,7 @@ export function PostHider({
       </Text>
       {!moderation.noOverride && (
         <Text type="sm" style={[styles.showBtn, pal.link]}>
-          {override ? 'Hide' : 'Show'}
+          {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
         </Text>
       )}
     </Pressable>
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index a50b52175..50ef8a875 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,7 +6,11 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {Text} from '../text/Text'
 import {PostDropdownBtn} from '../forms/PostDropdownBtn'
 import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@@ -26,11 +30,14 @@ import {
 import {useComposerControls} from '#/state/shell/composer'
 import {Shadow} from '#/state/cache/types'
 import {useRequireAuth} from '#/state/session'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 let PostCtrls = ({
   big,
   post,
   record,
+  richText,
   showAppealLabelItem,
   style,
   onPressReply,
@@ -38,11 +45,13 @@ let PostCtrls = ({
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
+  richText: RichTextAPI
   showAppealLabelItem?: boolean
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
 }): React.ReactNode => {
   const theme = useTheme()
+  const {_} = useLingui()
   const {openComposer} = useComposerControls()
   const {closeModal} = useModalControls()
   const postLikeMutation = usePostLikeMutation()
@@ -176,9 +185,9 @@ let PostCtrls = ({
           requireAuth(() => onPressToggleLike())
         }}
         accessibilityRole="button"
-        accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
-          post.likeCount
-        } ${pluralize(post.likeCount || 0, 'like')})`}
+        accessibilityLabel={`${
+          post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
+        } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
         accessibilityHint=""
         hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
         {post.viewer?.like ? (
@@ -209,6 +218,7 @@ let PostCtrls = ({
           postCid={post.cid}
           postUri={post.uri}
           record={record}
+          richText={richText}
           showAppealLabelItem={showAppealLabelItem}
           style={styles.ctrlPad}
         />
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 620852d8e..d45bf1d87 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -8,6 +8,8 @@ import {pluralize} from 'lib/strings/helpers'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
 import {useModalControls} from '#/state/modals'
 import {useRequireAuth} from '#/state/session'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 interface Props {
   isReposted: boolean
@@ -25,6 +27,7 @@ let RepostButton = ({
   onQuote,
 }: Props): React.ReactNode => {
   const theme = useTheme()
+  const {_} = useLingui()
   const {openModal} = useModalControls()
   const requireAuth = useRequireAuth()
 
@@ -53,7 +56,9 @@ let RepostButton = ({
       style={[styles.control, !big && styles.controlPad]}
       accessibilityRole="button"
       accessibilityLabel={`${
-        isReposted ? 'Undo repost' : 'Repost'
+        isReposted
+          ? _(msg`Undo repost`)
+          : _(msg({message: 'Repost', context: 'action'}))
       } (${repostCount} ${pluralize(repostCount || 0, 'repost')})`}
       accessibilityHint=""
       hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
new file mode 100644
index 000000000..f06c8b794
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
@@ -0,0 +1,170 @@
+import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player'
+import React from 'react'
+import {Image, ImageLoadEventData} from 'expo-image'
+import {
+  ActivityIndicator,
+  GestureResponderEvent,
+  LayoutChangeEvent,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useExternalEmbedsPrefs} from 'state/preferences'
+import {useModalControls} from 'state/modals'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {AppBskyEmbedExternal} from '@atproto/api'
+
+export function ExternalGifEmbed({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {openModal} = useModalControls()
+  const {_} = useLingui()
+
+  const thumbHasLoaded = React.useRef(false)
+  const viewWidth = React.useRef(0)
+
+  // Tracking if the placer has been activated
+  const [isPlayerActive, setIsPlayerActive] = React.useState(false)
+  // Tracking whether the gif has been loaded yet
+  const [isPrefetched, setIsPrefetched] = React.useState(false)
+  // Tracking whether the image is animating
+  const [isAnimating, setIsAnimating] = React.useState(true)
+  const [imageDims, setImageDims] = React.useState({height: 100, width: 1})
+
+  // Used for controlling animation
+  const imageRef = React.useRef<Image>(null)
+
+  const load = React.useCallback(() => {
+    setIsPlayerActive(true)
+    Image.prefetch(params.playerUri).then(() => {
+      // Replace the image once it's fetched
+      setIsPrefetched(true)
+    })
+  }, [params.playerUri])
+
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Don't propagate on web
+      event.preventDefault()
+
+      // Show consent if this is the first load
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        openModal({
+          name: 'embed-consent',
+          source: params.source,
+          onAccept: load,
+        })
+        return
+      }
+      // If the player isn't active, we want to activate it and prefetch the gif
+      if (!isPlayerActive) {
+        load()
+        return
+      }
+      // Control animation on native
+      setIsAnimating(prev => {
+        if (prev) {
+          if (isNative) {
+            imageRef.current?.stopAnimating()
+          }
+          return false
+        } else {
+          if (isNative) {
+            imageRef.current?.startAnimating()
+          }
+          return true
+        }
+      })
+    },
+    [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source],
+  )
+
+  const onLoad = React.useCallback((e: ImageLoadEventData) => {
+    if (thumbHasLoaded.current) return
+    setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current))
+    thumbHasLoaded.current = true
+  }, [])
+
+  const onLayout = React.useCallback((e: LayoutChangeEvent) => {
+    viewWidth.current = e.nativeEvent.layout.width
+  }, [])
+
+  return (
+    <Pressable
+      style={[
+        {height: imageDims.height},
+        styles.topRadius,
+        styles.gifContainer,
+      ]}
+      onPress={onPlayPress}
+      onLayout={onLayout}
+      accessibilityRole="button"
+      accessibilityHint={_(msg`Plays the GIF`)}
+      accessibilityLabel={_(msg`Play ${link.title}`)}>
+      {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay
+        <View style={[styles.layer, styles.overlayLayer]}>
+          <View style={[styles.overlayContainer, styles.topRadius]}>
+            {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
+              <FontAwesomeIcon icon="play" size={42} color="white" />
+            ) : (
+              // Activity indicator while gif loads
+              <ActivityIndicator size="large" color="white" />
+            )}
+          </View>
+        </View>
+      )}
+      <Image
+        source={{
+          uri:
+            !isPrefetched || (isWeb && !isAnimating)
+              ? link.thumb
+              : params.playerUri,
+        }} // Web uses the thumb to control playback
+        style={{flex: 1}}
+        ref={imageRef}
+        onLoad={onLoad}
+        autoplay={isAnimating}
+        contentFit="contain"
+        accessibilityIgnoresInvertColors
+        accessibilityLabel={link.title}
+        accessibilityHint={link.title}
+        cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
+      />
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  topRadius: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+  },
+  layer: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
+})
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 27aa804d3..aaa98a41f 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api'
 import {toNiceDomain} from 'lib/strings/url-helpers'
 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
+import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
+import {useExternalEmbedsPrefs} from 'state/preferences'
 
 export const ExternalLinkEmbed = ({
   link,
@@ -16,69 +18,47 @@ export const ExternalLinkEmbed = ({
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const externalEmbedPrefs = useExternalEmbedsPrefs()
 
-  const embedPlayerParams = React.useMemo(
-    () => parseEmbedPlayerFromUrl(link.uri),
-    [link.uri],
-  )
+  const embedPlayerParams = React.useMemo(() => {
+    const params = parseEmbedPlayerFromUrl(link.uri)
+
+    if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
+      return params
+    }
+  }, [link.uri, externalEmbedPrefs])
 
   return (
-    <View
-      style={{
-        flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
-      }}>
+    <View style={styles.container}>
       {link.thumb && !embedPlayerParams ? (
-        <View
-          style={
-            !isMobile
-              ? {
-                  borderTopLeftRadius: 6,
-                  borderBottomLeftRadius: 6,
-                  width: 120,
-                  aspectRatio: 1,
-                  overflow: 'hidden',
-                }
-              : {
-                  borderTopLeftRadius: 6,
-                  borderTopRightRadius: 6,
-                  width: '100%',
-                  height: 200,
-                  overflow: 'hidden',
-                }
-          }>
-          <Image
-            style={styles.extImage}
-            source={{uri: link.thumb}}
-            accessibilityIgnoresInvertColors
-          />
-        </View>
+        <Image
+          style={{aspectRatio: 1.91}}
+          source={{uri: link.thumb}}
+          accessibilityIgnoresInvertColors
+        />
       ) : undefined}
-      {embedPlayerParams && (
-        <ExternalPlayer link={link} params={embedPlayerParams} />
-      )}
-      <View
-        style={{
-          paddingHorizontal: isMobile ? 10 : 14,
-          paddingTop: 8,
-          paddingBottom: 10,
-          flex: !isMobile ? 1 : undefined,
-        }}>
+      {(embedPlayerParams?.isGif && (
+        <ExternalGifEmbed link={link} params={embedPlayerParams} />
+      )) ||
+        (embedPlayerParams && (
+          <ExternalPlayer link={link} params={embedPlayerParams} />
+        ))}
+      <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
         <Text
           type="sm"
           numberOfLines={1}
           style={[pal.textLight, styles.extUri]}>
           {toNiceDomain(link.uri)}
         </Text>
-        <Text
-          type="lg-bold"
-          numberOfLines={isMobile ? 4 : 2}
-          style={[pal.text]}>
-          {link.title || link.uri}
-        </Text>
-        {link.description ? (
+        {!embedPlayerParams?.isGif && (
+          <Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
+            {link.title || link.uri}
+          </Text>
+        )}
+        {link.description && !embedPlayerParams?.hideDetails ? (
           <Text
             type="md"
-            numberOfLines={isMobile ? 4 : 2}
+            numberOfLines={link.thumb ? 2 : 4}
             style={[pal.text, styles.extDescription]}>
             {link.description}
           </Text>
@@ -89,9 +69,16 @@ export const ExternalLinkEmbed = ({
 }
 
 const styles = StyleSheet.create({
-  extImage: {
+  container: {
+    flexDirection: 'column',
+    borderRadius: 6,
+    overflow: 'hidden',
+  },
+  info: {
     width: '100%',
-    height: 200,
+    bottom: 0,
+    paddingTop: 8,
+    paddingBottom: 10,
   },
   extUri: {
     marginTop: 2,
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index 580cf363a..8b0858b69 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -1,22 +1,32 @@
 import React from 'react'
 import {
   ActivityIndicator,
-  Dimensions,
   GestureResponderEvent,
   Pressable,
   StyleSheet,
+  useWindowDimensions,
   View,
 } from 'react-native'
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useFrameCallback,
+} from 'react-native-reanimated'
 import {Image} from 'expo-image'
 import {WebView} from 'react-native-webview'
-import YoutubePlayer from 'react-native-youtube-iframe'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyEmbedExternal} from '@atproto/api'
 import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
 import {EventStopper} from '../EventStopper'
-import {AppBskyEmbedExternal} from '@atproto/api'
 import {isNative} from 'platform/detection'
-import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
+import {useExternalEmbedsPrefs} from 'state/preferences'
+import {useModalControls} from 'state/modals'
 
 interface ShouldStartLoadRequest {
   url: string
@@ -32,6 +42,8 @@ function PlaceholderOverlay({
   isPlayerActive: boolean
   onPress: (event: GestureResponderEvent) => void
 }) {
+  const {_} = useLingui()
+
   // If the player is active and not loading, we don't want to show the overlay.
   if (isPlayerActive && !isLoading) return null
 
@@ -39,8 +51,8 @@ function PlaceholderOverlay({
     <View style={[styles.layer, styles.overlayLayer]}>
       <Pressable
         accessibilityRole="button"
-        accessibilityLabel="Play Video"
-        accessibilityHint=""
+        accessibilityLabel={_(msg`Play Video`)}
+        accessibilityHint={_(msg`Play Video`)}
         onPress={onPress}
         style={[styles.overlayContainer, styles.topRadius]}>
         {!isPlayerActive ? (
@@ -77,31 +89,21 @@ function Player({
   return (
     <View style={[styles.layer, styles.playerLayer]}>
       <EventStopper>
-        {isNative && params.type === 'youtube_video' ? (
-          <YoutubePlayer
-            videoId={params.videoId}
-            play
-            height={height}
-            onReady={onLoad}
-            webViewStyle={[styles.webview, styles.topRadius]}
+        <View style={{height, width: '100%'}}>
+          <WebView
+            javaScriptEnabled={true}
+            onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+            mediaPlaybackRequiresUserAction={false}
+            allowsInlineMediaPlayback
+            bounces={false}
+            allowsFullscreenVideo
+            nestedScrollEnabled
+            source={{uri: params.playerUri}}
+            onLoad={onLoad}
+            setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+            style={[styles.webview, styles.topRadius]}
           />
-        ) : (
-          <View style={{height, width: '100%'}}>
-            <WebView
-              javaScriptEnabled={true}
-              onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
-              mediaPlaybackRequiresUserAction={false}
-              allowsInlineMediaPlayback
-              bounces={false}
-              allowsFullscreenVideo
-              nestedScrollEnabled
-              source={{uri: params.playerUri}}
-              onLoad={onLoad}
-              setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
-              style={[styles.webview, styles.topRadius]}
-            />
-          </View>
-        )}
+        </View>
       </EventStopper>
     </View>
   )
@@ -116,6 +118,10 @@ export function ExternalPlayer({
   params: EmbedPlayerParams
 }) {
   const navigation = useNavigation<NavigationProp>()
+  const insets = useSafeAreaInsets()
+  const windowDims = useWindowDimensions()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {openModal} = useModalControls()
 
   const [isPlayerActive, setPlayerActive] = React.useState(false)
   const [isLoading, setIsLoading] = React.useState(true)
@@ -124,34 +130,51 @@ export function ExternalPlayer({
     height: 0,
   })
 
-  const viewRef = React.useRef<View>(null)
+  const viewRef = useAnimatedRef()
+
+  const frameCallback = useFrameCallback(() => {
+    const measurement = measure(viewRef)
+    if (!measurement) return
+
+    const {height: winHeight, width: winWidth} = windowDims
+
+    // Get the proper screen height depending on what is going on
+    const realWinHeight = isNative // If it is native, we always want the larger number
+      ? winHeight > winWidth
+        ? winHeight
+        : winWidth
+      : winHeight // On web, we always want the actual screen height
+
+    const top = measurement.pageY
+    const bot = measurement.pageY + measurement.height
+
+    // We can use the same logic on all platforms against the screenHeight that we get above
+    const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
+
+    if (!isVisible) {
+      runOnJS(setPlayerActive)(false)
+    }
+  }, false) // False here disables autostarting the callback
 
   // watch for leaving the viewport due to scrolling
   React.useEffect(() => {
+    // We don't want to do anything if the player isn't active
+    if (!isPlayerActive) return
+
     // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
     // continue playing. We need to watch for the blur event
     const unsubscribe = navigation.addListener('blur', () => {
       setPlayerActive(false)
     })
 
-    const interval = setInterval(() => {
-      viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
-        const window = Dimensions.get('window')
-        const top = pageY
-        const bot = pageY + h
-        const isVisible = isNative
-          ? top >= 0 && bot <= window.height
-          : !(top >= window.height || bot <= 0)
-        if (!isVisible) {
-          setPlayerActive(false)
-        }
-      })
-    }, 1e3)
+    // Start watching for changes
+    frameCallback.setActive(true)
+
     return () => {
       unsubscribe()
-      clearInterval(interval)
+      frameCallback.setActive(false)
     }
-  }, [viewRef, navigation])
+  }, [navigation, isPlayerActive, frameCallback])
 
   // calculate height for the player and the screen size
   const height = React.useMemo(
@@ -168,12 +191,26 @@ export function ExternalPlayer({
     setIsLoading(false)
   }, [])
 
-  const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
-    // Prevent this from propagating upward on web
-    event.preventDefault()
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Prevent this from propagating upward on web
+      event.preventDefault()
 
-    setPlayerActive(true)
-  }, [])
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        openModal({
+          name: 'embed-consent',
+          source: params.source,
+          onAccept: () => {
+            setPlayerActive(true)
+          },
+        })
+        return
+      }
+
+      setPlayerActive(true)
+    },
+    [externalEmbedsPrefs, openModal, params.source],
+  )
 
   // measure the layout to set sizing
   const onLayout = React.useCallback(
@@ -187,7 +224,7 @@ export function ExternalPlayer({
   )
 
   return (
-    <View
+    <Animated.View
       ref={viewRef}
       style={{height}}
       collapsable={false}
@@ -205,7 +242,6 @@ export function ExternalPlayer({
           accessibilityIgnoresInvertColors
         />
       )}
-
       <PlaceholderOverlay
         isLoading={isLoading}
         isPlayerActive={isPlayerActive}
@@ -217,7 +253,7 @@ export function ExternalPlayer({
         height={height}
         onLoad={onLoad}
       />
-    </View>
+    </Animated.View>
   )
 }
 
@@ -248,4 +284,8 @@ const styles = StyleSheet.create({
   webview: {
     backgroundColor: 'transparent',
   },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
 })
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index e793f983e..256817bba 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -6,6 +6,8 @@ import {
   AppBskyEmbedImages,
   AppBskyEmbedRecordWithMedia,
   ModerationUI,
+  AppBskyEmbedExternal,
+  RichText as RichTextAPI,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {PostMeta} from '../PostMeta'
@@ -17,6 +19,8 @@ import {PostEmbeds} from '.'
 import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
 import {InfoCircleIcon} from 'lib/icons'
+import {Trans} from '@lingui/macro'
+import {RichText} from 'view/com/util/text/RichText'
 
 export function MaybeQuoteEmbed({
   embed,
@@ -41,6 +45,7 @@ export function MaybeQuoteEmbed({
           uri: embed.record.uri,
           indexedAt: embed.record.indexedAt,
           text: embed.record.value.text,
+          facets: embed.record.value.facets,
           embeds: embed.record.embeds,
         }}
         moderation={moderation}
@@ -52,7 +57,7 @@ export function MaybeQuoteEmbed({
       <View style={[styles.errorContainer, pal.borderDark]}>
         <InfoCircleIcon size={18} style={pal.text} />
         <Text type="lg" style={pal.text}>
-          Blocked
+          <Trans>Blocked</Trans>
         </Text>
       </View>
     )
@@ -61,7 +66,7 @@ export function MaybeQuoteEmbed({
       <View style={[styles.errorContainer, pal.borderDark]}>
         <InfoCircleIcon size={18} style={pal.text} />
         <Text type="lg" style={pal.text}>
-          Deleted
+          <Trans>Deleted</Trans>
         </Text>
       </View>
     )
@@ -82,22 +87,30 @@ export function QuoteEmbed({
   const itemUrip = new AtUri(quote.uri)
   const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
   const itemTitle = `Post by ${quote.author.handle}`
-  const isEmpty = React.useMemo(
-    () => quote.text.trim().length === 0,
-    [quote.text],
-  )
-  const imagesEmbed = React.useMemo(
+  const richText = React.useMemo(
     () =>
-      quote.embeds?.find(
-        embed =>
-          AppBskyEmbedImages.isView(embed) ||
-          AppBskyEmbedRecordWithMedia.isView(embed),
-      ),
-    [quote.embeds],
+      quote.text.trim()
+        ? new RichTextAPI({text: quote.text, facets: quote.facets})
+        : undefined,
+    [quote.text, quote.facets],
   )
+  const embed = React.useMemo(() => {
+    const e = quote.embeds?.[0]
+
+    if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
+      return e
+    } else if (
+      AppBskyEmbedRecordWithMedia.isView(e) &&
+      (AppBskyEmbedImages.isView(e.media) ||
+        AppBskyEmbedExternal.isView(e.media))
+    ) {
+      return e.media
+    }
+  }, [quote.embeds])
   return (
     <Link
       style={[styles.container, pal.borderDark, style]}
+      hoverStyle={{borderColor: pal.colors.borderLinkHover}}
       href={itemHref}
       title={itemTitle}>
       <PostMeta
@@ -110,17 +123,16 @@ export function QuoteEmbed({
       {moderation ? (
         <PostAlerts moderation={moderation} style={styles.alert} />
       ) : null}
-      {!isEmpty ? (
-        <Text type="post-text" style={pal.text} numberOfLines={6}>
-          {quote.text}
-        </Text>
+      {richText ? (
+        <RichText
+          richText={richText}
+          type="post-text"
+          style={pal.text}
+          numberOfLines={20}
+          noLinks
+        />
       ) : null}
-      {AppBskyEmbedImages.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed} moderation={{}} />
-      )}
-      {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed.media} moderation={{}} />
-      )}
+      {embed && <PostEmbeds embed={embed} moderation={{}} />}
     </Link>
   )
 }
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index c94ce9684..6f168a293 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -22,7 +22,6 @@ import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
 import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
@@ -51,7 +50,6 @@ export function PostEmbeds({
 }) {
   const pal = usePalette('default')
   const {openLightbox} = useLightboxControls()
-  const {isMobile} = useWebMediaQueries()
 
   // quote post with media
   // =
@@ -63,7 +61,7 @@ export function PostEmbeds({
     const mediaModeration = isModOnQuote ? {} : moderation
     const quoteModeration = isModOnQuote ? moderation : {}
     return (
-      <View style={[styles.stackContainer, style]}>
+      <View style={style}>
         <PostEmbeds embed={embed.media} moderation={mediaModeration} />
         <ContentHider moderation={quoteModeration}>
           <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} />
@@ -129,10 +127,7 @@ export function PostEmbeds({
               dimensionsHint={aspectRatio}
               onPress={() => _openLightbox(0)}
               onPressIn={() => onPressIn(0)}
-              style={[
-                styles.singleImage,
-                isMobile && styles.singleImageMobile,
-              ]}>
+              style={[styles.singleImage]}>
               {alt === '' ? null : (
                 <View style={styles.altContainer}>
                   <Text style={styles.alt} accessible={false}>
@@ -151,11 +146,7 @@ export function PostEmbeds({
             images={embed.images}
             onPress={_openLightbox}
             onPressIn={onPressIn}
-            style={
-              embed.images.length === 1
-                ? [styles.singleImage, isMobile && styles.singleImageMobile]
-                : undefined
-            }
+            style={embed.images.length === 1 ? [styles.singleImage] : undefined}
           />
         </View>
       )
@@ -168,11 +159,14 @@ export function PostEmbeds({
     const link = embed.external
 
     return (
-      <View style={[styles.extOuter, pal.view, pal.border, style]}>
-        <Link asAnchor href={link.uri}>
-          <ExternalLinkEmbed link={link} />
-        </Link>
-      </View>
+      <Link
+        asAnchor
+        anchorNoUnderline
+        href={link.uri}
+        style={[styles.extOuter, pal.view, pal.borderDark, style]}
+        hoverStyle={{borderColor: pal.colors.borderLinkHover}}>
+        <ExternalLinkEmbed link={link} />
+      </Link>
     )
   }
 
@@ -180,18 +174,11 @@ export function PostEmbeds({
 }
 
 const styles = StyleSheet.create({
-  stackContainer: {
-    gap: 6,
-  },
   imagesContainer: {
     marginTop: 8,
   },
   singleImage: {
     borderRadius: 8,
-    maxHeight: 1000,
-  },
-  singleImageMobile: {
-    maxHeight: 500,
   },
   extOuter: {
     borderWidth: 1,
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index 99062e848..e910127fe 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -17,6 +17,8 @@ export function RichText({
   lineHeight = 1.2,
   style,
   numberOfLines,
+  selectable,
+  noLinks,
 }: {
   testID?: string
   type?: TypographyVariant
@@ -24,6 +26,8 @@ export function RichText({
   lineHeight?: number
   style?: StyleProp<TextStyle>
   numberOfLines?: number
+  selectable?: boolean
+  noLinks?: boolean
 }) {
   const theme = useTheme()
   const pal = usePalette('default')
@@ -42,7 +46,11 @@ export function RichText({
       }
       return (
         // @ts-ignore web only -prf
-        <Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}>
+        <Text
+          testID={testID}
+          style={[style, pal.text]}
+          dataSet={WORD_WRAP}
+          selectable={selectable}>
           {text}
         </Text>
       )
@@ -54,7 +62,8 @@ export function RichText({
         style={[style, pal.text, lineHeightStyle]}
         numberOfLines={numberOfLines}
         // @ts-ignore web only -prf
-        dataSet={WORD_WRAP}>
+        dataSet={WORD_WRAP}
+        selectable={selectable}>
         {text}
       </Text>
     )
@@ -70,7 +79,11 @@ export function RichText({
   for (const segment of richText.segments()) {
     const link = segment.link
     const mention = segment.mention
-    if (mention && AppBskyRichtextFacet.validateMention(mention).success) {
+    if (
+      !noLinks &&
+      mention &&
+      AppBskyRichtextFacet.validateMention(mention).success
+    ) {
       els.push(
         <TextLink
           key={key}
@@ -79,20 +92,26 @@ export function RichText({
           href={`/profile/${mention.did}`}
           style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
           dataSet={WORD_WRAP}
+          selectable={selectable}
         />,
       )
     } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
-      els.push(
-        <TextLink
-          key={key}
-          type={type}
-          text={toShortUrl(segment.text)}
-          href={link.uri}
-          style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
-          dataSet={WORD_WRAP}
-          warnOnMismatchingLabel
-        />,
-      )
+      if (noLinks) {
+        els.push(toShortUrl(segment.text))
+      } else {
+        els.push(
+          <TextLink
+            key={key}
+            type={type}
+            text={toShortUrl(segment.text)}
+            href={link.uri}
+            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
+            dataSet={WORD_WRAP}
+            warnOnMismatchingLabel
+            selectable={selectable}
+          />,
+        )
+      }
     } else {
       els.push(segment.text)
     }
@@ -105,7 +124,8 @@ export function RichText({
       style={[style, pal.text, lineHeightStyle]}
       numberOfLines={numberOfLines}
       // @ts-ignore web only -prf
-      dataSet={WORD_WRAP}>
+      dataSet={WORD_WRAP}
+      selectable={selectable}>
       {els}
     </Text>
   )
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index ea97d59fe..ccb51bfca 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -2,12 +2,15 @@ import React from 'react'
 import {Text as RNText, TextProps} from 'react-native'
 import {s, lh} from 'lib/styles'
 import {useTheme, TypographyVariant} from 'lib/ThemeContext'
+import {isIOS} from 'platform/detection'
+import {UITextView} from 'react-native-ui-text-view'
 
 export type CustomTextProps = TextProps & {
   type?: TypographyVariant
   lineHeight?: number
   title?: string
   dataSet?: Record<string, string | number>
+  selectable?: boolean
 }
 
 export function Text({
@@ -17,16 +20,29 @@ export function Text({
   style,
   title,
   dataSet,
+  selectable,
   ...props
 }: React.PropsWithChildren<CustomTextProps>) {
   const theme = useTheme()
   const typography = theme.typography[type]
   const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
+
+  if (selectable && isIOS) {
+    return (
+      <UITextView
+        style={[s.black, typography, lineHeightStyle, style]}
+        {...props}>
+        {children}
+      </UITextView>
+    )
+  }
+
   return (
     <RNText
       style={[s.black, typography, lineHeightStyle, style]}
       // @ts-ignore web only -esb
       dataSet={Object.assign({tooltip: title}, dataSet || {})}
+      selectable={selectable}
       {...props}>
       {children}
     </RNText>