about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/Button.tsx204
-rw-r--r--src/view/com/Typography.tsx104
-rw-r--r--src/view/com/auth/create/state.ts8
-rw-r--r--src/view/com/auth/login/LoginForm.tsx6
-rw-r--r--src/view/com/composer/Composer.tsx21
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx2
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx5
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx140
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx2
-rw-r--r--src/view/com/modals/AltImage.tsx147
-rw-r--r--src/view/com/modals/EmbedConsent.tsx153
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/util/post-embeds/ExternalGifEmbed.tsx170
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx71
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx148
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx1
-rw-r--r--src/view/com/util/post-embeds/index.tsx18
-rw-r--r--src/view/icons/index.tsx6
-rw-r--r--src/view/screens/DebugNew.tsx541
-rw-r--r--src/view/screens/Home.tsx5
-rw-r--r--src/view/screens/Moderation.tsx8
-rw-r--r--src/view/screens/PreferencesExternalEmbeds.tsx138
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx8
-rw-r--r--src/view/screens/PreferencesThreads.tsx18
-rw-r--r--src/view/screens/SavedFeeds.tsx12
-rw-r--r--src/view/screens/Settings.tsx35
-rw-r--r--src/view/shell/Composer.web.tsx26
-rw-r--r--src/view/shell/desktop/RightNav.tsx6
29 files changed, 1746 insertions, 264 deletions
diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx
new file mode 100644
index 000000000..d1f70d4ae
--- /dev/null
+++ b/src/view/com/Button.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import {Pressable, Text, PressableProps, TextProps} from 'react-native'
+import * as tokens from '#/alf/tokens'
+import {atoms} from '#/alf'
+
+export type ButtonType =
+  | 'primary'
+  | 'secondary'
+  | 'tertiary'
+  | 'positive'
+  | 'negative'
+export type ButtonSize = 'small' | 'large'
+
+export type VariantProps = {
+  type?: ButtonType
+  size?: ButtonSize
+}
+type ButtonState = {
+  pressed: boolean
+  hovered: boolean
+  focused: boolean
+}
+export type ButtonProps = Omit<PressableProps, 'children'> &
+  VariantProps & {
+    children:
+      | ((props: {
+          state: ButtonState
+          type?: ButtonType
+          size?: ButtonSize
+        }) => React.ReactNode)
+      | React.ReactNode
+      | string
+  }
+export type ButtonTextProps = TextProps & VariantProps
+
+export function Button({children, style, type, size, ...rest}: ButtonProps) {
+  const {baseStyles, hoverStyles} = React.useMemo(() => {
+    const baseStyles = []
+    const hoverStyles = []
+
+    switch (type) {
+      case 'primary':
+        baseStyles.push({
+          backgroundColor: tokens.color.blue_500,
+        })
+        break
+      case 'secondary':
+        baseStyles.push({
+          backgroundColor: tokens.color.gray_200,
+        })
+        hoverStyles.push({
+          backgroundColor: tokens.color.gray_100,
+        })
+        break
+      default:
+    }
+
+    switch (size) {
+      case 'large':
+        baseStyles.push(
+          atoms.py_md,
+          atoms.px_xl,
+          atoms.rounded_md,
+          atoms.gap_sm,
+        )
+        break
+      case 'small':
+        baseStyles.push(
+          atoms.py_sm,
+          atoms.px_md,
+          atoms.rounded_sm,
+          atoms.gap_xs,
+        )
+        break
+      default:
+    }
+
+    return {
+      baseStyles,
+      hoverStyles,
+    }
+  }, [type, size])
+
+  const [state, setState] = React.useState({
+    pressed: false,
+    hovered: false,
+    focused: false,
+  })
+
+  const onPressIn = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      pressed: true,
+    }))
+  }, [setState])
+  const onPressOut = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      pressed: false,
+    }))
+  }, [setState])
+  const onHoverIn = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      hovered: true,
+    }))
+  }, [setState])
+  const onHoverOut = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      hovered: false,
+    }))
+  }, [setState])
+  const onFocus = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      focused: true,
+    }))
+  }, [setState])
+  const onBlur = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      focused: false,
+    }))
+  }, [setState])
+
+  return (
+    <Pressable
+      {...rest}
+      style={state => [
+        atoms.flex_row,
+        atoms.align_center,
+        ...baseStyles,
+        ...(state.hovered ? hoverStyles : []),
+        typeof style === 'function' ? style(state) : style,
+      ]}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      onHoverIn={onHoverIn}
+      onHoverOut={onHoverOut}
+      onFocus={onFocus}
+      onBlur={onBlur}>
+      {typeof children === 'string' ? (
+        <ButtonText type={type} size={size}>
+          {children}
+        </ButtonText>
+      ) : typeof children === 'function' ? (
+        children({state, type, size})
+      ) : (
+        children
+      )}
+    </Pressable>
+  )
+}
+
+export function ButtonText({
+  children,
+  style,
+  type,
+  size,
+  ...rest
+}: ButtonTextProps) {
+  const textStyles = React.useMemo(() => {
+    const base = []
+
+    switch (type) {
+      case 'primary':
+        base.push({color: tokens.color.white})
+        break
+      case 'secondary':
+        base.push({
+          color: tokens.color.gray_700,
+        })
+        break
+      default:
+    }
+
+    switch (size) {
+      case 'small':
+        base.push(atoms.text_sm, {paddingBottom: 1})
+        break
+      case 'large':
+        base.push(atoms.text_md, {paddingBottom: 1})
+        break
+      default:
+    }
+
+    return base
+  }, [type, size])
+
+  return (
+    <Text
+      {...rest}
+      style={[
+        atoms.flex_1,
+        atoms.font_semibold,
+        atoms.text_center,
+        ...textStyles,
+        style,
+      ]}>
+      {children}
+    </Text>
+  )
+}
diff --git a/src/view/com/Typography.tsx b/src/view/com/Typography.tsx
new file mode 100644
index 000000000..6579c2e51
--- /dev/null
+++ b/src/view/com/Typography.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import {Text as RNText, TextProps} from 'react-native'
+import {useTheme, atoms, web} from '#/alf'
+
+export function Text({style, ...rest}: TextProps) {
+  const t = useTheme()
+  return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
+}
+
+export function H1({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'heading',
+      'aria-level': 1,
+    }) || {}
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
+    />
+  )
+}
+
+export function H2({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'heading',
+      'aria-level': 2,
+    }) || {}
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
+    />
+  )
+}
+
+export function H3({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'heading',
+      'aria-level': 3,
+    }) || {}
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
+    />
+  )
+}
+
+export function H4({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'heading',
+      'aria-level': 4,
+    }) || {}
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
+    />
+  )
+}
+
+export function H5({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'heading',
+      'aria-level': 5,
+    }) || {}
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
+    />
+  )
+}
+
+export function H6({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'heading',
+      'aria-level': 6,
+    }) || {}
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
+    />
+  )
+}
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index a77d2a44f..62a8495b3 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -136,7 +136,13 @@ export async function submit({
         msg`Invite code not accepted. Check that you input it correctly and try again.`,
       )
     }
-    logger.error('Failed to create account', {error: e})
+
+    if ([400, 429].includes(e.status)) {
+      logger.warn('Failed to create account', {error: e})
+    } else {
+      logger.error(`Failed to create account (${e.status} status)`, {error: e})
+    }
+
     uiDispatch({type: 'set-processing', value: false})
     uiDispatch({type: 'set-error', value: cleanError(errMsg)})
     throw e
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
index 727a0e945..98c5eb374 100644
--- a/src/view/com/auth/login/LoginForm.tsx
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -107,17 +107,21 @@ export const LoginForm = ({
       })
     } catch (e: any) {
       const errMsg = e.toString()
-      logger.warn('Failed to login', {error: e})
       setIsProcessing(false)
       if (errMsg.includes('Authentication Required')) {
+        logger.info('Failed to login due to invalid credentials', {
+          error: errMsg,
+        })
         setError(_(msg`Invalid username or password`))
       } else if (isNetworkError(e)) {
+        logger.warn('Failed to login due to network error', {error: errMsg})
         setError(
           _(
             msg`Unable to contact your service. Please check your Internet connection.`,
           ),
         )
       } else {
+        logger.warn('Failed to login', {error: errMsg})
         setError(cleanError(errMsg))
       }
     }
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 9f60923d6..b15afe6f0 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -6,6 +6,7 @@ import {
   Keyboard,
   KeyboardAvoidingView,
   Platform,
+  Pressable,
   ScrollView,
   StyleSheet,
   TouchableOpacity,
@@ -46,7 +47,6 @@ import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
-import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -70,6 +70,7 @@ export const ComposePost = observer(function ComposePost({
   onPost,
   quote: initQuote,
   mention: initMention,
+  openPicker,
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -274,6 +275,10 @@ export const ComposePost = observer(function ComposePost({
   const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
   const hasMedia = gallery.size > 0 || Boolean(extLink)
 
+  const onEmojiButtonPress = useCallback(() => {
+    openPicker?.(textInput.current?.getCursorPosition())
+  }, [openPicker])
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -456,7 +461,19 @@ export const ComposePost = observer(function ComposePost({
               <OpenCameraBtn gallery={gallery} />
             </>
           ) : null}
-          {!isMobile ? <EmojiPickerButton /> : null}
+          {!isMobile ? (
+            <Pressable
+              onPress={onEmojiButtonPress}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Open emoji picker`)}
+              accessibilityHint={_(msg`Open emoji picker`)}>
+              <FontAwesomeIcon
+                icon={['far', 'face-smile']}
+                color={pal.colors.link}
+                size={22}
+              />
+            </Pressable>
+          ) : null}
           <View style={s.flex1} />
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 7e39f6aed..57bfd0a88 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants'
 export interface TextInputRef {
   focus: () => void
   blur: () => void
+  getCursorPosition: () => DOMRect | undefined
 }
 
 interface TextInputProps extends ComponentProps<typeof RNTextInput> {
@@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl(
     blur: () => {
       textInput.current?.blur()
     },
+    getCursorPosition: () => undefined, // Not implemented on native
   }))
 
   const onChangeText = useCallback(
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 206a3205b..ec3a042a3 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -22,6 +22,7 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 export interface TextInputRef {
   focus: () => void
   blur: () => void
+  getCursorPosition: () => DOMRect | undefined
 }
 
 interface TextInputProps {
@@ -169,6 +170,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   React.useImperativeHandle(ref, () => ({
     focus: () => {}, // TODO
     blur: () => {}, // TODO
+    getCursorPosition: () => {
+      const pos = editor?.state.selection.$anchor.pos
+      return pos ? editor?.view.coordsAtPos(pos) : undefined
+    },
   }))
 
   return (
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index f4b2d99b0..6d16403ff 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -1,11 +1,17 @@
 import React from 'react'
 import Picker from '@emoji-mart/react'
-import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {
+  StyleSheet,
+  TouchableWithoutFeedback,
+  useWindowDimensions,
+  View,
+} from 'react-native'
 import {textInputWebEmitter} from '../TextInput.web'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useMediaQuery} from 'react-responsive'
+
+const HEIGHT_OFFSET = 40
+const WIDTH_OFFSET = 100
+const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
+const PICKER_WIDTH = 350 + WIDTH_OFFSET
 
 export type Emoji = {
   aliases?: string[]
@@ -18,59 +24,87 @@ export type Emoji = {
   unified: string
 }
 
-export function EmojiPickerButton() {
-  const pal = usePalette('default')
-  const [open, setOpen] = React.useState(false)
-  const onOpenChange = (o: boolean) => {
-    setOpen(o)
-  }
-  const close = () => {
-    setOpen(false)
-  }
+export interface EmojiPickerState {
+  isOpen: boolean
+  pos: {top: number; left: number; right: number; bottom: number}
+}
 
-  return (
-    <DropdownMenu.Root open={open} onOpenChange={onOpenChange}>
-      <DropdownMenu.Trigger style={styles.trigger}>
-        <FontAwesomeIcon
-          icon={['far', 'face-smile']}
-          color={pal.colors.link}
-          size={22}
-        />
-      </DropdownMenu.Trigger>
-
-      <DropdownMenu.Portal>
-        <EmojiPicker close={close} />
-      </DropdownMenu.Portal>
-    </DropdownMenu.Root>
-  )
+interface IProps {
+  state: EmojiPickerState
+  close: () => void
 }
 
-export function EmojiPicker({close}: {close: () => void}) {
+export function EmojiPicker({state, close}: IProps) {
+  const {height, width} = useWindowDimensions()
+
+  const isShiftDown = React.useRef(false)
+
+  const position = React.useMemo(() => {
+    const fitsBelow = state.pos.top + PICKER_HEIGHT < height
+    const fitsAbove = PICKER_HEIGHT < state.pos.top
+    const placeOnLeft = PICKER_WIDTH < state.pos.left
+    const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
+
+    if (fitsBelow) {
+      return {
+        top: state.pos.top + HEIGHT_OFFSET,
+      }
+    } else if (fitsAbove) {
+      return {
+        bottom: height - state.pos.bottom + HEIGHT_OFFSET,
+      }
+    } else {
+      return {
+        top: screenYMiddle,
+        left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
+        right: !placeOnLeft
+          ? width - state.pos.right - PICKER_WIDTH
+          : undefined,
+      }
+    }
+  }, [state.pos, height, width])
+
+  React.useEffect(() => {
+    if (!state.isOpen) return
+
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') {
+        isShiftDown.current = true
+      }
+    }
+    const onKeyUp = (e: KeyboardEvent) => {
+      if (e.key === 'Shift') {
+        isShiftDown.current = false
+      }
+    }
+    window.addEventListener('keydown', onKeyDown, true)
+    window.addEventListener('keyup', onKeyUp, true)
+
+    return () => {
+      window.removeEventListener('keydown', onKeyDown, true)
+      window.removeEventListener('keyup', onKeyUp, true)
+    }
+  }, [state.isOpen])
+
   const onInsert = (emoji: Emoji) => {
     textInputWebEmitter.emit('emoji-inserted', emoji)
-    close()
+
+    if (!isShiftDown.current) {
+      close()
+    }
   }
-  const reducedPadding = useMediaQuery({query: '(max-height: 750px)'})
-  const noPadding = useMediaQuery({query: '(max-height: 550px)'})
-  const noPicker = useMediaQuery({query: '(max-height: 350px)'})
+
+  if (!state.isOpen) return null
 
   return (
-    // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-    <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal>
+    <TouchableWithoutFeedback
+      accessibilityRole="button"
+      onPress={close}
+      accessibilityViewIsModal>
       <View style={styles.mask}>
         {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
-        <TouchableWithoutFeedback
-          onPress={e => {
-            e.stopPropagation() // prevent event from bubbling up to the mask
-          }}>
-          <View
-            style={[
-              styles.picker,
-              {
-                paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325,
-                display: noPicker ? 'none' : 'flex',
-              },
-            ]}>
+        <TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
+          <View style={[{position: 'absolute'}, position]}>
             <Picker
               data={async () => {
                 return (await import('./EmojiPickerData.json')).default
@@ -93,15 +127,7 @@ const styles = StyleSheet.create({
     right: 0,
     width: '100%',
     height: '100%',
-  },
-  trigger: {
-    backgroundColor: 'transparent',
-    // @ts-ignore web only -prf
-    border: 'none',
-    paddingTop: 4,
-    paddingLeft: 12,
-    paddingRight: 12,
-    cursor: 'pointer',
+    alignItems: 'center',
   },
   picker: {
     marginHorizontal: 'auto',
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 99e2b474f..338ffc3d0 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -229,7 +229,7 @@ export function FeedSourceCardLoaded({
         </View>
 
         {showSaveBtn && feed.type === 'feed' && (
-          <View>
+          <View style={[s.justifyCenter]}>
             <Pressable
               testID={`feed-${feed.displayName}-toggleSave`}
               disabled={isSavePending || isPinPending || isRemovePending}
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index a2e918317..5156511d6 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -1,14 +1,12 @@
 import React, {useMemo, useCallback, useState} from 'react'
 import {
   ImageStyle,
-  KeyboardAvoidingView,
-  ScrollView,
   StyleSheet,
-  TextInput,
   TouchableOpacity,
   View,
   useWindowDimensions,
 } from 'react-native'
+import {ScrollView, TextInput} from './util'
 import {Image} from 'expo-image'
 import {usePalette} from 'lib/hooks/usePalette'
 import {gradients, s} from 'lib/styles'
@@ -17,13 +15,13 @@ import {MAX_ALT_TEXT} from 'lib/constants'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
-import {isAndroid, isWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
 import {useLingui} from '@lingui/react'
 import {Trans, msg} from '@lingui/macro'
 import {useModalControls} from '#/state/modals'
 
-export const snapPoints = ['fullscreen']
+export const snapPoints = ['100%']
 
 interface Props {
   image: ImageModel
@@ -54,102 +52,86 @@ export function Component({image}: Props) {
     }
   }, [image, windim])
 
+  const onUpdate = useCallback(
+    (v: string) => {
+      v = enforceLen(v, MAX_ALT_TEXT)
+      setAltText(v)
+      image.setAltText(v)
+    },
+    [setAltText, image],
+  )
+
   const onPressSave = useCallback(() => {
     image.setAltText(altText)
     closeModal()
   }, [closeModal, image, altText])
 
-  const onPressCancel = () => {
-    closeModal()
-  }
-
   return (
-    <KeyboardAvoidingView
-      behavior={isAndroid ? 'height' : 'padding'}
-      style={[pal.view, styles.container]}>
-      <ScrollView
-        testID="altTextImageModal"
-        style={styles.scrollContainer}
-        keyboardShouldPersistTaps="always"
-        nativeID="imageAltText">
-        <View style={styles.scrollInner}>
-          <View style={[pal.viewLight, styles.imageContainer]}>
-            <Image
-              testID="selectedPhotoImage"
-              style={imageStyles}
-              source={{
-                uri: image.cropped?.path ?? image.path,
-              }}
-              contentFit="contain"
-              accessible={true}
-              accessibilityIgnoresInvertColors
-            />
-          </View>
-          <TextInput
-            testID="altTextImageInput"
-            style={[styles.textArea, pal.border, pal.text]}
-            keyboardAppearance={theme.colorScheme}
-            multiline
-            placeholder="Add alt text"
-            placeholderTextColor={pal.colors.textLight}
-            value={altText}
-            onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-            accessibilityLabel={_(msg`Image alt text`)}
-            accessibilityHint=""
-            accessibilityLabelledBy="imageAltText"
-            autoFocus
+    <ScrollView
+      testID="altTextImageModal"
+      style={[pal.view, styles.scrollContainer]}
+      keyboardShouldPersistTaps="always"
+      nativeID="imageAltText">
+      <View style={styles.scrollInner}>
+        <View style={[pal.viewLight, styles.imageContainer]}>
+          <Image
+            testID="selectedPhotoImage"
+            style={imageStyles}
+            source={{
+              uri: image.cropped?.path ?? image.path,
+            }}
+            contentFit="contain"
+            accessible={true}
+            accessibilityIgnoresInvertColors
           />
-          <View style={styles.buttonControls}>
-            <TouchableOpacity
-              testID="altTextImageSaveBtn"
-              onPress={onPressSave}
-              accessibilityLabel={_(msg`Save alt text`)}
-              accessibilityHint={`Saves alt text, which reads: ${altText}`}
-              accessibilityRole="button">
-              <LinearGradient
-                colors={[gradients.blueLight.start, gradients.blueLight.end]}
-                start={{x: 0, y: 0}}
-                end={{x: 1, y: 1}}
-                style={[styles.button]}>
-                <Text type="button-lg" style={[s.white, s.bold]}>
-                  <Trans>Save</Trans>
-                </Text>
-              </LinearGradient>
-            </TouchableOpacity>
-            <TouchableOpacity
-              testID="altTextImageCancelBtn"
-              onPress={onPressCancel}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Cancel add image alt text`)}
-              accessibilityHint=""
-              onAccessibilityEscape={onPressCancel}>
-              <View style={[styles.button]}>
-                <Text type="button-lg" style={[pal.textLight]}>
-                  <Trans>Cancel</Trans>
-                </Text>
-              </View>
-            </TouchableOpacity>
-          </View>
         </View>
-      </ScrollView>
-    </KeyboardAvoidingView>
+        <TextInput
+          testID="altTextImageInput"
+          style={[styles.textArea, pal.border, pal.text]}
+          keyboardAppearance={theme.colorScheme}
+          multiline
+          placeholder={_(msg`Add alt text`)}
+          placeholderTextColor={pal.colors.textLight}
+          value={altText}
+          onChangeText={onUpdate}
+          accessibilityLabel={_(msg`Image alt text`)}
+          accessibilityHint=""
+          accessibilityLabelledBy="imageAltText"
+          autoFocus
+        />
+        <View style={styles.buttonControls}>
+          <TouchableOpacity
+            testID="altTextImageSaveBtn"
+            onPress={onPressSave}
+            accessibilityLabel={_(msg`Save alt text`)}
+            accessibilityHint=""
+            accessibilityRole="button">
+            <LinearGradient
+              colors={[gradients.blueLight.start, gradients.blueLight.end]}
+              start={{x: 0, y: 0}}
+              end={{x: 1, y: 1}}
+              style={[styles.button]}>
+              <Text type="button-lg" style={[s.white, s.bold]}>
+                <Trans>Done</Trans>
+              </Text>
+            </LinearGradient>
+          </TouchableOpacity>
+        </View>
+      </View>
+    </ScrollView>
   )
 }
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    height: '100%',
-    width: '100%',
-    paddingVertical: isWeb ? 0 : 18,
-  },
   scrollContainer: {
     flex: 1,
     height: '100%',
     paddingHorizontal: isWeb ? 0 : 12,
+    paddingVertical: isWeb ? 0 : 24,
   },
   scrollInner: {
     gap: 12,
+    paddingTop: isWeb ? 0 : 12,
   },
   imageContainer: {
     borderRadius: 8,
@@ -173,5 +155,6 @@ const styles = StyleSheet.create({
   },
   buttonControls: {
     gap: 8,
+    paddingBottom: isWeb ? 0 : 50,
   },
 })
diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx
new file mode 100644
index 000000000..04104c52e
--- /dev/null
+++ b/src/view/com/modals/EmbedConsent.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {s, colors, gradients} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {ScrollView} from './util'
+import {usePalette} from 'lib/hooks/usePalette'
+import {
+  EmbedPlayerSource,
+  embedPlayerSources,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+
+export const snapPoints = [450]
+
+export function Component({
+  onAccept,
+  source,
+}: {
+  onAccept: () => void
+  source: EmbedPlayerSource
+}) {
+  const pal = usePalette('default')
+  const {closeModal} = useModalControls()
+  const {_} = useLingui()
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const {isMobile} = useWebMediaQueries()
+
+  const onShowAllPress = React.useCallback(() => {
+    for (const key of embedPlayerSources) {
+      setExternalEmbedPref(key, 'show')
+    }
+    onAccept()
+    closeModal()
+  }, [closeModal, onAccept, setExternalEmbedPref])
+
+  const onShowPress = React.useCallback(() => {
+    setExternalEmbedPref(source, 'show')
+    onAccept()
+    closeModal()
+  }, [closeModal, onAccept, setExternalEmbedPref, source])
+
+  const onHidePress = React.useCallback(() => {
+    setExternalEmbedPref(source, 'hide')
+    closeModal()
+  }, [closeModal, setExternalEmbedPref, source])
+
+  return (
+    <ScrollView
+      testID="embedConsentModal"
+      style={[
+        s.flex1,
+        pal.view,
+        isMobile
+          ? {paddingHorizontal: 20, paddingTop: 10}
+          : {paddingHorizontal: 30},
+      ]}>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>External Media</Trans>
+      </Text>
+
+      <Text style={pal.text}>
+        <Trans>
+          This content is hosted by {externalEmbedLabels[source]}. Do you want
+          to enable external media?
+        </Trans>
+      </Text>
+      <View style={[s.mt10]} />
+      <Text style={pal.textLight}>
+        <Trans>
+          External media may allow websites to collect information about you and
+          your device. No information is sent or requested until you press the
+          "play" button.
+        </Trans>
+      </Text>
+      <View style={[s.mt20]} />
+      <TouchableOpacity
+        testID="enableAllBtn"
+        onPress={onShowAllPress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Show embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.btn]}>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Enable External Media</Trans>
+          </Text>
+        </LinearGradient>
+      </TouchableOpacity>
+      <View style={[s.mt10]} />
+      <TouchableOpacity
+        testID="enableSourceBtn"
+        onPress={onShowPress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Never load embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text style={[pal.text, s.bold, s.f18]}>
+            <Trans>Enable {externalEmbedLabels[source]} only</Trans>
+          </Text>
+        </View>
+      </TouchableOpacity>
+      <View style={[s.mt10]} />
+      <TouchableOpacity
+        testID="disableSourceBtn"
+        onPress={onHidePress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Never load embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text style={[pal.text, s.bold, s.f18]}>
+            <Trans>No thanks</Trans>
+          </Text>
+        </View>
+      </TouchableOpacity>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 2aac20dac..f9d211d07 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
+import * as EmbedConsentModal from './EmbedConsent'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 const HANDLE_HEIGHT = 24
@@ -176,6 +177,9 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'link-warning') {
     snapPoints = LinkWarningModal.snapPoints
     element = <LinkWarningModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'embed-consent') {
+    snapPoints = EmbedConsentModal.snapPoints
+    element = <EmbedConsentModal.Component {...activeModal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 12138f54d..c43a8a6ce 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -34,6 +34,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as LinkWarningModal from './LinkWarning'
+import * as EmbedConsentModal from './EmbedConsent'
 
 export function ModalsContainer() {
   const {isModalActive, activeModals} = useModals()
@@ -129,6 +130,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ChangeEmailModal.Component />
   } else if (modal.name === 'link-warning') {
     element = <LinkWarningModal.Component {...modal} />
+  } else if (modal.name === 'embed-consent') {
+    element = <EmbedConsentModal.Component {...modal} />
   } else {
     return null
   }
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..af62aa2b3 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,36 +18,27 @@ 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={{flexDirection: 'column'}}>
       {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',
-                }
-          }>
+          style={{
+            borderTopLeftRadius: 6,
+            borderTopRightRadius: 6,
+            width: '100%',
+            height: isMobile ? 200 : 300,
+            overflow: 'hidden',
+          }}>
           <Image
             style={styles.extImage}
             source={{uri: link.thumb}}
@@ -53,15 +46,17 @@ export const ExternalLinkEmbed = ({
           />
         </View>
       ) : undefined}
-      {embedPlayerParams && (
-        <ExternalPlayer link={link} params={embedPlayerParams} />
-      )}
+      {(embedPlayerParams?.isGif && (
+        <ExternalGifEmbed link={link} params={embedPlayerParams} />
+      )) ||
+        (embedPlayerParams && (
+          <ExternalPlayer link={link} params={embedPlayerParams} />
+        ))}
       <View
         style={{
           paddingHorizontal: isMobile ? 10 : 14,
           paddingTop: 8,
           paddingBottom: 10,
-          flex: !isMobile ? 1 : undefined,
         }}>
         <Text
           type="sm"
@@ -69,16 +64,15 @@ export const ExternalLinkEmbed = ({
           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={4} style={[pal.text]}>
+            {link.title || link.uri}
+          </Text>
+        )}
+        {link.description && !embedPlayerParams?.hideDetails ? (
           <Text
             type="md"
-            numberOfLines={isMobile ? 4 : 2}
+            numberOfLines={4}
             style={[pal.text, styles.extDescription]}>
             {link.description}
           </Text>
@@ -90,8 +84,7 @@ export const ExternalLinkEmbed = ({
 
 const styles = StyleSheet.create({
   extImage: {
-    width: '100%',
-    height: 200,
+    flex: 1,
   },
   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..fbb89af27 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -98,6 +98,7 @@ export function QuoteEmbed({
   return (
     <Link
       style={[styles.container, pal.borderDark, style]}
+      hoverStyle={{borderColor: pal.colors.borderLinkHover}}
       href={itemHref}
       title={itemTitle}>
       <PostMeta
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index c94ce9684..00a102e7b 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -63,7 +63,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} />
@@ -168,11 +168,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,9 +183,6 @@ export function PostEmbeds({
 }
 
 const styles = StyleSheet.create({
-  stackContainer: {
-    gap: 6,
-  },
   imagesContainer: {
     marginTop: 8,
   },
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index 089d3f0a8..221b9702c 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
+import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
+import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
-import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
@@ -129,9 +130,10 @@ library.add(
   faCircle,
   faCircleCheck,
   farCircleCheck,
+  faCircleDot,
   faCircleExclamation,
+  faCirclePlay,
   faCircleUser,
-  faCircleDot,
   faClone,
   farClone,
   faComment,
diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx
new file mode 100644
index 000000000..0b7c5f03b
--- /dev/null
+++ b/src/view/screens/DebugNew.tsx
@@ -0,0 +1,541 @@
+import React from 'react'
+import {View} from 'react-native'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+import {useSetColorMode} from '#/state/shell'
+import * as tokens from '#/alf/tokens'
+import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf'
+import {Button, ButtonText} from '#/view/com/Button'
+import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography'
+
+function ThemeSelector() {
+  const setColorMode = useSetColorMode()
+
+  return (
+    <View style={[a.flex_row, a.gap_md]}>
+      <Button
+        type="secondary"
+        size="small"
+        onPress={() => setColorMode('system')}>
+        System
+      </Button>
+      <Button
+        type="secondary"
+        size="small"
+        onPress={() => setColorMode('light')}>
+        Light
+      </Button>
+      <Button
+        type="secondary"
+        size="small"
+        onPress={() => setColorMode('dark')}>
+        Dark
+      </Button>
+    </View>
+  )
+}
+
+function BreakpointDebugger() {
+  const t = useTheme()
+  const breakpoints = useBreakpoints()
+
+  return (
+    <View>
+      <H3 style={[a.pb_md]}>Breakpoint Debugger</H3>
+      <Text style={[a.pb_md]}>
+        Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>}
+        {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>}
+        {breakpoints.gtTablet && <Text>desktop</Text>}
+      </Text>
+      <Text
+        style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}>
+        {JSON.stringify(breakpoints, null, 2)}
+      </Text>
+    </View>
+  )
+}
+
+function ThemedSection() {
+  const t = useTheme()
+
+  return (
+    <View style={[t.atoms.bg, a.gap_md, a.p_xl]}>
+      <H3 style={[a.font_bold]}>theme.atoms.text</H3>
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <H3 style={[a.font_bold, t.atoms.text_contrast_700]}>
+        theme.atoms.text_contrast_700
+      </H3>
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <H3 style={[a.font_bold, t.atoms.text_contrast_500]}>
+        theme.atoms.text_contrast_500
+      </H3>
+      <View style={[a.flex_1, t.atoms.border_contrast_500, a.border_t]} />
+
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg,
+            a.align_center,
+            a.justify_center,
+            {height: 60},
+          ]}>
+          <Text>theme.bg</Text>
+        </View>
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg_contrast_100,
+            a.align_center,
+            a.justify_center,
+            {height: 60},
+          ]}>
+          <Text>theme.bg_contrast_100</Text>
+        </View>
+      </View>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg_contrast_200,
+            a.align_center,
+            a.justify_center,
+            {height: 60},
+          ]}>
+          <Text>theme.bg_contrast_200</Text>
+        </View>
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg_contrast_300,
+            a.align_center,
+            a.justify_center,
+            {height: 60},
+          ]}>
+          <Text>theme.bg_contrast_300</Text>
+        </View>
+      </View>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg_positive,
+            a.align_center,
+            a.justify_center,
+            {height: 60},
+          ]}>
+          <Text>theme.bg_positive</Text>
+        </View>
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg_negative,
+            a.align_center,
+            a.justify_center,
+            {height: 60},
+          ]}>
+          <Text>theme.bg_negative</Text>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+export function DebugScreen() {
+  const t = useTheme()
+
+  return (
+    <ScrollView>
+      <CenteredView style={[t.atoms.bg]}>
+        <View style={[a.p_xl, a.gap_xxl, {paddingBottom: 200}]}>
+          <ThemeSelector />
+
+          <Alf theme="light">
+            <ThemedSection />
+          </Alf>
+          <Alf theme="dark">
+            <ThemedSection />
+          </Alf>
+
+          <H1>Heading 1</H1>
+          <H2>Heading 2</H2>
+          <H3>Heading 3</H3>
+          <H4>Heading 4</H4>
+          <H5>Heading 5</H5>
+          <H6>Heading 6</H6>
+
+          <Text style={[a.text_xxl]}>atoms.text_xxl</Text>
+          <Text style={[a.text_xl]}>atoms.text_xl</Text>
+          <Text style={[a.text_lg]}>atoms.text_lg</Text>
+          <Text style={[a.text_md]}>atoms.text_md</Text>
+          <Text style={[a.text_sm]}>atoms.text_sm</Text>
+          <Text style={[a.text_xs]}>atoms.text_xs</Text>
+          <Text style={[a.text_xxs]}>atoms.text_xxs</Text>
+
+          <View style={[a.gap_md, a.align_start]}>
+            <Button>
+              {({state}) => (
+                <View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}>
+                  <Text>Unstyled button, state: {JSON.stringify(state)}</Text>
+                </View>
+              )}
+            </Button>
+
+            <Button type="primary" size="small">
+              Button
+            </Button>
+            <Button type="secondary" size="small">
+              Button
+            </Button>
+
+            <Button type="primary" size="large">
+              Button
+            </Button>
+            <Button type="secondary" size="large">
+              Button
+            </Button>
+
+            <Button type="secondary" size="small">
+              {({type, size}) => (
+                <>
+                  <FontAwesomeIcon icon={['fas', 'plus']} size={12} />
+                  <ButtonText type={type} size={size}>
+                    With an icon
+                  </ButtonText>
+                </>
+              )}
+            </Button>
+            <Button type="primary" size="large">
+              {({state: _state, ...rest}) => (
+                <>
+                  <FontAwesomeIcon icon={['fas', 'plus']} />
+                  <ButtonText {...rest}>With an icon</ButtonText>
+                </>
+              )}
+            </Button>
+          </View>
+
+          <View style={[a.gap_md]}>
+            <View style={[a.flex_row, a.gap_md]}>
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_0},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_100},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_200},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_300},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_400},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_500},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_600},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_700},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_800},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_900},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.gray_1000},
+                ]}
+              />
+            </View>
+
+            <View style={[a.flex_row, a.gap_md]}>
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_0},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_100},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_200},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_300},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_400},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_500},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_600},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_700},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_800},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_900},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.blue_1000},
+                ]}
+              />
+            </View>
+            <View style={[a.flex_row, a.gap_md]}>
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_0},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_100},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_200},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_300},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_400},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_500},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_600},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_700},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_800},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_900},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.green_1000},
+                ]}
+              />
+            </View>
+            <View style={[a.flex_row, a.gap_md]}>
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_0},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_100},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_200},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_300},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_400},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_500},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_600},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_700},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_800},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_900},
+                ]}
+              />
+              <View
+                style={[
+                  a.flex_1,
+                  {height: 60, backgroundColor: tokens.color.red_1000},
+                ]}
+              />
+            </View>
+          </View>
+
+          <View>
+            <H3 style={[a.pb_md, a.font_bold]}>Spacing</H3>
+
+            <View style={[a.gap_md]}>
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>xxs (2px)</Text>
+                <View style={[a.flex_1, a.pt_xxs, t.atoms.bg_contrast_300]} />
+              </View>
+
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>xs (4px)</Text>
+                <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} />
+              </View>
+
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>sm (8px)</Text>
+                <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} />
+              </View>
+
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>md (12px)</Text>
+                <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} />
+              </View>
+
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>lg (18px)</Text>
+                <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} />
+              </View>
+
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>xl (24px)</Text>
+                <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} />
+              </View>
+
+              <View style={[a.flex_row, a.align_center]}>
+                <Text style={{width: 80}}>xxl (32px)</Text>
+                <View style={[a.flex_1, a.pt_xxl, t.atoms.bg_contrast_300]} />
+              </View>
+            </View>
+          </View>
+
+          <BreakpointDebugger />
+        </View>
+      </CenteredView>
+    </ScrollView>
+  )
+}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index b8033f0b4..0e20a9cf7 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -19,6 +19,7 @@ import {useSession} from '#/state/session'
 import {loadString, saveString} from '#/lib/storage'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {clamp} from '#/lib/numbers'
+import {PROD_DEFAULT_FEED} from '#/lib/constants'
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export function HomeScreen(props: Props) {
@@ -109,7 +110,9 @@ function HomeScreenReady({
   const homeFeedParams = React.useMemo<FeedParams>(() => {
     return {
       mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
-      mergeFeedSources: preferences.feeds.saved,
+      mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled
+        ? preferences.feeds.saved
+        : [PROD_DEFAULT_FEED('whats-hot')],
     }
   }, [preferences])
 
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
index 1bf8db2e0..96bb46cef 100644
--- a/src/view/screens/Moderation.tsx
+++ b/src/view/screens/Moderation.tsx
@@ -62,7 +62,7 @@ export function ModerationScreen({}: Props) {
       ]}
       testID="moderationScreen">
       <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
-      <ScrollView>
+      <ScrollView contentContainerStyle={[styles.noBorder]}>
         <View style={styles.spacer} />
         <TouchableOpacity
           testID="contentFilteringBtn"
@@ -275,4 +275,10 @@ const styles = StyleSheet.create({
     borderRadius: 30,
     marginRight: 12,
   },
+  noBorder: {
+    borderBottomWidth: 0,
+    borderRightWidth: 0,
+    borderLeftWidth: 0,
+    borderTopWidth: 0,
+  },
 })
diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx
new file mode 100644
index 000000000..24e7d56df
--- /dev/null
+++ b/src/view/screens/PreferencesExternalEmbeds.tsx
@@ -0,0 +1,138 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {s} from 'lib/styles'
+import {Text} from '../com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {
+  EmbedPlayerSource,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans} from '@lingui/macro'
+import {ScrollView} from '../com/util/Views'
+import {
+  useExternalEmbedsPrefs,
+  useSetExternalEmbedPref,
+} from 'state/preferences'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesExternalEmbeds'
+>
+export function PreferencesExternalEmbeds({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {isMobile} = useWebMediaQueries()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('PreferencesExternalEmbeds')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen">
+      <SimpleViewHeader
+        showBackButton={isMobile}
+        style={[
+          pal.border,
+          {borderBottomWidth: 1},
+          !isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
+        ]}>
+        <View style={{flex: 1}}>
+          <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+          <Text style={pal.textLight}>
+            <Trans>Customize media from external sites.</Trans>
+          </Text>
+        </View>
+      </SimpleViewHeader>
+      <ScrollView
+        // @ts-ignore web only -prf
+        dataSet={{'stable-gutters': 1}}
+        contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}>
+        <View style={[pal.view]}>
+          <View style={styles.infoCard}>
+            <Text style={pal.text}>
+              <Trans>
+                External media may allow websites to collect information about
+                you and your device. No information is sent or requested until
+                you press the "play" button.
+              </Trans>
+            </Text>
+          </View>
+        </View>
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          Enable media players for
+        </Text>
+        {Object.entries(externalEmbedLabels).map(([key, label]) => (
+          <PrefSelector
+            source={key as EmbedPlayerSource}
+            label={label}
+            key={key}
+          />
+        ))}
+      </ScrollView>
+    </View>
+  )
+}
+
+function PrefSelector({
+  source,
+  label,
+}: {
+  source: EmbedPlayerSource
+  label: string
+}) {
+  const pal = usePalette('default')
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const sources = useExternalEmbedsPrefs()
+
+  return (
+    <View>
+      <View style={[pal.view, styles.toggleCard]}>
+        <ToggleButton
+          type="default-light"
+          label={label}
+          labelType="lg"
+          isSelected={sources?.[source] === 'show'}
+          onPress={() =>
+            setExternalEmbedPref(
+              source,
+              sources?.[source] === 'show' ? 'hide' : 'show',
+            )
+          }
+        />
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  heading: {
+    paddingHorizontal: 18,
+    paddingTop: 14,
+    paddingBottom: 14,
+  },
+  spacer: {
+    height: 8,
+  },
+  infoCard: {
+    paddingHorizontal: 20,
+    paddingVertical: 14,
+  },
+  toggleCard: {
+    paddingVertical: 8,
+    paddingHorizontal: 6,
+    marginBottom: 1,
+  },
+})
diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 20ef72923..874272831 100644
--- a/src/view/screens/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -29,6 +29,7 @@ function RepliesThresholdInput({
   const pal = usePalette('default')
   const [value, setValue] = useState(initialValue)
   const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
+  const preValue = React.useRef(initialValue)
   const save = React.useMemo(
     () =>
       debounce(
@@ -46,7 +47,12 @@ function RepliesThresholdInput({
       <Slider
         value={value}
         onValueChange={(v: number | number[]) => {
-          const threshold = Math.floor(Array.isArray(v) ? v[0] : v)
+          let threshold = Array.isArray(v) ? v[0] : v
+          if (threshold > preValue.current) threshold = Math.floor(threshold)
+          else threshold = Math.ceil(threshold)
+
+          preValue.current = threshold
+
           setValue(threshold)
           save(threshold)
         }}
diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx
index 73d941932..35a010b55 100644
--- a/src/view/screens/PreferencesThreads.tsx
+++ b/src/view/screens/PreferencesThreads.tsx
@@ -75,10 +75,16 @@ export function PreferencesThreads({navigation}: Props) {
                 <RadioGroup
                   type="default-light"
                   items={[
-                    {key: 'oldest', label: 'Oldest replies first'},
-                    {key: 'newest', label: 'Newest replies first'},
-                    {key: 'most-likes', label: 'Most-liked replies first'},
-                    {key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
+                    {key: 'oldest', label: _(msg`Oldest replies first`)},
+                    {key: 'newest', label: _(msg`Newest replies first`)},
+                    {
+                      key: 'most-likes',
+                      label: _(msg`Most-liked replies first`),
+                    },
+                    {
+                      key: 'random',
+                      label: _(msg`Random (aka "Poster's Roulette")`),
+                    },
                   ]}
                   onSelect={key => setThreadViewPrefs({sort: key})}
                   initialSelection={preferences?.threadViewPrefs?.sort}
@@ -97,7 +103,7 @@ export function PreferencesThreads({navigation}: Props) {
               </Text>
               <ToggleButton
                 type="default-light"
-                label={prioritizeFollowedUsers ? 'Yes' : 'No'}
+                label={prioritizeFollowedUsers ? _(msg`Yes`) : _(msg`No`)}
                 isSelected={prioritizeFollowedUsers}
                 onPress={() =>
                   setThreadViewPrefs({
@@ -120,7 +126,7 @@ export function PreferencesThreads({navigation}: Props) {
               </Text>
               <ToggleButton
                 type="default-light"
-                label={treeViewEnabled ? 'Yes' : 'No'}
+                label={treeViewEnabled ? _(msg`Yes`) : _(msg`No`)}
                 isSelected={treeViewEnabled}
                 onPress={() =>
                   setThreadViewPrefs({
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index bbac30689..8a16ffdf2 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -82,7 +82,7 @@ export function SavedFeeds({}: Props) {
         isTabletOrDesktop && styles.desktopContainer,
       ]}>
       <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
-      <ScrollView style={s.flex1}>
+      <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
         <View style={[pal.text, pal.border, styles.title]}>
           <Text type="title" style={pal.text}>
             <Trans>Pinned Feeds</Trans>
@@ -288,7 +288,7 @@ function ListItem({
       <FeedSourceCard
         key={feedUri}
         feedUri={feedUri}
-        style={styles.noBorder}
+        style={styles.noTopBorder}
         showSaveBtn
         showMinimalPlaceholder
       />
@@ -344,7 +344,7 @@ const styles = StyleSheet.create({
   webArrowUpButton: {
     marginBottom: 10,
   },
-  noBorder: {
+  noTopBorder: {
     borderTopWidth: 0,
   },
   footerText: {
@@ -352,4 +352,10 @@ const styles = StyleSheet.create({
     paddingTop: 22,
     paddingBottom: 100,
   },
+  noBorder: {
+    borderBottomWidth: 0,
+    borderRightWidth: 0,
+    borderLeftWidth: 0,
+    borderTopWidth: 0,
+  },
 })
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index f85ce44b2..d413e234f 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -431,7 +431,7 @@ export function SettingsScreen({}: Props) {
         <View style={[pal.view, styles.toggleCard]}>
           <ToggleButton
             type="default-light"
-            label="Require alt text before posting"
+            label={_(msg`Require alt text before posting`)}
             labelType="lg"
             isSelected={requireAltTextEnabled}
             onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
@@ -575,6 +575,39 @@ export function SettingsScreen({}: Props) {
             <Trans>Moderation</Trans>
           </Text>
         </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Privacy</Trans>
+        </Text>
+
+        <TouchableOpacity
+          testID="externalEmbedsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={
+            isSwitchingAccounts
+              ? undefined
+              : () => navigation.navigate('PreferencesExternalEmbeds')
+          }
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens external embeds settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon={['far', 'circle-play']}
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+        </TouchableOpacity>
+
         <View style={styles.spacer20} />
 
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 73f9f540e..ed64bc799 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -5,6 +5,10 @@ import {ComposePost} from '../com/composer/Composer'
 import {useComposerState} from 'state/shell/composer'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {
+  EmojiPicker,
+  EmojiPickerState,
+} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
 
 const BOTTOM_BAR_HEIGHT = 61
 
@@ -13,6 +17,26 @@ export function Composer({}: {winHeight: number}) {
   const {isMobile} = useWebMediaQueries()
   const state = useComposerState()
 
+  const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
+    isOpen: false,
+    pos: {top: 0, left: 0, right: 0, bottom: 0},
+  })
+
+  const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => {
+    if (!pos) return
+    setPickerState({
+      isOpen: true,
+      pos,
+    })
+  }, [])
+
+  const onClosePicker = React.useCallback(() => {
+    setPickerState(prev => ({
+      ...prev,
+      isOpen: false,
+    }))
+  }, [])
+
   // rendering
   // =
 
@@ -41,8 +65,10 @@ export function Composer({}: {winHeight: number}) {
           quote={state.quote}
           onPost={state.onPost}
           mention={state.mention}
+          openPicker={onOpenPicker}
         />
       </Animated.View>
+      <EmojiPicker state={pickerState} close={onClosePicker} />
     </Animated.View>
   )
 }
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 02b742dc9..894624a6e 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -180,14 +180,14 @@ const styles = StyleSheet.create({
     position: 'absolute',
     // @ts-ignore web only
     left: 'calc(50vw + 320px)',
-    width: 304,
+    width: 300,
     maxHeight: '100%',
     overflowY: 'auto',
   },
 
   message: {
     paddingVertical: 18,
-    paddingHorizontal: 10,
+    paddingHorizontal: 12,
   },
   messageLine: {
     marginBottom: 10,
@@ -195,7 +195,7 @@ const styles = StyleSheet.create({
 
   inviteCodes: {
     borderTopWidth: 1,
-    paddingHorizontal: 16,
+    paddingHorizontal: 12,
     paddingVertical: 12,
     flexDirection: 'row',
   },