about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Button.tsx189
-rw-r--r--src/components/Dialog/context.ts28
-rw-r--r--src/components/Dialog/index.tsx229
-rw-r--r--src/components/Dialog/index.web.tsx67
-rw-r--r--src/components/Dialog/types.ts62
-rw-r--r--src/components/Error.tsx97
-rw-r--r--src/components/GradientFill.tsx27
-rw-r--r--src/components/IconCircle.tsx51
-rw-r--r--src/components/LabelingServiceCard/index.tsx182
-rw-r--r--src/components/LikedByList.tsx109
-rw-r--r--src/components/LikesDialog.tsx131
-rw-r--r--src/components/Link.tsx185
-rw-r--r--src/components/Lists.tsx200
-rw-r--r--src/components/Loader.tsx13
-rw-r--r--src/components/Menu/context.tsx8
-rw-r--r--src/components/Menu/index.tsx221
-rw-r--r--src/components/Menu/index.web.tsx293
-rw-r--r--src/components/Menu/types.ts99
-rw-r--r--src/components/Prompt.tsx117
-rw-r--r--src/components/ReportDialog/SelectLabelerView.tsx87
-rw-r--r--src/components/ReportDialog/SelectReportOptionView.tsx185
-rw-r--r--src/components/ReportDialog/SubmitView.tsx264
-rw-r--r--src/components/ReportDialog/const.ts1
-rw-r--r--src/components/ReportDialog/index.tsx102
-rw-r--r--src/components/ReportDialog/types.ts15
-rw-r--r--src/components/RichText.tsx157
-rw-r--r--src/components/TagMenu/index.tsx277
-rw-r--r--src/components/TagMenu/index.web.tsx149
-rw-r--r--src/components/Typography.tsx51
-rw-r--r--src/components/dialogs/BirthDateSettings.tsx132
-rw-r--r--src/components/dialogs/Context.tsx29
-rw-r--r--src/components/dialogs/MutedWords.tsx376
-rw-r--r--src/components/forms/DateField/index.android.tsx114
-rw-r--r--src/components/forms/DateField/index.shared.tsx99
-rw-r--r--src/components/forms/DateField/index.tsx72
-rw-r--r--src/components/forms/DateField/index.web.tsx10
-rw-r--r--src/components/forms/DateField/types.ts1
-rw-r--r--src/components/forms/FormError.tsx30
-rw-r--r--src/components/forms/HostingProvider.tsx95
-rw-r--r--src/components/forms/TextField.tsx28
-rw-r--r--src/components/forms/Toggle.tsx78
-rw-r--r--src/components/forms/ToggleButton.tsx8
-rw-r--r--src/components/hooks/useDelayedLoading.ts15
-rw-r--r--src/components/hooks/useOnKeyboard.ts12
-rw-r--r--src/components/icons/ArrowTriangle.tsx5
-rw-r--r--src/components/icons/Bars.tsx5
-rw-r--r--src/components/icons/Bubble.tsx5
-rw-r--r--src/components/icons/Calendar.tsx5
-rw-r--r--src/components/icons/Camera.tsx9
-rw-r--r--src/components/icons/Check.tsx4
-rw-r--r--src/components/icons/Chevron.tsx8
-rw-r--r--src/components/icons/CircleBanSign.tsx5
-rw-r--r--src/components/icons/Clipboard.tsx5
-rw-r--r--src/components/icons/DotGrid.tsx5
-rw-r--r--src/components/icons/Envelope.tsx5
-rw-r--r--src/components/icons/Filter.tsx5
-rw-r--r--src/components/icons/Flag.tsx5
-rw-r--r--src/components/icons/Gear.tsx5
-rw-r--r--src/components/icons/Group.tsx5
-rw-r--r--src/components/icons/Group3.tsx5
-rw-r--r--src/components/icons/Heart2.tsx9
-rw-r--r--src/components/icons/Lock.tsx5
-rw-r--r--src/components/icons/MagnifyingGlass2.tsx5
-rw-r--r--src/components/icons/Mute.tsx5
-rw-r--r--src/components/icons/PageText.tsx5
-rw-r--r--src/components/icons/Pencil.tsx5
-rw-r--r--src/components/icons/PeopleRemove2.tsx5
-rw-r--r--src/components/icons/Person.tsx5
-rw-r--r--src/components/icons/PersonCheck.tsx5
-rw-r--r--src/components/icons/PersonX.tsx5
-rw-r--r--src/components/icons/RaisingHand.tsx5
-rw-r--r--src/components/icons/Shield.tsx5
-rw-r--r--src/components/icons/Speaker.tsx5
-rw-r--r--src/components/icons/SquareArrowTopRight.tsx5
-rw-r--r--src/components/icons/SquareBehindSquare4.tsx5
-rw-r--r--src/components/icons/StreamingLive.tsx5
-rw-r--r--src/components/icons/Ticket.tsx5
-rw-r--r--src/components/icons/Times.tsx5
-rw-r--r--src/components/icons/Trash.tsx5
-rw-r--r--src/components/icons/Warning.tsx5
-rw-r--r--src/components/moderation/ContentHider.tsx182
-rw-r--r--src/components/moderation/LabelPreference.tsx293
-rw-r--r--src/components/moderation/LabelsOnMe.tsx83
-rw-r--r--src/components/moderation/LabelsOnMeDialog.tsx262
-rw-r--r--src/components/moderation/ModerationDetailsDialog.tsx148
-rw-r--r--src/components/moderation/PostAlerts.tsx66
-rw-r--r--src/components/moderation/PostHider.tsx129
-rw-r--r--src/components/moderation/ProfileHeaderAlerts.tsx66
-rw-r--r--src/components/moderation/ScreenHider.tsx172
89 files changed, 5760 insertions, 526 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 68cee4374..ece1ad6b0 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -1,20 +1,22 @@
 import React from 'react'
 import {
+  AccessibilityProps,
   Pressable,
-  Text,
   PressableProps,
+  StyleProp,
+  StyleSheet,
+  Text,
   TextProps,
-  ViewStyle,
-  AccessibilityProps,
-  View,
   TextStyle,
-  StyleSheet,
-  StyleProp,
+  View,
+  ViewStyle,
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
+import {Trans} from '@lingui/macro'
 
-import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
+import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
 import {Props as SVGIconProps} from '#/components/icons/common'
+import {normalizeTextStyles} from '#/components/Typography'
 
 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
 export type ButtonColor =
@@ -27,7 +29,7 @@ export type ButtonColor =
   | 'gradient_sunset'
   | 'gradient_nordic'
   | 'gradient_bonfire'
-export type ButtonSize = 'small' | 'large'
+export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
 export type ButtonShape = 'round' | 'square' | 'default'
 export type VariantProps = {
   /**
@@ -48,25 +50,32 @@ export type VariantProps = {
   shape?: ButtonShape
 }
 
-export type ButtonProps = React.PropsWithChildren<
-  Pick<PressableProps, 'disabled' | 'onPress'> &
-    AccessibilityProps &
-    VariantProps & {
-      testID?: string
-      label: string
-      style?: StyleProp<ViewStyle>
-    }
->
-export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
+export type ButtonState = {
+  hovered: boolean
+  focused: boolean
+  pressed: boolean
+  disabled: boolean
+}
 
-const Context = React.createContext<
+export type ButtonContext = VariantProps & ButtonState
+
+export type ButtonProps = Pick<
+  PressableProps,
+  'disabled' | 'onPress' | 'testID'
+> &
+  AccessibilityProps &
   VariantProps & {
-    hovered: boolean
-    focused: boolean
-    pressed: boolean
-    disabled: boolean
+    testID?: string
+    label: string
+    style?: StyleProp<ViewStyle>
+    children:
+      | React.ReactNode
+      | string
+      | ((context: ButtonContext) => React.ReactNode | string)
   }
->({
+export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
+
+const Context = React.createContext<VariantProps & ButtonState>({
   hovered: false,
   focused: false,
   pressed: false,
@@ -132,7 +141,7 @@ export function Button({
     }))
   }, [setState])
 
-  const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
+  const {baseStyles, hoverStyles} = React.useMemo(() => {
     const baseStyles: ViewStyle[] = []
     const hoverStyles: ViewStyle[] = []
     const light = t.name === 'light'
@@ -158,7 +167,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: tokens.color.blue_500,
+            borderColor: t.palette.primary_500,
           })
           hoverStyles.push(a.border, {
             backgroundColor: light
@@ -167,7 +176,7 @@ export function Button({
           })
         } else {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
+            borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
           })
         }
       } else if (variant === 'ghost') {
@@ -184,20 +193,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_50
-              : tokens.color.gray_900,
+            backgroundColor: t.palette.contrast_25,
           })
           hoverStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_100
-              : tokens.color.gray_950,
+            backgroundColor: t.palette.contrast_50,
           })
         } else {
           baseStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_200
-              : tokens.color.gray_950,
+            backgroundColor: t.palette.contrast_100,
           })
         }
       } else if (variant === 'outline') {
@@ -207,21 +210,19 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
+            borderColor: t.palette.contrast_300,
           })
-          hoverStyles.push(a.border, t.atoms.bg_contrast_50)
+          hoverStyles.push(t.atoms.bg_contrast_50)
         } else {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
+            borderColor: t.palette.contrast_200,
           })
         }
       } else if (variant === 'ghost') {
         if (!disabled) {
           baseStyles.push(t.atoms.bg)
           hoverStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_100
-              : tokens.color.gray_900,
+            backgroundColor: t.palette.contrast_100,
           })
         }
       }
@@ -229,14 +230,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: t.palette.negative_400,
+            backgroundColor: t.palette.negative_500,
           })
           hoverStyles.push({
-            backgroundColor: t.palette.negative_500,
+            backgroundColor: t.palette.negative_600,
           })
         } else {
           baseStyles.push({
-            backgroundColor: t.palette.negative_600,
+            backgroundColor: t.palette.negative_700,
           })
         }
       } else if (variant === 'outline') {
@@ -246,7 +247,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: t.palette.negative_400,
+            borderColor: t.palette.negative_500,
           })
           hoverStyles.push(a.border, {
             backgroundColor: light
@@ -266,7 +267,7 @@ export function Button({
           hoverStyles.push({
             backgroundColor: light
               ? t.palette.negative_100
-              : t.palette.negative_950,
+              : t.palette.negative_975,
           })
         }
       }
@@ -275,8 +276,12 @@ export function Button({
     if (shape === 'default') {
       if (size === 'large') {
         baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
+      } else if (size === 'medium') {
+        baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
       } else if (size === 'small') {
         baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
+      } else if (size === 'tiny') {
+        baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
       }
     } else if (shape === 'round' || shape === 'square') {
       if (size === 'large') {
@@ -287,24 +292,24 @@ export function Button({
         }
       } else if (size === 'small') {
         baseStyles.push({height: 40, width: 40})
+      } else if (size === 'tiny') {
+        baseStyles.push({height: 20, width: 20})
       }
 
       if (shape === 'round') {
         baseStyles.push(a.rounded_full)
       } else if (shape === 'square') {
-        baseStyles.push(a.rounded_sm)
+        if (size === 'tiny') {
+          baseStyles.push(a.rounded_xs)
+        } else {
+          baseStyles.push(a.rounded_sm)
+        }
       }
     }
 
     return {
       baseStyles,
       hoverStyles,
-      focusStyles: [
-        ...hoverStyles,
-        {
-          outline: 0,
-        } as ViewStyle,
-      ],
     }
   }, [t, variant, color, size, shape, disabled])
 
@@ -338,7 +343,7 @@ export function Button({
       }
     }, [variant, color])
 
-  const context = React.useMemo(
+  const context = React.useMemo<ButtonContext>(
     () => ({
       ...state,
       variant,
@@ -349,6 +354,8 @@ export function Button({
     [state, variant, color, size, disabled],
   )
 
+  const flattenedBaseStyles = flatten(baseStyles)
+
   return (
     <Pressable
       role="button"
@@ -362,15 +369,12 @@ export function Button({
         disabled: disabled || false,
       }}
       style={[
-        flatten(style),
         a.flex_row,
         a.align_center,
         a.justify_center,
-        a.overflow_hidden,
-        a.justify_center,
-        ...baseStyles,
+        flattenedBaseStyles,
         ...(state.hovered || state.pressed ? hoverStyles : []),
-        ...(state.focused ? focusStyles : []),
+        flatten(style),
       ]}
       onPressIn={onPressIn}
       onPressOut={onPressOut}
@@ -379,21 +383,33 @@ export function Button({
       onFocus={onFocus}
       onBlur={onBlur}>
       {variant === 'gradient' && (
-        <LinearGradient
-          colors={
-            state.hovered || state.pressed || state.focused
-              ? gradientHoverColors
-              : gradientColors
-          }
-          locations={gradientLocations}
-          start={{x: 0, y: 0}}
-          end={{x: 1, y: 1}}
-          style={[a.absolute, a.inset_0]}
-        />
+        <View
+          style={[
+            a.absolute,
+            a.inset_0,
+            a.overflow_hidden,
+            {borderRadius: flattenedBaseStyles.borderRadius},
+          ]}>
+          <LinearGradient
+            colors={
+              state.hovered || state.pressed
+                ? gradientHoverColors
+                : gradientColors
+            }
+            locations={gradientLocations}
+            start={{x: 0, y: 0}}
+            end={{x: 1, y: 1}}
+            style={[a.absolute, a.inset_0]}
+          />
+        </View>
       )}
       <Context.Provider value={context}>
-        {typeof children === 'string' ? (
+        {/* @ts-ignore */}
+        {typeof children === 'string' || children?.type === Trans ? (
+          /* @ts-ignore */
           <ButtonText>{children}</ButtonText>
+        ) : typeof children === 'function' ? (
+          children(context)
         ) : (
           children
         )}
@@ -435,31 +451,31 @@ export function useSharedButtonTextStyles() {
       if (variant === 'solid' || variant === 'gradient') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_700 : tokens.color.gray_100,
+            color: t.palette.contrast_700,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+            color: t.palette.contrast_400,
           })
         }
       } else if (variant === 'outline') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+            color: t.palette.contrast_600,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+            color: t.palette.contrast_300,
           })
         }
       } else if (variant === 'ghost') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+            color: t.palette.contrast_600,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_600,
+            color: t.palette.contrast_300,
           })
         }
       }
@@ -493,6 +509,8 @@ export function useSharedButtonTextStyles() {
 
     if (size === 'large') {
       baseStyles.push(a.text_md, android({paddingBottom: 1}))
+    } else if (size === 'tiny') {
+      baseStyles.push(a.text_xs, android({paddingBottom: 1}))
     } else {
       baseStyles.push(a.text_sm, android({paddingBottom: 1}))
     }
@@ -505,7 +523,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
   const textStyles = useSharedButtonTextStyles()
 
   return (
-    <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
+    <Text
+      {...rest}
+      style={normalizeTextStyles([
+        a.font_bold,
+        a.text_center,
+        textStyles,
+        style,
+      ])}>
       {children}
     </Text>
   )
@@ -514,9 +539,11 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
 export function ButtonIcon({
   icon: Comp,
   position,
+  size: iconSize,
 }: {
   icon: React.ComponentType<SVGIconProps>
   position?: 'left' | 'right'
+  size?: SVGIconProps['size']
 }) {
   const {size, disabled} = useButtonContext()
   const textStyles = useSharedButtonTextStyles()
@@ -532,7 +559,9 @@ export function ButtonIcon({
         },
       ]}>
       <Comp
-        size={size === 'large' ? 'md' : 'sm'}
+        size={
+          iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm')
+        }
         style={[{color: textStyles.color, pointerEvents: 'none'}]}
       />
     </View>
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index b28b9f5a2..859f8edd7 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -1,7 +1,11 @@
 import React from 'react'
 
 import {useDialogStateContext} from '#/state/dialogs'
-import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
+import {
+  DialogContextProps,
+  DialogControlRefProps,
+  DialogOuterProps,
+} from '#/components/Dialog/types'
 
 export const Context = React.createContext<DialogContextProps>({
   close: () => {},
@@ -11,9 +15,9 @@ export function useDialogContext() {
   return React.useContext(Context)
 }
 
-export function useDialogControl() {
+export function useDialogControl(): DialogOuterProps['control'] {
   const id = React.useId()
-  const control = React.useRef<DialogControlProps>({
+  const control = React.useRef<DialogControlRefProps>({
     open: () => {},
     close: () => {},
   })
@@ -27,9 +31,17 @@ export function useDialogControl() {
     }
   }, [id, activeDialogs])
 
-  return {
-    ref: control,
-    open: () => control.current.open(),
-    close: () => control.current.close(),
-  }
+  return React.useMemo<DialogOuterProps['control']>(
+    () => ({
+      id,
+      ref: control,
+      open: () => {
+        control.current.open()
+      },
+      close: cb => {
+        control.current.close(cb)
+      },
+    }),
+    [id, control],
+  )
 }
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 9132e68de..07e101f85 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -1,46 +1,114 @@
 import React, {useImperativeHandle} from 'react'
-import {View, Dimensions} from 'react-native'
+import {Dimensions, Pressable, View} from 'react-native'
+import Animated, {useAnimatedStyle} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import BottomSheet, {
-  BottomSheetBackdrop,
+  BottomSheetBackdropProps,
   BottomSheetScrollView,
+  BottomSheetScrollViewMethods,
   BottomSheetTextInput,
   BottomSheetView,
-} from '@gorhom/bottom-sheet'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-
-import {useTheme, atoms as a} from '#/alf'
-import {Portal} from '#/components/Portal'
-import {createInput} from '#/components/forms/TextField'
+  useBottomSheet,
+  WINDOW_HEIGHT,
+} from '@discord/bottom-sheet/src'
 
+import {logger} from '#/logger'
+import {useDialogStateControlContext} from '#/state/dialogs'
+import {isNative} from 'platform/detection'
+import {atoms as a, flatten, useTheme} from '#/alf'
+import {Context} from '#/components/Dialog/context'
 import {
-  DialogOuterProps,
   DialogControlProps,
   DialogInnerProps,
+  DialogOuterProps,
 } from '#/components/Dialog/types'
-import {Context} from '#/components/Dialog/context'
+import {createInput} from '#/components/forms/TextField'
+import {Portal} from '#/components/Portal'
 
-export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
+export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
 // @ts-ignore
 export const Input = createInput(BottomSheetTextInput)
 
+function Backdrop(props: BottomSheetBackdropProps) {
+  const t = useTheme()
+  const bottomSheet = useBottomSheet()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const opacity =
+      (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
+
+    return {
+      opacity: Math.min(Math.max(opacity, 0), 0.55),
+    }
+  })
+
+  const onPress = React.useCallback(() => {
+    bottomSheet.close()
+  }, [bottomSheet])
+
+  return (
+    <Animated.View
+      style={[
+        t.atoms.bg_contrast_300,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          position: 'absolute',
+        },
+        animatedStyle,
+      ]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Dialog backdrop"
+        accessibilityHint="Press the backdrop to close the dialog"
+        style={{flex: 1}}
+        onPress={onPress}
+      />
+    </Animated.View>
+  )
+}
+
 export function Outer({
   children,
   control,
   onClose,
   nativeOptions,
+  testID,
 }: React.PropsWithChildren<DialogOuterProps>) {
   const t = useTheme()
   const sheet = React.useRef<BottomSheet>(null)
   const sheetOptions = nativeOptions?.sheet || {}
   const hasSnapPoints = !!sheetOptions.snapPoints
   const insets = useSafeAreaInsets()
+  const closeCallback = React.useRef<() => void>()
+  const {setDialogIsOpen} = useDialogStateControlContext()
 
-  const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
-    sheet.current?.snapToIndex(i)
-  }, [])
+  /*
+   * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
+   */
+  const [openIndex, setOpenIndex] = React.useState(-1)
+
+  /*
+   * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
+   */
+  const isOpen = openIndex > -1
 
-  const close = React.useCallback(() => {
+  const open = React.useCallback<DialogControlProps['open']>(
+    ({index} = {}) => {
+      setDialogIsOpen(control.id, true)
+      // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
+      setOpenIndex(index || 0)
+    },
+    [setOpenIndex, setDialogIsOpen, control.id],
+  )
+
+  const close = React.useCallback<DialogControlProps['close']>(cb => {
+    if (cb && typeof cb === 'function') {
+      closeCallback.current = cb
+    }
     sheet.current?.close()
   }, [])
 
@@ -53,103 +121,120 @@ export function Outer({
     [open, close],
   )
 
-  const onChange = React.useCallback(
-    (index: number) => {
-      if (index === -1) {
-        onClose?.()
-      }
-    },
-    [onClose],
-  )
+  const onCloseInner = React.useCallback(() => {
+    try {
+      closeCallback.current?.()
+    } catch (e: any) {
+      logger.error(`Dialog closeCallback failed`, {
+        message: e.message,
+      })
+    } finally {
+      closeCallback.current = undefined
+    }
+    setDialogIsOpen(control.id, false)
+    onClose?.()
+    setOpenIndex(-1)
+  }, [control.id, onClose, setDialogIsOpen])
 
   const context = React.useMemo(() => ({close}), [close])
 
   return (
-    <Portal>
-      <BottomSheet
-        enableDynamicSizing={!hasSnapPoints}
-        enablePanDownToClose
-        keyboardBehavior="interactive"
-        android_keyboardInputMode="adjustResize"
-        keyboardBlurBehavior="restore"
-        topInset={insets.top}
-        {...sheetOptions}
-        ref={sheet}
-        index={-1}
-        backgroundStyle={{backgroundColor: 'transparent'}}
-        backdropComponent={props => (
-          <BottomSheetBackdrop
-            opacity={0.4}
-            appearsOnIndex={0}
-            disappearsOnIndex={-1}
-            {...props}
-          />
-        )}
-        handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
-        handleStyle={{display: 'none'}}
-        onChange={onChange}>
-        <Context.Provider value={context}>
-          <View
-            style={[
-              a.absolute,
-              a.inset_0,
-              t.atoms.bg,
-              {
-                borderTopLeftRadius: 40,
-                borderTopRightRadius: 40,
-                height: Dimensions.get('window').height * 2,
-              },
-            ]}
-          />
-          {children}
-        </Context.Provider>
-      </BottomSheet>
-    </Portal>
+    isOpen && (
+      <Portal>
+        <View
+          // iOS
+          accessibilityViewIsModal
+          // Android
+          importantForAccessibility="yes"
+          style={[a.absolute, a.inset_0]}
+          testID={testID}>
+          <BottomSheet
+            enableDynamicSizing={!hasSnapPoints}
+            enablePanDownToClose
+            keyboardBehavior="interactive"
+            android_keyboardInputMode="adjustResize"
+            keyboardBlurBehavior="restore"
+            topInset={insets.top}
+            {...sheetOptions}
+            snapPoints={sheetOptions.snapPoints || ['100%']}
+            ref={sheet}
+            index={openIndex}
+            backgroundStyle={{backgroundColor: 'transparent'}}
+            backdropComponent={Backdrop}
+            handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
+            handleStyle={{display: 'none'}}
+            onClose={onCloseInner}>
+            <Context.Provider value={context}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  t.atoms.bg,
+                  {
+                    borderTopLeftRadius: 40,
+                    borderTopRightRadius: 40,
+                    height: Dimensions.get('window').height * 2,
+                  },
+                ]}
+              />
+              {children}
+            </Context.Provider>
+          </BottomSheet>
+        </View>
+      </Portal>
+    )
   )
 }
 
-// TODO a11y props here, or is that handled by the sheet?
-export function Inner(props: DialogInnerProps) {
+export function Inner({children, style}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
     <BottomSheetView
       style={[
-        a.p_lg,
+        a.p_xl,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
           borderTopRightRadius: 40,
           paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
         },
+        flatten(style),
       ]}>
-      {props.children}
+      {children}
     </BottomSheetView>
   )
 }
 
-export function ScrollableInner(props: DialogInnerProps) {
+export const ScrollableInner = React.forwardRef<
+  BottomSheetScrollViewMethods,
+  DialogInnerProps
+>(function ScrollableInner({children, style}, ref) {
   const insets = useSafeAreaInsets()
   return (
     <BottomSheetScrollView
       keyboardShouldPersistTaps="handled"
-      keyboardDismissMode="on-drag"
       style={[
         a.flex_1, // main diff is this
         a.p_xl,
+        a.h_full,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
           borderTopRightRadius: 40,
         },
-      ]}>
-      {props.children}
+        flatten(style),
+      ]}
+      contentContainerStyle={isNative ? a.pb_4xl : undefined}
+      ref={ref}>
+      {children}
       <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
     </BottomSheetScrollView>
   )
-}
+})
 
 export function Handle() {
   const t = useTheme()
+
   return (
     <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
       <View
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 305c00e97..038f6295a 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -5,11 +5,14 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
+import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
 
 import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
 import {Context} from '#/components/Dialog/context'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {useDialogStateControlContext} from '#/state/dialogs'
 
 export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
@@ -18,27 +21,30 @@ export {Input} from '#/components/forms/TextField'
 const stopPropagation = (e: any) => e.stopPropagation()
 
 export function Outer({
+  children,
   control,
   onClose,
-  children,
 }: React.PropsWithChildren<DialogOuterProps>) {
   const {_} = useLingui()
   const t = useTheme()
   const {gtMobile} = useBreakpoints()
   const [isOpen, setIsOpen] = React.useState(false)
   const [isVisible, setIsVisible] = React.useState(true)
+  const {setDialogIsOpen} = useDialogStateControlContext()
 
   const open = React.useCallback(() => {
     setIsOpen(true)
-  }, [setIsOpen])
+    setDialogIsOpen(control.id, true)
+  }, [setIsOpen, setDialogIsOpen, control.id])
 
   const close = React.useCallback(async () => {
     setIsVisible(false)
     await new Promise(resolve => setTimeout(resolve, 150))
     setIsOpen(false)
     setIsVisible(true)
+    setDialogIsOpen(control.id, false)
     onClose?.()
-  }, [onClose, setIsOpen])
+  }, [onClose, setIsOpen, setDialogIsOpen, control.id])
 
   useImperativeHandle(
     control.ref,
@@ -93,7 +99,7 @@ export function Outer({
                     style={[
                       web(a.fixed),
                       a.inset_0,
-                      {opacity: 0.5, backgroundColor: t.palette.black},
+                      {opacity: 0.8, backgroundColor: t.palette.black},
                     ]}
                   />
                 )}
@@ -147,7 +153,7 @@ export function Inner({
           a.rounded_md,
           a.w_full,
           a.border,
-          gtMobile ? a.p_xl : a.p_lg,
+          gtMobile ? a.p_2xl : a.p_xl,
           t.atoms.bg,
           {
             maxWidth: 600,
@@ -156,7 +162,7 @@ export function Inner({
             shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
             shadowRadius: 30,
           },
-          ...(Array.isArray(style) ? style : [style || {}]),
+          flatten(style),
         ]}>
         {children}
       </Animated.View>
@@ -170,25 +176,28 @@ export function Handle() {
   return null
 }
 
-/**
- * TODO(eric) unused rn
- */
-// export function Close() {
-//   const {_} = useLingui()
-//   const t = useTheme()
-//   const {close} = useDialogContext()
-//   return (
-//     <View
-//       style={[
-//         a.absolute,
-//         a.z_10,
-//         {
-//           top: a.pt_lg.paddingTop,
-//           right: a.pr_lg.paddingRight,
-//         },
-//       ]}>
-//       <Button onPress={close} label={_(msg`Close active dialog`)}>
-//       </Button>
-//     </View>
-//   )
-// }
+export function Close() {
+  const {_} = useLingui()
+  const {close} = React.useContext(Context)
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.z_10,
+        {
+          top: a.pt_md.paddingTop,
+          right: a.pr_md.paddingRight,
+        },
+      ]}>
+      <Button
+        size="small"
+        variant="ghost"
+        color="secondary"
+        shape="round"
+        onPress={() => close()}
+        label={_(msg`Close active dialog`)}>
+        <ButtonIcon icon={X} size="md" />
+      </Button>
+    </View>
+  )
+}
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index d36784183..1ddab02ee 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -1,43 +1,75 @@
 import React from 'react'
-import type {ViewStyle, AccessibilityProps} from 'react-native'
-import {BottomSheetProps} from '@gorhom/bottom-sheet'
+import type {
+  AccessibilityProps,
+  GestureResponderEvent,
+  ScrollViewProps,
+} from 'react-native'
+import {BottomSheetProps} from '@discord/bottom-sheet/src'
+
+import {ViewStyleProp} from '#/alf'
 
 type A11yProps = Required<AccessibilityProps>
 
+/**
+ * Mutated by useImperativeHandle to provide a public API for controlling the
+ * dialog. The methods here will actually become the handlers defined within
+ * the `Dialog.Outer` component.
+ *
+ * `Partial<GestureResponderEvent>` here allows us to add this directly to the
+ * `onPress` prop of a button, for example. If this type was not added, we
+ * would need to create a function to wrap `.open()` with.
+ */
+export type DialogControlRefProps = {
+  open: (
+    options?: DialogControlOpenOptions & Partial<GestureResponderEvent>,
+  ) => void
+  close: (callback?: () => void) => void
+}
+
+/**
+ * The return type of the useDialogControl hook.
+ */
+export type DialogControlProps = DialogControlRefProps & {
+  id: string
+  ref: React.RefObject<DialogControlRefProps>
+  isOpen?: boolean
+}
+
 export type DialogContextProps = {
-  close: () => void
+  close: DialogControlProps['close']
 }
 
-export type DialogControlProps = {
-  open: (index?: number) => void
-  close: () => void
+export type DialogControlOpenOptions = {
+  /**
+   * NATIVE ONLY
+   *
+   * Optional index of the snap point to open the bottom sheet to. Defaults to
+   * 0, which is the first snap point (i.e. "open").
+   */
+  index?: number
 }
 
 export type DialogOuterProps = {
-  control: {
-    ref: React.RefObject<DialogControlProps>
-    open: (index?: number) => void
-    close: () => void
-  }
+  control: DialogControlProps
   onClose?: () => void
   nativeOptions?: {
     sheet?: Omit<BottomSheetProps, 'children'>
   }
   webOptions?: {}
+  testID?: string
 }
 
-type DialogInnerPropsBase<T> = React.PropsWithChildren<{
-  style?: ViewStyle
-}> &
-  T
+type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
 export type DialogInnerProps =
   | DialogInnerPropsBase<{
       label?: undefined
       accessibilityLabelledBy: A11yProps['aria-labelledby']
       accessibilityDescribedBy: string
+      keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
     }>
   | DialogInnerPropsBase<{
       label: string
       accessibilityLabelledBy?: undefined
       accessibilityDescribedBy?: undefined
+      keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
     }>
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
new file mode 100644
index 000000000..7df166c3f
--- /dev/null
+++ b/src/components/Error.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useNavigation} from '@react-navigation/core'
+import {StackActions} from '@react-navigation/native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {NavigationProp} from 'lib/routes/types'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {router} from '#/routes'
+
+export function Error({
+  title,
+  message,
+  onRetry,
+}: {
+  title?: string
+  message?: string
+  onRetry?: () => unknown
+}) {
+  const navigation = useNavigation<NavigationProp>()
+  const {_} = useLingui()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+
+  const canGoBack = navigation.canGoBack()
+  const onGoBack = React.useCallback(() => {
+    if (canGoBack) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('HomeTab')
+
+      // Checking the state for routes ensures that web doesn't encounter errors while going back
+      if (navigation.getState()?.routes) {
+        navigation.dispatch(StackActions.push(...router.matchPath('/')))
+      } else {
+        navigation.navigate('HomeTab')
+        navigation.dispatch(StackActions.popToTop())
+      }
+    }
+  }, [navigation, canGoBack])
+
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        a.align_center,
+        !gtMobile ? a.justify_between : a.gap_5xl,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}
+      sideBorders>
+      <View style={[a.w_full, a.align_center, a.gap_lg]}>
+        <Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
+        <Text
+          style={[
+            a.text_md,
+            a.text_center,
+            t.atoms.text_contrast_high,
+            {lineHeight: 1.4},
+            gtMobile && {width: 450},
+          ]}>
+          {message}
+        </Text>
+      </View>
+      <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
+        {onRetry && (
+          <Button
+            variant="solid"
+            color="primary"
+            label={_(msg`Press to retry`)}
+            onPress={onRetry}
+            size="large"
+            style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+            <ButtonText>
+              <Trans>Retry</Trans>
+            </ButtonText>
+          </Button>
+        )}
+        <Button
+          variant="solid"
+          color={onRetry ? 'secondary' : 'primary'}
+          label={_(msg`Return to previous page`)}
+          onPress={onGoBack}
+          size="large"
+          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+          <ButtonText>
+            <Trans>Go Back</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </CenteredView>
+  )
+}
diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx
new file mode 100644
index 000000000..dc14aa72b
--- /dev/null
+++ b/src/components/GradientFill.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import LinearGradient from 'react-native-linear-gradient'
+
+import {atoms as a, tokens} from '#/alf'
+
+export function GradientFill({
+  gradient,
+}: {
+  gradient:
+    | typeof tokens.gradients.sky
+    | typeof tokens.gradients.midnight
+    | typeof tokens.gradients.sunrise
+    | typeof tokens.gradients.sunset
+    | typeof tokens.gradients.bonfire
+    | typeof tokens.gradients.summer
+    | typeof tokens.gradients.nordic
+}) {
+  return (
+    <LinearGradient
+      colors={gradient.values.map(c => c[1])}
+      locations={gradient.values.map(c => c[0])}
+      start={{x: 0, y: 0}}
+      end={{x: 1, y: 1}}
+      style={[a.absolute, a.inset_0]}
+    />
+  )
+}
diff --git a/src/components/IconCircle.tsx b/src/components/IconCircle.tsx
new file mode 100644
index 000000000..aa779e37f
--- /dev/null
+++ b/src/components/IconCircle.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {
+  useTheme,
+  atoms as a,
+  ViewStyleProp,
+  TextStyleProp,
+  flatten,
+} from '#/alf'
+import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
+import {Props} from '#/components/icons/common'
+
+export function IconCircle({
+  icon: Icon,
+  size = 'xl',
+  style,
+  iconStyle,
+}: ViewStyleProp & {
+  icon: typeof Growth
+  size?: Props['size']
+  iconStyle?: TextStyleProp['style']
+}) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.rounded_full,
+        {
+          width: size === 'lg' ? 52 : 64,
+          height: size === 'lg' ? 52 : 64,
+          backgroundColor:
+            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
+        },
+        flatten(style),
+      ]}>
+      <Icon
+        size={size}
+        style={[
+          {
+            color: t.palette.primary_500,
+          },
+          flatten(iconStyle),
+        ]}
+      />
+    </View>
+  )
+}
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
new file mode 100644
index 000000000..f924f0f59
--- /dev/null
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {useLabelerInfoQuery} from '#/state/queries/labeler'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {RichText} from '#/components/RichText'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {pluralize} from '#/lib/strings/helpers'
+
+type LabelingServiceProps = {
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed
+}
+
+export function Outer({
+  children,
+  style,
+}: React.PropsWithChildren<ViewStyleProp>) {
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.gap_md,
+        a.w_full,
+        a.p_lg,
+        a.pr_md,
+        a.overflow_hidden,
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function Avatar({avatar}: {avatar?: string}) {
+  return <UserAvatar type="labeler" size={40} avatar={avatar} />
+}
+
+export function Title({value}: {value: string}) {
+  return <Text style={[a.text_md, a.font_bold]}>{value}</Text>
+}
+
+export function Description({value, handle}: {value?: string; handle: string}) {
+  return value ? (
+    <Text numberOfLines={2}>
+      <RichText value={value} style={[]} />
+    </Text>
+  ) : (
+    <Text>
+      <Trans>By {sanitizeHandle(handle, '@')}</Trans>
+    </Text>
+  )
+}
+
+export function LikeCount({count}: {count: number}) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[
+        a.mt_sm,
+        a.text_sm,
+        t.atoms.text_contrast_medium,
+        {fontWeight: '500'},
+      ]}>
+      <Trans>
+        Liked by {count} {pluralize(count, 'user')}
+      </Trans>
+    </Text>
+  )
+}
+
+export function Content({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.flex_row,
+        a.gap_md,
+        a.align_center,
+        a.justify_between,
+      ]}>
+      <View style={[a.gap_xs, a.flex_1]}>{children}</View>
+
+      <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} />
+    </View>
+  )
+}
+
+/**
+ * The canonical view for a labeling service. Use this or compose your own.
+ */
+export function Default({
+  labeler,
+  style,
+}: LabelingServiceProps & ViewStyleProp) {
+  return (
+    <Outer style={style}>
+      <Avatar avatar={labeler.creator.avatar} />
+      <Content>
+        <Title
+          value={getLabelingServiceTitle({
+            displayName: labeler.creator.displayName,
+            handle: labeler.creator.handle,
+          })}
+        />
+        <Description
+          value={labeler.creator.description}
+          handle={labeler.creator.handle}
+        />
+        {labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null}
+      </Content>
+    </Outer>
+  )
+}
+
+export function Link({
+  children,
+  labeler,
+}: LabelingServiceProps & Pick<LinkProps, 'children'>) {
+  const {_} = useLingui()
+
+  return (
+    <InternalLink
+      to={{
+        screen: 'Profile',
+        params: {
+          name: labeler.creator.handle,
+        },
+      }}
+      label={_(
+        msg`View the labeling service provided by @${labeler.creator.handle}`,
+      )}>
+      {children}
+    </InternalLink>
+  )
+}
+
+// TODO not finished yet
+export function DefaultSkeleton() {
+  return (
+    <View>
+      <Text>Loading</Text>
+    </View>
+  )
+}
+
+export function Loader({
+  did,
+  loading: LoadingComponent = DefaultSkeleton,
+  error: ErrorComponent,
+  component: Component,
+}: {
+  did: string
+  loading?: React.ComponentType<{}>
+  error?: React.ComponentType<{error: string}>
+  component: React.ComponentType<{
+    labeler: AppBskyLabelerDefs.LabelerViewDetailed
+  }>
+}) {
+  const {isLoading, data, error} = useLabelerInfoQuery({did})
+
+  return isLoading ? (
+    LoadingComponent ? (
+      <LoadingComponent />
+    ) : null
+  ) : error || !data ? (
+    ErrorComponent ? (
+      <ErrorComponent error={error?.message || 'Unknown error'} />
+    ) : null
+  ) : (
+    <Component labeler={data} />
+  )
+}
diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx
new file mode 100644
index 000000000..bd1213639
--- /dev/null
+++ b/src/components/LikedByList.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {logger} from '#/logger'
+import {List} from '#/view/com/util/List'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {ListFooter} from '#/components/Lists'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function LikedByList({uri}: {uri: string}) {
+  const t = useTheme()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetching: isFetchingResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isRefetching,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error: likedByError,
+    refetch,
+  } = useLikedByQuery(resolvedUri?.uri)
+  const likes = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.likes)
+    }
+    return []
+  }, [data])
+  const initialNumToRender = useInitialNumToRender()
+  const error = resolveError || likedByError
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh likes', {message: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more likes', {message: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
+    return (
+      <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
+    )
+  }, [])
+
+  if (isFetchingResolvedUri || !isFetched) {
+    return (
+      <View style={[a.w_full, a.align_center, a.p_lg]}>
+        <Loader size="xl" />
+      </View>
+    )
+  }
+
+  return likes.length ? (
+    <List
+      data={likes}
+      keyExtractor={item => item.actor.did}
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
+      onEndReached={onEndReached}
+      onEndReachedThreshold={3}
+      renderItem={renderItem}
+      initialNumToRender={initialNumToRender}
+      ListFooterComponent={() => (
+        <ListFooter
+          isFetching={isFetching && !isRefetching}
+          isError={isError}
+          error={error ? error.toString() : undefined}
+          onRetry={fetchNextPage}
+        />
+      )}
+    />
+  ) : (
+    <View style={[a.p_lg]}>
+      <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
+        <Text style={[a.text_md, a.leading_snug]}>
+          <Trans>
+            Nobody has liked this yet. Maybe you should be the first!
+          </Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx
new file mode 100644
index 000000000..94a3f27e2
--- /dev/null
+++ b/src/components/LikesDialog.tsx
@@ -0,0 +1,131 @@
+import React, {useMemo, useCallback} from 'react'
+import {ActivityIndicator, FlatList, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {Loader} from '#/components/Loader'
+
+interface LikesDialogProps {
+  control: Dialog.DialogOuterProps['control']
+  uri: string
+}
+
+export function LikesDialog(props: LikesDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <LikesDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+export function LikesDialogInner({control, uri}: LikesDialogProps) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetched: hasFetchedResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching: isFetchingLikedBy,
+    isFetched: hasFetchedLikedBy,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error: likedByError,
+  } = useLikedByQuery(resolvedUri?.uri)
+
+  const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
+  const likes = useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.likes)
+    }
+    return []
+  }, [data])
+
+  const onEndReached = useCallback(async () => {
+    if (isFetchingLikedBy || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more likes', {message: err})
+    }
+  }, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(
+    ({item}: {item: GetLikes.Like}) => {
+      return (
+        <ProfileCardWithFollowBtn
+          key={item.actor.did}
+          profile={item.actor}
+          onPress={() => control.close()}
+        />
+      )
+    },
+    [control],
+  )
+
+  return (
+    <Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
+      <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
+        <Trans>Liked by</Trans>
+      </Text>
+
+      {isLoading ? (
+        <View style={{minHeight: 300}}>
+          <Loader size="xl" />
+        </View>
+      ) : resolveError || likedByError || !data ? (
+        <ErrorMessage message={cleanError(resolveError || likedByError)} />
+      ) : likes.length === 0 ? (
+        <View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
+          <Text style={[a.text_center]}>
+            <Trans>
+              Nobody has liked this yet. Maybe you should be the first!
+            </Trans>
+          </Text>
+        </View>
+      ) : (
+        <FlatList
+          data={likes}
+          keyExtractor={item => item.actor.did}
+          onEndReached={onEndReached}
+          renderItem={renderItem}
+          initialNumToRender={15}
+          ListFooterComponent={
+            <ListFooterComponent isFetching={isFetchingNextPage} />
+          }
+        />
+      )}
+
+      <Dialog.Close />
+    </Dialog.Inner>
+  )
+}
+
+function ListFooterComponent({isFetching}: {isFetching: boolean}) {
+  if (isFetching) {
+    return (
+      <View style={a.pt_lg}>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+  return null
+}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 763f07ca9..7d0e83332 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,21 +1,13 @@
 import React from 'react'
-import {
-  GestureResponderEvent,
-  Linking,
-  TouchableWithoutFeedback,
-} from 'react-native'
-import {
-  useLinkProps,
-  useNavigation,
-  StackActions,
-} from '@react-navigation/native'
+import {GestureResponderEvent} from 'react-native'
+import {useLinkProps, StackActions} from '@react-navigation/native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {isWeb} from '#/platform/detection'
-import {useTheme, web, flatten, TextStyleProp} from '#/alf'
+import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
 import {Button, ButtonProps} from '#/components/Button'
-import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {AllNavigatorParams} from '#/lib/routes/types'
 import {
   convertBskyAppUrlIfNeeded,
   isExternalUrl,
@@ -23,7 +15,9 @@ import {
 } from '#/lib/strings/url-helpers'
 import {useModalControls} from '#/state/modals'
 import {router} from '#/routes'
-import {Text} from '#/components/Typography'
+import {Text, TextProps} from '#/components/Typography'
+import {useOpenLink} from 'state/preferences/in-app-browser'
+import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
 
 /**
  * Only available within a `Link`, since that inherits from `Button`.
@@ -35,6 +29,13 @@ type BaseLinkProps = Pick<
   Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
   'to'
 > & {
+  testID?: string
+
+  /**
+   * Label for a11y. Defaults to the href.
+   */
+  label?: string
+
   /**
    * The React Navigation `StackAction` to perform when the link is pressed.
    */
@@ -45,29 +46,48 @@ type BaseLinkProps = Pick<
    *
    * Note: atm this only works for `InlineLink`s with a string child.
    */
-  warnOnMismatchingTextChild?: boolean
+  disableMismatchWarning?: boolean
+
+  /**
+   * Callback for when the link is pressed. Prevent default and return `false`
+   * to exit early and prevent navigation.
+   *
+   * DO NOT use this for navigation, that's what the `to` prop is for.
+   */
+  onPress?: (e: GestureResponderEvent) => void | false
+
+  /**
+   * Web-only attribute. Sets `download` attr on web.
+   */
+  download?: string
 }
 
 export function useLink({
   to,
   displayText,
   action = 'push',
-  warnOnMismatchingTextChild,
+  disableMismatchWarning,
+  onPress: outerOnPress,
 }: BaseLinkProps & {
   displayText: string
 }) {
-  const navigation = useNavigation<NavigationProp>()
+  const navigation = useNavigationDeduped()
   const {href} = useLinkProps<AllNavigatorParams>({
     to:
       typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
   })
   const isExternal = isExternalUrl(href)
   const {openModal, closeModal} = useModalControls()
+  const openLink = useOpenLink()
 
   const onPress = React.useCallback(
     (e: GestureResponderEvent) => {
+      const exitEarlyIfFalse = outerOnPress?.(e)
+
+      if (exitEarlyIfFalse === false) return
+
       const requiresWarning = Boolean(
-        warnOnMismatchingTextChild &&
+        !disableMismatchWarning &&
           displayText &&
           isExternal &&
           linkRequiresWarning(href, displayText),
@@ -85,7 +105,7 @@ export function useLink({
         e.preventDefault()
 
         if (isExternal) {
-          Linking.openURL(href)
+          openLink(href)
         } else {
           /**
            * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
@@ -103,7 +123,7 @@ export function useLink({
             href.startsWith('http') ||
             href.startsWith('mailto')
           ) {
-            Linking.openURL(href)
+            openLink(href)
           } else {
             closeModal() // close any active modals
 
@@ -124,14 +144,16 @@ export function useLink({
       }
     },
     [
-      href,
-      isExternal,
-      warnOnMismatchingTextChild,
-      navigation,
-      action,
+      outerOnPress,
+      disableMismatchWarning,
       displayText,
-      closeModal,
+      isExternal,
+      href,
       openModal,
+      openLink,
+      closeModal,
+      action,
+      navigation,
     ],
   )
 
@@ -142,17 +164,8 @@ export function useLink({
   }
 }
 
-export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
-  Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & {
-    /**
-     * Label for a11y. Defaults to the href.
-     */
-    label?: string
-    /**
-     * Web-only attribute. Sets `download` attr on web.
-     */
-    download?: string
-  }
+export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
+  Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
 
 /**
  * A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -166,6 +179,7 @@ export function Link({
   children,
   to,
   action = 'push',
+  onPress: outerOnPress,
   download,
   ...rest
 }: LinkProps) {
@@ -173,24 +187,26 @@ export function Link({
     to,
     displayText: typeof children === 'string' ? children : '',
     action,
+    onPress: outerOnPress,
   })
 
   return (
     <Button
       label={href}
       {...rest}
+      style={[a.justify_start, flatten(rest.style)]}
       role="link"
       accessibilityRole="link"
       href={href}
-      onPress={onPress}
+      onPress={download ? undefined : onPress}
       {...web({
         hrefAttrs: {
-          target: isExternal ? 'blank' : undefined,
+          target: download ? undefined : isExternal ? 'blank' : undefined,
           rel: isExternal ? 'noopener noreferrer' : undefined,
           download,
         },
         dataSet: {
-          // default to no underline, apply this ourselves
+          // no underline, only `InlineLink` has underlines
           noUnderline: '1',
         },
       })}>
@@ -200,21 +216,19 @@ export function Link({
 }
 
 export type InlineLinkProps = React.PropsWithChildren<
-  BaseLinkProps &
-    TextStyleProp & {
-      /**
-       * Label for a11y. Defaults to the href.
-       */
-      label?: string
-    }
+  BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
 >
 
 export function InlineLink({
   children,
   to,
   action = 'push',
-  warnOnMismatchingTextChild,
+  disableMismatchWarning,
   style,
+  onPress: outerOnPress,
+  download,
+  selectable,
+  label,
   ...rest
 }: InlineLinkProps) {
   const t = useTheme()
@@ -223,52 +237,59 @@ export function InlineLink({
     to,
     displayText: stringChildren ? children : '',
     action,
-    warnOnMismatchingTextChild,
+    disableMismatchWarning,
+    onPress: outerOnPress,
   })
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
   const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
   const {
     state: pressed,
     onIn: onPressIn,
     onOut: onPressOut,
   } = useInteractionState()
+  const flattenedStyle = flatten(style) || {}
 
   return (
-    <TouchableWithoutFeedback
-      accessibilityRole="button"
-      onPress={onPress}
+    <Text
+      selectable={selectable}
+      accessibilityHint=""
+      accessibilityLabel={label || href}
+      {...rest}
+      style={[
+        {color: t.palette.primary_500},
+        (hovered || focused || pressed) && {
+          ...web({outline: 0}),
+          textDecorationLine: 'underline',
+          textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
+        },
+        flattenedStyle,
+      ]}
+      role="link"
+      onPress={download ? undefined : onPress}
       onPressIn={onPressIn}
       onPressOut={onPressOut}
       onFocus={onFocus}
-      onBlur={onBlur}>
-      <Text
-        label={href}
-        {...rest}
-        style={[
-          {color: t.palette.primary_500},
-          (focused || pressed) && {
-            outline: 0,
-            textDecorationLine: 'underline',
-            textDecorationColor: t.palette.primary_500,
-          },
-          flatten(style),
-        ]}
-        role="link"
-        accessibilityRole="link"
-        href={href}
-        {...web({
-          hrefAttrs: {
-            target: isExternal ? 'blank' : undefined,
-            rel: isExternal ? 'noopener noreferrer' : undefined,
-          },
-          dataSet: stringChildren
-            ? {}
-            : {
-                // default to no underline, apply this ourselves
-                noUnderline: '1',
-              },
-        })}>
-        {children}
-      </Text>
-    </TouchableWithoutFeedback>
+      onBlur={onBlur}
+      onMouseEnter={onHoverIn}
+      onMouseLeave={onHoverOut}
+      accessibilityRole="link"
+      href={href}
+      {...web({
+        hrefAttrs: {
+          target: download ? undefined : isExternal ? 'blank' : undefined,
+          rel: isExternal ? 'noopener noreferrer' : undefined,
+          download,
+        },
+        dataSet: {
+          // default to no underline, apply this ourselves
+          noUnderline: '1',
+        },
+      })}>
+      {children}
+    </Text>
   )
 }
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
new file mode 100644
index 000000000..d3e072028
--- /dev/null
+++ b/src/components/Lists.tsx
@@ -0,0 +1,200 @@
+import React from 'react'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+
+import {CenteredView} from 'view/com/util/Views'
+import {Loader} from '#/components/Loader'
+import {cleanError} from 'lib/strings/errors'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {Error} from '#/components/Error'
+
+export function ListFooter({
+  isFetching,
+  isError,
+  error,
+  onRetry,
+  height,
+}: {
+  isFetching?: boolean
+  isError?: boolean
+  error?: string
+  onRetry?: () => Promise<unknown>
+  height?: number
+}) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.align_center,
+        a.border_t,
+        a.pb_lg,
+        t.atoms.border_contrast_low,
+        {height: height ?? 180, paddingTop: 30},
+      ]}>
+      {isFetching ? (
+        <Loader size="xl" />
+      ) : (
+        <ListFooterMaybeError
+          isError={isError}
+          error={error}
+          onRetry={onRetry}
+        />
+      )}
+    </View>
+  )
+}
+
+function ListFooterMaybeError({
+  isError,
+  error,
+  onRetry,
+}: {
+  isError?: boolean
+  error?: string
+  onRetry?: () => Promise<unknown>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  if (!isError) return null
+
+  return (
+    <View style={[a.w_full, a.px_lg]}>
+      <View
+        style={[
+          a.flex_row,
+          a.gap_md,
+          a.p_md,
+          a.rounded_sm,
+          a.align_center,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text
+          style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]}
+          numberOfLines={2}>
+          {error ? (
+            cleanError(error)
+          ) : (
+            <Trans>Oops, something went wrong!</Trans>
+          )}
+        </Text>
+        <Button
+          variant="gradient"
+          label={_(msg`Press to retry`)}
+          style={[
+            a.align_center,
+            a.justify_center,
+            a.rounded_sm,
+            a.overflow_hidden,
+            a.px_md,
+            a.py_sm,
+          ]}
+          onPress={onRetry}>
+          <Trans>Retry</Trans>
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+export function ListHeaderDesktop({
+  title,
+  subtitle,
+}: {
+  title: string
+  subtitle?: string
+}) {
+  const {gtTablet} = useBreakpoints()
+  const t = useTheme()
+
+  if (!gtTablet) return null
+
+  return (
+    <View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
+      <Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
+      {subtitle ? (
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          {subtitle}
+        </Text>
+      ) : undefined}
+    </View>
+  )
+}
+
+export function ListMaybePlaceholder({
+  isLoading,
+  isEmpty,
+  isError,
+  emptyTitle,
+  emptyMessage,
+  errorTitle,
+  errorMessage,
+  emptyType = 'page',
+  onRetry,
+}: {
+  isLoading: boolean
+  isEmpty?: boolean
+  isError?: boolean
+  emptyTitle?: string
+  emptyMessage?: string
+  errorTitle?: string
+  errorMessage?: string
+  emptyType?: 'page' | 'results'
+  onRetry?: () => Promise<unknown>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile, gtTablet} = useBreakpoints()
+
+  if (!isLoading && isError) {
+    return (
+      <Error
+        title={errorTitle ?? _(msg`Oops!`)}
+        message={errorMessage ?? _(`Something went wrong!`)}
+        onRetry={onRetry}
+      />
+    )
+  }
+
+  if (isLoading) {
+    return (
+      <CenteredView
+        style={[
+          a.flex_1,
+          a.align_center,
+          !gtMobile ? a.justify_between : a.gap_5xl,
+          t.atoms.border_contrast_low,
+          {paddingTop: 175, paddingBottom: 110},
+        ]}
+        sideBorders={gtMobile}
+        topBorder={!gtTablet}>
+        <View style={[a.w_full, a.align_center, {top: 100}]}>
+          <Loader size="xl" />
+        </View>
+      </CenteredView>
+    )
+  }
+
+  if (isEmpty) {
+    return (
+      <Error
+        title={
+          emptyTitle ??
+          (emptyType === 'results'
+            ? _(msg`No results found`)
+            : _(msg`Page not found`))
+        }
+        message={
+          emptyMessage ??
+          _(msg`We're sorry! We can't find the page you were looking for.`)
+        }
+        onRetry={onRetry}
+      />
+    )
+  }
+}
diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx
index bbe4e2f75..b9f399f95 100644
--- a/src/components/Loader.tsx
+++ b/src/components/Loader.tsx
@@ -7,11 +7,12 @@ import Animated, {
   withTiming,
 } from 'react-native-reanimated'
 
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme, flatten} from '#/alf'
 import {Props, useCommonSVGProps} from '#/components/icons/common'
 import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
 
 export function Loader(props: Props) {
+  const t = useTheme()
   const common = useCommonSVGProps(props)
   const rotation = useSharedValue(0)
 
@@ -35,7 +36,15 @@ export function Loader(props: Props) {
         {width: common.size, height: common.size},
         animatedStyles,
       ]}>
-      <Icon {...props} style={[a.absolute, a.inset_0, props.style]} />
+      <Icon
+        {...props}
+        style={[
+          a.absolute,
+          a.inset_0,
+          t.atoms.text_contrast_high,
+          flatten(props.style),
+        ]}
+      />
     </Animated.View>
   )
 }
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
new file mode 100644
index 000000000..9fc91f681
--- /dev/null
+++ b/src/components/Menu/context.tsx
@@ -0,0 +1,8 @@
+import React from 'react'
+
+import type {ContextType} from '#/components/Menu/types'
+
+export const Context = React.createContext<ContextType>({
+  // @ts-ignore
+  control: null,
+})
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
new file mode 100644
index 000000000..051e95b95
--- /dev/null
+++ b/src/components/Menu/index.tsx
@@ -0,0 +1,221 @@
+import React from 'react'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
+import flattenReactChildren from 'react-keyed-flatten-children'
+
+import {atoms as a, useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Text} from '#/components/Typography'
+
+import {Context} from '#/components/Menu/context'
+import {
+  ContextType,
+  TriggerProps,
+  ItemProps,
+  GroupProps,
+  ItemTextProps,
+  ItemIconProps,
+} from '#/components/Menu/types'
+import {Button, ButtonText} from '#/components/Button'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {isNative} from 'platform/detection'
+
+export {useDialogControl as useMenuControl} from '#/components/Dialog'
+
+export function useMemoControlContext() {
+  return React.useContext(Context)
+}
+
+export function Root({
+  children,
+  control,
+}: React.PropsWithChildren<{
+  control?: Dialog.DialogOuterProps['control']
+}>) {
+  const defaultControl = Dialog.useDialogControl()
+  const context = React.useMemo<ContextType>(
+    () => ({
+      control: control || defaultControl,
+    }),
+    [control, defaultControl],
+  )
+
+  return <Context.Provider value={context}>{children}</Context.Provider>
+}
+
+export function Trigger({children, label}: TriggerProps) {
+  const {control} = React.useContext(Context)
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  return children({
+    isNative: true,
+    control,
+    state: {
+      hovered: false,
+      focused,
+      pressed,
+    },
+    props: {
+      onPress: control.open,
+      onFocus,
+      onBlur,
+      onPressIn,
+      onPressOut,
+      accessibilityLabel: label,
+    },
+  })
+}
+
+export function Outer({
+  children,
+  showCancel,
+}: React.PropsWithChildren<{
+  showCancel?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
+  const context = React.useContext(Context)
+
+  return (
+    <Dialog.Outer control={context.control}>
+      <Dialog.Handle />
+
+      {/* Re-wrap with context since Dialogs are portal-ed to root */}
+      <Context.Provider value={context}>
+        <Dialog.ScrollableInner label="Menu TODO">
+          <View style={[a.gap_lg]}>
+            {children}
+            {isNative && showCancel && <Cancel />}
+          </View>
+          <View style={{height: a.gap_lg.gap}} />
+        </Dialog.ScrollableInner>
+      </Context.Provider>
+    </Dialog.Outer>
+  )
+}
+
+export function Item({children, label, style, onPress, ...rest}: ItemProps) {
+  const t = useTheme()
+  const {control} = React.useContext(Context)
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  return (
+    <Pressable
+      {...rest}
+      accessibilityHint=""
+      accessibilityLabel={label}
+      onPress={e => {
+        onPress(e)
+
+        if (!e.defaultPrevented) {
+          control?.close()
+        }
+      }}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        a.px_md,
+        a.rounded_md,
+        a.border,
+        t.atoms.bg_contrast_25,
+        t.atoms.border_contrast_low,
+        {minHeight: 44, paddingVertical: 10},
+        style,
+        (focused || pressed) && [t.atoms.bg_contrast_50],
+      ]}>
+      {children}
+    </Pressable>
+  )
+}
+
+export function ItemText({children, style}: ItemTextProps) {
+  const t = useTheme()
+  return (
+    <Text
+      numberOfLines={1}
+      ellipsizeMode="middle"
+      style={[
+        a.flex_1,
+        a.text_md,
+        a.font_bold,
+        t.atoms.text_contrast_medium,
+        {paddingTop: 3},
+        style,
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
+export function ItemIcon({icon: Comp}: ItemIconProps) {
+  const t = useTheme()
+  return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
+}
+
+export function Group({children, style}: GroupProps) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.rounded_md,
+        a.overflow_hidden,
+        a.border,
+        t.atoms.border_contrast_low,
+        style,
+      ]}>
+      {flattenReactChildren(children).map((child, i) => {
+        return React.isValidElement(child) && child.type === Item ? (
+          <React.Fragment key={i}>
+            {i > 0 ? (
+              <View style={[a.border_b, t.atoms.border_contrast_low]} />
+            ) : null}
+            {React.cloneElement(child, {
+              // @ts-ignore
+              style: {
+                borderRadius: 0,
+                borderWidth: 0,
+              },
+            })}
+          </React.Fragment>
+        ) : null
+      })}
+    </View>
+  )
+}
+
+function Cancel() {
+  const {_} = useLingui()
+  const {control} = React.useContext(Context)
+
+  return (
+    <Button
+      label={_(msg`Close this dialog`)}
+      size="small"
+      variant="ghost"
+      color="secondary"
+      onPress={() => control.close()}>
+      <ButtonText>
+        <Trans>Cancel</Trans>
+      </ButtonText>
+    </Button>
+  )
+}
+
+export function Divider() {
+  return null
+}
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
new file mode 100644
index 000000000..60b234203
--- /dev/null
+++ b/src/components/Menu/index.web.tsx
@@ -0,0 +1,293 @@
+/* eslint-disable react/prop-types */
+
+import React from 'react'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import * as Dialog from '#/components/Dialog'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {atoms as a, useTheme, flatten, web} from '#/alf'
+import {Text} from '#/components/Typography'
+
+import {
+  ContextType,
+  TriggerProps,
+  ItemProps,
+  GroupProps,
+  ItemTextProps,
+  ItemIconProps,
+  RadixPassThroughTriggerProps,
+} from '#/components/Menu/types'
+import {Context} from '#/components/Menu/context'
+import {Portal} from '#/components/Portal'
+
+export function useMenuControl(): Dialog.DialogControlProps {
+  const id = React.useId()
+  const [isOpen, setIsOpen] = React.useState(false)
+
+  return React.useMemo(
+    () => ({
+      id,
+      ref: {current: null},
+      isOpen,
+      open() {
+        setIsOpen(true)
+      },
+      close() {
+        setIsOpen(false)
+      },
+    }),
+    [id, isOpen, setIsOpen],
+  )
+}
+
+export function useMemoControlContext() {
+  return React.useContext(Context)
+}
+
+export function Root({
+  children,
+  control,
+}: React.PropsWithChildren<{
+  control?: Dialog.DialogOuterProps['control']
+}>) {
+  const {_} = useLingui()
+  const defaultControl = useMenuControl()
+  const context = React.useMemo<ContextType>(
+    () => ({
+      control: control || defaultControl,
+    }),
+    [control, defaultControl],
+  )
+  const onOpenChange = React.useCallback(
+    (open: boolean) => {
+      if (context.control.isOpen && !open) {
+        context.control.close()
+      } else if (!context.control.isOpen && open) {
+        context.control.open()
+      }
+    },
+    [context.control],
+  )
+
+  return (
+    <Context.Provider value={context}>
+      {context.control.isOpen && (
+        <Portal>
+          <Pressable
+            style={[a.fixed, a.inset_0, a.z_50]}
+            onPress={() => context.control.close()}
+            accessibilityHint=""
+            accessibilityLabel={_(
+              msg`Context menu backdrop, click to close the menu.`,
+            )}
+          />
+        </Portal>
+      )}
+      <DropdownMenu.Root
+        open={context.control.isOpen}
+        onOpenChange={onOpenChange}>
+        {children}
+      </DropdownMenu.Root>
+    </Context.Provider>
+  )
+}
+
+const RadixTriggerPassThrough = React.forwardRef(
+  (
+    props: {
+      children: (
+        props: RadixPassThroughTriggerProps & {
+          ref: React.Ref<any>
+        },
+      ) => React.ReactNode
+    },
+    ref,
+  ) => {
+    // @ts-expect-error Radix provides no types of this stuff
+    return props.children({...props, ref})
+  },
+)
+RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
+
+export function Trigger({children, label}: TriggerProps) {
+  const {control} = React.useContext(Context)
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  return (
+    <DropdownMenu.Trigger asChild>
+      <RadixTriggerPassThrough>
+        {props =>
+          children({
+            isNative: false,
+            control,
+            state: {
+              hovered,
+              focused,
+              pressed: false,
+            },
+            props: {
+              ...props,
+              // disable on web, use `onPress`
+              onPointerDown: () => false,
+              onPress: () =>
+                control.isOpen ? control.close() : control.open(),
+              onFocus: onFocus,
+              onBlur: onBlur,
+              onMouseEnter,
+              onMouseLeave,
+              accessibilityLabel: label,
+            },
+          })
+        }
+      </RadixTriggerPassThrough>
+    </DropdownMenu.Trigger>
+  )
+}
+
+export function Outer({
+  children,
+  style,
+}: React.PropsWithChildren<{
+  showCancel?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
+  const t = useTheme()
+
+  return (
+    <DropdownMenu.Portal>
+      <DropdownMenu.Content sideOffset={5} loop aria-label="Test">
+        <View
+          style={[
+            a.rounded_sm,
+            a.p_xs,
+            t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
+            t.atoms.shadow_md,
+            style,
+          ]}>
+          {children}
+        </View>
+
+        {/* Disabled until we can fix positioning
+        <DropdownMenu.Arrow
+          className="DropdownMenuArrow"
+          fill={
+            (t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25)
+              .backgroundColor
+          }
+        />
+          */}
+      </DropdownMenu.Content>
+    </DropdownMenu.Portal>
+  )
+}
+
+export function Item({children, label, onPress, ...rest}: ItemProps) {
+  const t = useTheme()
+  const {control} = React.useContext(Context)
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  return (
+    <DropdownMenu.Item asChild>
+      <Pressable
+        {...rest}
+        className="radix-dropdown-item"
+        accessibilityHint=""
+        accessibilityLabel={label}
+        onPress={e => {
+          onPress(e)
+
+          /**
+           * Ported forward from Radix
+           * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item
+           */
+          if (!e.defaultPrevented) {
+            control.close()
+          }
+        }}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        // need `flatten` here for Radix compat
+        style={flatten([
+          a.flex_row,
+          a.align_center,
+          a.gap_lg,
+          a.py_sm,
+          a.rounded_xs,
+          {minHeight: 32, paddingHorizontal: 10},
+          web({outline: 0}),
+          (hovered || focused) && [
+            web({outline: '0 !important'}),
+            t.name === 'light'
+              ? t.atoms.bg_contrast_25
+              : t.atoms.bg_contrast_50,
+          ],
+        ])}
+        {...web({
+          onMouseEnter,
+          onMouseLeave,
+        })}>
+        {children}
+      </Pressable>
+    </DropdownMenu.Item>
+  )
+}
+
+export function ItemText({children, style}: ItemTextProps) {
+  const t = useTheme()
+  return (
+    <Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
+      {children}
+    </Text>
+  )
+}
+
+export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
+  const t = useTheme()
+  return (
+    <Comp
+      size="md"
+      fill={t.atoms.text_contrast_medium.color}
+      style={[
+        position === 'left' && {
+          marginLeft: -2,
+        },
+        position === 'right' && {
+          marginRight: -2,
+          marginLeft: 12,
+        },
+      ]}
+    />
+  )
+}
+
+export function Group({children}: GroupProps) {
+  return children
+}
+
+export function Divider() {
+  const t = useTheme()
+  return (
+    <DropdownMenu.Separator
+      style={flatten([
+        a.my_xs,
+        t.atoms.bg_contrast_100,
+        {
+          height: 1,
+        },
+      ])}
+    />
+  )
+}
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
new file mode 100644
index 000000000..e710971ee
--- /dev/null
+++ b/src/components/Menu/types.ts
@@ -0,0 +1,99 @@
+import React from 'react'
+import {
+  GestureResponderEvent,
+  PressableProps,
+  AccessibilityProps,
+} from 'react-native'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import * as Dialog from '#/components/Dialog'
+import {TextStyleProp, ViewStyleProp} from '#/alf'
+
+export type ContextType = {
+  control: Dialog.DialogOuterProps['control']
+}
+
+export type RadixPassThroughTriggerProps = {
+  id: string
+  type: 'button'
+  disabled: boolean
+  ['data-disabled']: boolean
+  ['data-state']: string
+  ['aria-controls']?: string
+  ['aria-haspopup']?: boolean
+  ['aria-expanded']?: AccessibilityProps['aria-expanded']
+  onKeyDown: (e: React.KeyboardEvent) => void
+  /**
+   * Radix provides this, but we override on web to use `onPress` instead,
+   * which is less sensitive while scrolling.
+   */
+  onPointerDown: PressableProps['onPointerDown']
+}
+export type TriggerProps = {
+  children(props: TriggerChildProps): React.ReactNode
+  label: string
+}
+export type TriggerChildProps =
+  | {
+      isNative: true
+      control: Dialog.DialogOuterProps['control']
+      state: {
+        /**
+         * Web only, `false` on native
+         */
+        hovered: false
+        focused: boolean
+        pressed: boolean
+      }
+      /**
+       * We don't necessarily know what these will be spread on to, so we
+       * should add props one-by-one.
+       *
+       * On web, these properties are applied to a parent `Pressable`, so this
+       * object is empty.
+       */
+      props: {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onPressIn: () => void
+        onPressOut: () => void
+        accessibilityLabel: string
+      }
+    }
+  | {
+      isNative: false
+      control: Dialog.DialogOuterProps['control']
+      state: {
+        hovered: boolean
+        focused: boolean
+        /**
+         * Native only, `false` on web
+         */
+        pressed: false
+      }
+      props: RadixPassThroughTriggerProps & {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onMouseEnter: () => void
+        onMouseLeave: () => void
+        accessibilityLabel: string
+      }
+    }
+
+export type ItemProps = React.PropsWithChildren<
+  Omit<PressableProps, 'style'> &
+    ViewStyleProp & {
+      label: string
+      onPress: (e: GestureResponderEvent) => void
+    }
+>
+
+export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
+export type ItemIconProps = React.PropsWithChildren<{
+  icon: React.ComponentType<SVGIconProps>
+  position?: 'left' | 'right'
+}>
+
+export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 2c79d27cf..37d1b700a 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -1,13 +1,12 @@
 import React from 'react'
-import {View, PressableProps} from 'react-native'
+import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms as a} from '#/alf'
-import {Text} from '#/components/Typography'
-import {Button} from '#/components/Button'
-
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonColor, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
 
 export {useDialogControl as usePromptControl} from '#/components/Dialog'
 
@@ -22,9 +21,12 @@ const Context = React.createContext<{
 export function Outer({
   children,
   control,
+  testID,
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
+  testID?: string
 }>) {
+  const {gtMobile} = useBreakpoints()
   const titleId = React.useId()
   const descriptionId = React.useId()
 
@@ -34,16 +36,16 @@ export function Outer({
   )
 
   return (
-    <Dialog.Outer control={control}>
+    <Dialog.Outer control={control} testID={testID}>
       <Context.Provider value={context}>
         <Dialog.Handle />
 
-        <Dialog.Inner
+        <Dialog.ScrollableInner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={{width: 'auto', maxWidth: 400}}>
+          style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
           {children}
-        </Dialog.Inner>
+        </Dialog.ScrollableInner>
       </Context.Provider>
     </Dialog.Outer>
   )
@@ -71,8 +73,18 @@ export function Description({children}: React.PropsWithChildren<{}>) {
 }
 
 export function Actions({children}: React.PropsWithChildren<{}>) {
+  const {gtMobile} = useBreakpoints()
+
   return (
-    <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
+    <View
+      style={[
+        a.w_full,
+        a.gap_md,
+        a.justify_end,
+        gtMobile
+          ? [a.flex_row, a.flex_row_reverse, a.justify_start]
+          : [a.flex_col],
+      ]}>
       {children}
     </View>
   )
@@ -80,17 +92,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
 
 export function Cancel({
   children,
-}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
+  cta,
+}: React.PropsWithChildren<{
+  /**
+   * Optional i18n string, used in lieu of `children` for simple buttons. If
+   * undefined (and `children` is undefined), it will default to "Cancel".
+   */
+  cta?: string
+}>) {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
+  const onPress = React.useCallback(() => {
+    close()
+  }, [close])
+
   return (
     <Button
       variant="solid"
       color="secondary"
-      size="small"
-      label={_(msg`Cancel`)}
-      onPress={close}>
-      {children}
+      size={gtMobile ? 'small' : 'medium'}
+      label={cta || _(msg`Cancel`)}
+      onPress={onPress}>
+      {children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>}
     </Button>
   )
 }
@@ -98,21 +122,70 @@ export function Cancel({
 export function Action({
   children,
   onPress,
-}: React.PropsWithChildren<{onPress?: () => void}>) {
+  color = 'primary',
+  cta,
+  testID,
+}: React.PropsWithChildren<{
+  onPress: () => void
+  color?: ButtonColor
+  /**
+   * Optional i18n string, used in lieu of `children` for simple buttons. If
+   * undefined (and `children` is undefined), it will default to "Confirm".
+   */
+  cta?: string
+  testID?: string
+}>) {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
   const handleOnPress = React.useCallback(() => {
     close()
-    onPress?.()
+    onPress()
   }, [close, onPress])
+
   return (
     <Button
       variant="solid"
-      color="primary"
-      size="small"
-      label={_(msg`Confirm`)}
-      onPress={handleOnPress}>
-      {children}
+      color={color}
+      size={gtMobile ? 'small' : 'medium'}
+      label={cta || _(msg`Confirm`)}
+      onPress={handleOnPress}
+      testID={testID}>
+      {children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>}
     </Button>
   )
 }
+
+export function Basic({
+  control,
+  title,
+  description,
+  cancelButtonCta,
+  confirmButtonCta,
+  onConfirm,
+  confirmButtonColor,
+}: React.PropsWithChildren<{
+  control: Dialog.DialogOuterProps['control']
+  title: string
+  description: string
+  cancelButtonCta?: string
+  confirmButtonCta?: string
+  onConfirm: () => void
+  confirmButtonColor?: ButtonColor
+}>) {
+  return (
+    <Outer control={control} testID="confirmModal">
+      <Title>{title}</Title>
+      <Description>{description}</Description>
+      <Actions>
+        <Action
+          cta={confirmButtonCta}
+          onPress={onConfirm}
+          color={confirmButtonColor}
+          testID="confirmBtn"
+        />
+        <Cancel cta={cancelButtonCta} />
+      </Actions>
+    </Outer>
+  )
+}
diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx
new file mode 100644
index 000000000..dd07cafa3
--- /dev/null
+++ b/src/components/ReportDialog/SelectLabelerView.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyLabelerDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, useButtonContext} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import * as LabelingServiceCard from '#/components/LabelingServiceCard'
+import {Text} from '#/components/Typography'
+import {ReportDialogProps} from './types'
+
+export function SelectLabelerView({
+  ...props
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  onSelectLabeler: (v: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
+        <Text style={[a.text_2xl, a.font_bold]}>
+          <Trans>Select moderator</Trans>
+        </Text>
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          <Trans>To whom would you like to send this report?</Trans>
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm]}>
+        {props.labelers.map(labeler => {
+          return (
+            <Button
+              key={labeler.creator.did}
+              label={_(msg`Send report to ${labeler.creator.displayName}`)}
+              onPress={() => props.onSelectLabeler(labeler.creator.did)}>
+              <LabelerButton labeler={labeler} />
+            </Button>
+          )
+        })}
+      </View>
+    </View>
+  )
+}
+
+function LabelerButton({
+  labeler,
+}: {
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed
+}) {
+  const t = useTheme()
+  const {hovered, pressed} = useButtonContext()
+  const interacted = hovered || pressed
+
+  return (
+    <LabelingServiceCard.Outer
+      style={[
+        a.p_md,
+        a.rounded_sm,
+        t.atoms.bg_contrast_25,
+        interacted && t.atoms.bg_contrast_50,
+      ]}>
+      <LabelingServiceCard.Avatar avatar={labeler.creator.avatar} />
+      <LabelingServiceCard.Content>
+        <LabelingServiceCard.Title
+          value={getLabelingServiceTitle({
+            displayName: labeler.creator.displayName,
+            handle: labeler.creator.handle,
+          })}
+        />
+        <Text
+          style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semibold]}>
+          @{labeler.creator.handle}
+        </Text>
+      </LabelingServiceCard.Content>
+    </LabelingServiceCard.Outer>
+  )
+}
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
new file mode 100644
index 000000000..c67698348
--- /dev/null
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -0,0 +1,185 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyLabelerDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions'
+import {Link} from '#/components/Link'
+import {DMCA_LINK} from '#/components/ReportDialog/const'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {
+  Button,
+  ButtonIcon,
+  ButtonText,
+  useButtonContext,
+} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {
+  ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
+  ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
+} from '#/components/icons/Chevron'
+import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
+import {Text} from '#/components/Typography'
+import {ReportDialogProps} from './types'
+
+export function SelectReportOptionView({
+  ...props
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  onSelectReportOption: (reportOption: ReportOption) => void
+  goBack: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const allReportOptions = useReportOptions()
+  const reportOptions = allReportOptions[props.params.type]
+
+  const i18n = React.useMemo(() => {
+    let title = _(msg`Report this content`)
+    let description = _(msg`Why should this content be reviewed?`)
+
+    if (props.params.type === 'account') {
+      title = _(msg`Report this user`)
+      description = _(msg`Why should this user be reviewed?`)
+    } else if (props.params.type === 'post') {
+      title = _(msg`Report this post`)
+      description = _(msg`Why should this post be reviewed?`)
+    } else if (props.params.type === 'list') {
+      title = _(msg`Report this list`)
+      description = _(msg`Why should this list be reviewed?`)
+    } else if (props.params.type === 'feedgen') {
+      title = _(msg`Report this feed`)
+      description = _(msg`Why should this feed be reviewed?`)
+    }
+
+    return {
+      title,
+      description,
+    }
+  }, [_, props.params.type])
+
+  return (
+    <View style={[a.gap_lg]}>
+      {props.labelers?.length > 1 ? (
+        <Button
+          size="small"
+          variant="solid"
+          color="secondary"
+          shape="round"
+          label={_(msg`Go back to previous step`)}
+          onPress={props.goBack}>
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+      ) : null}
+
+      <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
+        <Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          {i18n.description}
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm]}>
+        {reportOptions.map(reportOption => {
+          return (
+            <Button
+              key={reportOption.reason}
+              label={_(msg`Create report for ${reportOption.title}`)}
+              onPress={() => props.onSelectReportOption(reportOption)}>
+              <ReportOptionButton
+                title={reportOption.title}
+                description={reportOption.description}
+              />
+            </Button>
+          )
+        })}
+
+        {(props.params.type === 'post' || props.params.type === 'account') && (
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              a.justify_between,
+              a.gap_lg,
+              a.p_md,
+              a.pl_lg,
+              a.rounded_md,
+              t.atoms.bg_contrast_900,
+            ]}>
+            <Text
+              style={[
+                a.flex_1,
+                t.atoms.text_inverted,
+                a.italic,
+                a.leading_snug,
+              ]}>
+              <Trans>Need to report a copyright violation?</Trans>
+            </Text>
+            <Link
+              to={DMCA_LINK}
+              label={_(msg`View details for reporting a copyright violation`)}
+              size="small"
+              variant="solid"
+              color="secondary">
+              <ButtonText>
+                <Trans>View details</Trans>
+              </ButtonText>
+              <ButtonIcon position="right" icon={SquareArrowTopRight} />
+            </Link>
+          </View>
+        )}
+      </View>
+    </View>
+  )
+}
+
+function ReportOptionButton({
+  title,
+  description,
+}: {
+  title: string
+  description: string
+}) {
+  const t = useTheme()
+  const {hovered, pressed} = useButtonContext()
+  const interacted = hovered || pressed
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.p_md,
+        a.rounded_md,
+        {paddingRight: 70},
+        t.atoms.bg_contrast_25,
+        interacted && t.atoms.bg_contrast_50,
+      ]}>
+      <View style={[a.flex_1, a.gap_xs]}>
+        <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+          {title}
+        </Text>
+        <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
+      </View>
+
+      <View
+        style={[
+          a.absolute,
+          a.inset_0,
+          a.justify_center,
+          a.pr_md,
+          {left: 'auto'},
+        ]}>
+        <ChevronRight size="md" fill={t.atoms.text_contrast_low.color} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
new file mode 100644
index 000000000..d47211c81
--- /dev/null
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -0,0 +1,264 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+
+import {atoms as a, useTheme, native} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import * as Toggle from '#/components/forms/Toggle'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/view/com/util/Toast'
+
+import {ReportDialogProps} from './types'
+import {getAgent} from '#/state/session'
+
+export function SubmitView({
+  params,
+  labelers,
+  selectedLabeler,
+  selectedReportOption,
+  goBack,
+  onSubmitComplete,
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  selectedLabeler: string
+  selectedReportOption: ReportOption
+  goBack: () => void
+  onSubmitComplete: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [details, setDetails] = React.useState<string>('')
+  const [submitting, setSubmitting] = React.useState<boolean>(false)
+  const [selectedServices, setSelectedServices] = React.useState<string[]>([
+    selectedLabeler,
+  ])
+  const [error, setError] = React.useState('')
+
+  const submit = React.useCallback(async () => {
+    setSubmitting(true)
+    setError('')
+
+    const $type =
+      params.type === 'account'
+        ? 'com.atproto.admin.defs#repoRef'
+        : 'com.atproto.repo.strongRef'
+    const report = {
+      reasonType: selectedReportOption.reason,
+      subject: {
+        $type,
+        ...params,
+      },
+      reason: details,
+    }
+    const results = await Promise.all(
+      selectedServices.map(did =>
+        getAgent()
+          .withProxy('atproto_labeler', did)
+          .createModerationReport(report)
+          .then(
+            _ => true,
+            _ => false,
+          ),
+      ),
+    )
+
+    setSubmitting(false)
+
+    if (results.includes(true)) {
+      Toast.show(_(msg`Thank you. Your report has been sent.`))
+      onSubmitComplete()
+    } else {
+      setError(
+        _(
+          msg`There was an issue sending your report. Please check your internet connection.`,
+        ),
+      )
+    }
+  }, [
+    _,
+    params,
+    details,
+    selectedReportOption,
+    selectedServices,
+    onSubmitComplete,
+    setError,
+  ])
+
+  return (
+    <View style={[a.gap_2xl]}>
+      <Button
+        size="small"
+        variant="solid"
+        color="secondary"
+        shape="round"
+        label={_(msg`Go back to previous step`)}
+        onPress={goBack}>
+        <ButtonIcon icon={ChevronLeft} />
+      </Button>
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.gap_lg,
+          a.p_md,
+          a.rounded_md,
+          a.border,
+          t.atoms.border_contrast_low,
+        ]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <Text style={[a.text_md, a.font_bold]}>
+            {selectedReportOption.title}
+          </Text>
+          <Text style={[a.leading_tight, {maxWidth: 400}]}>
+            {selectedReportOption.description}
+          </Text>
+        </View>
+
+        <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
+      </View>
+
+      <View style={[a.gap_md]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Select the moderation service(s) to report to</Trans>
+        </Text>
+
+        <Toggle.Group
+          label="Select mod services"
+          values={selectedServices}
+          onChange={setSelectedServices}>
+          <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+            {labelers.map(labeler => {
+              const title = getLabelingServiceTitle({
+                displayName: labeler.creator.displayName,
+                handle: labeler.creator.handle,
+              })
+              return (
+                <Toggle.Item
+                  key={labeler.creator.did}
+                  name={labeler.creator.did}
+                  label={title}>
+                  <LabelerToggle title={title} />
+                </Toggle.Item>
+              )
+            })}
+          </View>
+        </Toggle.Group>
+      </View>
+      <View style={[a.gap_md]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Optionally provide additional information below:</Trans>
+        </Text>
+
+        <View style={[a.relative, a.w_full]}>
+          <Dialog.Input
+            multiline
+            value={details}
+            onChangeText={setDetails}
+            label="Text field"
+            style={{paddingRight: 60}}
+            numberOfLines={6}
+          />
+
+          <View
+            style={[
+              a.absolute,
+              a.flex_row,
+              a.align_center,
+              a.pr_md,
+              a.pb_sm,
+              {
+                bottom: 0,
+                right: 0,
+              },
+            ]}>
+            <CharProgress count={details?.length || 0} />
+          </View>
+        </View>
+      </View>
+
+      <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
+        {!selectedServices.length ||
+          (error && (
+            <Text
+              style={[
+                a.flex_1,
+                a.italic,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+              ]}>
+              {error ? (
+                error
+              ) : (
+                <Trans>You must select at least one labeler for a report</Trans>
+              )}
+            </Text>
+          ))}
+
+        <Button
+          size="large"
+          variant="solid"
+          color="negative"
+          label={_(msg`Send report`)}
+          onPress={submit}
+          disabled={!selectedServices.length}>
+          <ButtonText>
+            <Trans>Send report</Trans>
+          </ButtonText>
+          {submitting && <ButtonIcon icon={Loader} />}
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+function LabelerToggle({title}: {title: string}) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_md,
+        a.p_md,
+        a.pr_lg,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.bg_contrast_25,
+        ctx.selected && [t.atoms.bg_contrast_50],
+      ]}>
+      <Toggle.Checkbox />
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.gap_lg,
+          a.z_10,
+        ]}>
+        <Text
+          style={[
+            native({marginTop: 2}),
+            t.atoms.text_contrast_medium,
+            ctx.selected && t.atoms.text,
+          ]}>
+          {title}
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts
new file mode 100644
index 000000000..30c9aff88
--- /dev/null
+++ b/src/components/ReportDialog/const.ts
@@ -0,0 +1 @@
+export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx
new file mode 100644
index 000000000..c87d32f9e
--- /dev/null
+++ b/src/components/ReportDialog/index.tsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+import {useMyLabelersQuery} from '#/state/queries/preferences'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {AppBskyLabelerDefs} from '@atproto/api'
+import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
+
+import {atoms as a} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
+import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {SelectLabelerView} from './SelectLabelerView'
+import {SelectReportOptionView} from './SelectReportOptionView'
+import {SubmitView} from './SubmitView'
+import {ReportDialogProps} from './types'
+
+export function ReportDialog(props: ReportDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <ReportDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ReportDialogInner(props: ReportDialogProps) {
+  const {_} = useLingui()
+  const {
+    isLoading: isLabelerLoading,
+    data: labelers,
+    error,
+  } = useMyLabelersQuery()
+  const isLoading = useDelayedLoading(500, isLabelerLoading)
+
+  const ref = React.useRef<BottomSheetScrollViewMethods>(null)
+  useOnKeyboardDidShow(() => {
+    ref.current?.scrollToEnd({animated: true})
+  })
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
+      {isLoading ? (
+        <View style={[a.align_center, {height: 100}]}>
+          <Loader size="xl" />
+          {/* Here to capture focus for a hot sec to prevent flash */}
+          <Pressable accessible={false} />
+        </View>
+      ) : error || !labelers ? (
+        <View>
+          <Text style={[a.text_md]}>
+            <Trans>Something went wrong, please try again.</Trans>
+          </Text>
+        </View>
+      ) : (
+        <ReportDialogLoaded labelers={labelers} {...props} />
+      )}
+    </Dialog.ScrollableInner>
+  )
+}
+
+function ReportDialogLoaded(
+  props: ReportDialogProps & {
+    labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  },
+) {
+  const [selectedLabeler, setSelectedLabeler] = React.useState<
+    string | undefined
+  >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined)
+  const [selectedReportOption, setSelectedReportOption] = React.useState<
+    ReportOption | undefined
+  >()
+
+  if (selectedReportOption && selectedLabeler) {
+    return (
+      <SubmitView
+        {...props}
+        selectedLabeler={selectedLabeler}
+        selectedReportOption={selectedReportOption}
+        goBack={() => setSelectedReportOption(undefined)}
+        onSubmitComplete={() => props.control.close()}
+      />
+    )
+  }
+  if (selectedLabeler) {
+    return (
+      <SelectReportOptionView
+        {...props}
+        goBack={() => setSelectedLabeler(undefined)}
+        onSelectReportOption={setSelectedReportOption}
+      />
+    )
+  }
+  return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} />
+}
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
new file mode 100644
index 000000000..0c8a1e077
--- /dev/null
+++ b/src/components/ReportDialog/types.ts
@@ -0,0 +1,15 @@
+import * as Dialog from '#/components/Dialog'
+
+export type ReportDialogProps = {
+  control: Dialog.DialogOuterProps['control']
+  params:
+    | {
+        type: 'post' | 'list' | 'feedgen' | 'other'
+        uri: string
+        cid: string
+      }
+    | {
+        type: 'account'
+        did: string
+      }
+}
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 068ee99e0..1a14415cf 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -1,11 +1,15 @@
 import React from 'react'
 import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
-import {atoms as a, TextStyleProp} from '#/alf'
+import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
 import {InlineLink} from '#/components/Link'
-import {Text} from '#/components/Typography'
+import {Text, TextProps} from '#/components/Typography'
 import {toShortUrl} from 'lib/strings/url-helpers'
-import {getAgent} from '#/state/session'
+import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
+import {isNative} from '#/platform/detection'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
 
 const WORD_WRAP = {wordWrap: 1}
 
@@ -15,34 +19,24 @@ export function RichText({
   style,
   numberOfLines,
   disableLinks,
-  resolveFacets = false,
-}: TextStyleProp & {
-  value: RichTextAPI | string
-  testID?: string
-  numberOfLines?: number
-  disableLinks?: boolean
-  resolveFacets?: boolean
-}) {
-  const detected = React.useRef(false)
-  const [richText, setRichText] = React.useState<RichTextAPI>(() =>
-    value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
+  selectable,
+  enableTags = false,
+  authorHandle,
+}: TextStyleProp &
+  Pick<TextProps, 'selectable'> & {
+    value: RichTextAPI | string
+    testID?: string
+    numberOfLines?: number
+    disableLinks?: boolean
+    enableTags?: boolean
+    authorHandle?: string
+  }) {
+  const richText = React.useMemo(
+    () =>
+      value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
+    [value],
   )
-  const styles = [a.leading_normal, style]
-
-  React.useEffect(() => {
-    if (!resolveFacets) return
-
-    async function detectFacets() {
-      const rt = new RichTextAPI({text: richText.text})
-      await rt.detectFacets(getAgent())
-      setRichText(rt)
-    }
-
-    if (!detected.current) {
-      detected.current = true
-      detectFacets()
-    }
-  }, [richText, setRichText, resolveFacets])
+  const styles = [a.leading_snug, flatten(style)]
 
   const {text, facets} = richText
 
@@ -50,6 +44,7 @@ export function RichText({
     if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
       return (
         <Text
+          selectable={selectable}
           testID={testID}
           style={[
             {
@@ -65,6 +60,7 @@ export function RichText({
     }
     return (
       <Text
+        selectable={selectable}
         testID={testID}
         style={styles}
         numberOfLines={numberOfLines}
@@ -81,6 +77,7 @@ export function RichText({
   for (const segment of richText.segments()) {
     const link = segment.link
     const mention = segment.mention
+    const tag = segment.tag
     if (
       mention &&
       AppBskyRichtextFacet.validateMention(mention).success &&
@@ -88,6 +85,7 @@ export function RichText({
     ) {
       els.push(
         <InlineLink
+          selectable={selectable}
           key={key}
           to={`/profile/${mention.did}`}
           style={[...styles, {pointerEvents: 'auto'}]}
@@ -102,16 +100,32 @@ export function RichText({
       } else {
         els.push(
           <InlineLink
+            selectable={selectable}
             key={key}
             to={link.uri}
             style={[...styles, {pointerEvents: 'auto'}]}
             // @ts-ignore TODO
-            dataSet={WORD_WRAP}
-            warnOnMismatchingLabel>
+            dataSet={WORD_WRAP}>
             {toShortUrl(segment.text)}
           </InlineLink>,
         )
       }
+    } else if (
+      !disableLinks &&
+      enableTags &&
+      tag &&
+      AppBskyRichtextFacet.validateTag(tag).success
+    ) {
+      els.push(
+        <RichTextTag
+          key={key}
+          text={segment.text}
+          tag={tag.tag}
+          style={styles}
+          selectable={selectable}
+          authorHandle={authorHandle}
+        />,
+      )
     } else {
       els.push(segment.text)
     }
@@ -120,6 +134,7 @@ export function RichText({
 
   return (
     <Text
+      selectable={selectable}
       testID={testID}
       style={styles}
       numberOfLines={numberOfLines}
@@ -129,3 +144,81 @@ export function RichText({
     </Text>
   )
 }
+
+function RichTextTag({
+  text,
+  tag,
+  style,
+  selectable,
+  authorHandle,
+}: {
+  text: string
+  tag: string
+  selectable?: boolean
+  authorHandle?: string
+} & TextStyleProp) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const control = useTagMenuControl()
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  const open = React.useCallback(() => {
+    control.open()
+  }, [control])
+
+  /*
+   * N.B. On web, this is wrapped in another pressable comopnent with a11y
+   * labels, etc. That's why only some of these props are applied here.
+   */
+
+  return (
+    <React.Fragment>
+      <TagMenu control={control} tag={tag} authorHandle={authorHandle}>
+        <Text
+          selectable={selectable}
+          {...native({
+            accessibilityLabel: _(msg`Hashtag: #${tag}`),
+            accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
+            accessibilityRole: isNative ? 'button' : undefined,
+            onPress: open,
+            onPressIn: onPressIn,
+            onPressOut: onPressOut,
+          })}
+          {...web({
+            onMouseEnter: onHoverIn,
+            onMouseLeave: onHoverOut,
+          })}
+          // @ts-ignore
+          onFocus={onFocus}
+          onBlur={onBlur}
+          style={[
+            style,
+            {
+              pointerEvents: 'auto',
+              color: t.palette.primary_500,
+            },
+            web({
+              cursor: 'pointer',
+            }),
+            (hovered || focused || pressed) && {
+              ...web({outline: 0}),
+              textDecorationLine: 'underline',
+              textDecorationColor: t.palette.primary_500,
+            },
+          ]}>
+          {text}
+        </Text>
+      </TagMenu>
+    </React.Fragment>
+  )
+}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
new file mode 100644
index 000000000..0ed703667
--- /dev/null
+++ b/src/components/TagMenu/index.tsx
@@ -0,0 +1,277 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {atoms as a, native, useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {Divider} from '#/components/Divider'
+import {Link} from '#/components/Link'
+import {makeSearchLink} from '#/lib/routes/links'
+import {NavigationProp} from '#/lib/routes/types'
+import {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+import {Loader} from '#/components/Loader'
+import {isInvalidHandle} from '#/lib/strings/handles'
+
+export function useTagMenuControl() {
+  return Dialog.useDialogControl()
+}
+
+export function TagMenu({
+  children,
+  control,
+  tag,
+  authorHandle,
+}: React.PropsWithChildren<{
+  control: Dialog.DialogOuterProps['control']
+  /**
+   * This should be the sanitized tag value from the facet itself, not the
+   * "display" value with a leading `#`.
+   */
+  tag: string
+  authorHandle?: string
+}>) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const {isLoading: isPreferencesLoading, data: preferences} =
+    usePreferencesQuery()
+  const {
+    mutateAsync: upsertMutedWord,
+    variables: optimisticUpsert,
+    reset: resetUpsert,
+  } = useUpsertMutedWordsMutation()
+  const {
+    mutateAsync: removeMutedWord,
+    variables: optimisticRemove,
+    reset: resetRemove,
+  } = useRemoveMutedWordMutation()
+  const displayTag = '#' + tag
+
+  const isMuted = Boolean(
+    (preferences?.moderationPrefs.mutedWords?.find(
+      m => m.value === tag && m.targets.includes('tag'),
+    ) ??
+      optimisticUpsert?.find(
+        m => m.value === tag && m.targets.includes('tag'),
+      )) &&
+      !(optimisticRemove?.value === tag),
+  )
+
+  return (
+    <>
+      {children}
+
+      <Dialog.Outer control={control}>
+        <Dialog.Handle />
+
+        <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
+          {isPreferencesLoading ? (
+            <View style={[a.w_full, a.align_center]}>
+              <Loader size="lg" />
+            </View>
+          ) : (
+            <>
+              <View
+                style={[
+                  a.rounded_md,
+                  a.border,
+                  a.mb_md,
+                  t.atoms.border_contrast_low,
+                  t.atoms.bg_contrast_25,
+                ]}>
+                <Link
+                  label={_(msg`Search for all posts with tag ${displayTag}`)}
+                  to={makeSearchLink({query: displayTag})}
+                  onPress={e => {
+                    e.preventDefault()
+
+                    control.close(() => {
+                      navigation.push('Hashtag', {
+                        tag: encodeURIComponent(tag),
+                      })
+                    })
+
+                    return false
+                  }}>
+                  <View
+                    style={[
+                      a.w_full,
+                      a.flex_row,
+                      a.align_center,
+                      a.justify_start,
+                      a.gap_md,
+                      a.px_lg,
+                      a.py_md,
+                    ]}>
+                    <Search size="lg" style={[t.atoms.text_contrast_medium]} />
+                    <Text
+                      numberOfLines={1}
+                      ellipsizeMode="middle"
+                      style={[
+                        a.flex_1,
+                        a.text_md,
+                        a.font_bold,
+                        native({top: 2}),
+                        t.atoms.text_contrast_medium,
+                      ]}>
+                      <Trans>
+                        See{' '}
+                        <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                          {displayTag}
+                        </Text>{' '}
+                        posts
+                      </Trans>
+                    </Text>
+                  </View>
+                </Link>
+
+                {authorHandle && !isInvalidHandle(authorHandle) && (
+                  <>
+                    <Divider />
+
+                    <Link
+                      label={_(
+                        msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
+                      )}
+                      to={makeSearchLink({
+                        query: displayTag,
+                        from: authorHandle,
+                      })}
+                      onPress={e => {
+                        e.preventDefault()
+
+                        control.close(() => {
+                          navigation.push('Hashtag', {
+                            tag: encodeURIComponent(tag),
+                            author: authorHandle,
+                          })
+                        })
+
+                        return false
+                      }}>
+                      <View
+                        style={[
+                          a.w_full,
+                          a.flex_row,
+                          a.align_center,
+                          a.justify_start,
+                          a.gap_md,
+                          a.px_lg,
+                          a.py_md,
+                        ]}>
+                        <Person
+                          size="lg"
+                          style={[t.atoms.text_contrast_medium]}
+                        />
+                        <Text
+                          numberOfLines={1}
+                          ellipsizeMode="middle"
+                          style={[
+                            a.flex_1,
+                            a.text_md,
+                            a.font_bold,
+                            native({top: 2}),
+                            t.atoms.text_contrast_medium,
+                          ]}>
+                          <Trans>
+                            See{' '}
+                            <Text
+                              style={[a.text_md, a.font_bold, t.atoms.text]}>
+                              {displayTag}
+                            </Text>{' '}
+                            posts by this user
+                          </Trans>
+                        </Text>
+                      </View>
+                    </Link>
+                  </>
+                )}
+
+                {preferences ? (
+                  <>
+                    <Divider />
+
+                    <Button
+                      label={
+                        isMuted
+                          ? _(msg`Unmute all ${displayTag} posts`)
+                          : _(msg`Mute all ${displayTag} posts`)
+                      }
+                      onPress={() => {
+                        control.close(() => {
+                          if (isMuted) {
+                            resetUpsert()
+                            removeMutedWord({
+                              value: tag,
+                              targets: ['tag'],
+                            })
+                          } else {
+                            resetRemove()
+                            upsertMutedWord([{value: tag, targets: ['tag']}])
+                          }
+                        })
+                      }}>
+                      <View
+                        style={[
+                          a.w_full,
+                          a.flex_row,
+                          a.align_center,
+                          a.justify_start,
+                          a.gap_md,
+                          a.px_lg,
+                          a.py_md,
+                        ]}>
+                        <Mute
+                          size="lg"
+                          style={[t.atoms.text_contrast_medium]}
+                        />
+                        <Text
+                          numberOfLines={1}
+                          ellipsizeMode="middle"
+                          style={[
+                            a.flex_1,
+                            a.text_md,
+                            a.font_bold,
+                            native({top: 2}),
+                            t.atoms.text_contrast_medium,
+                          ]}>
+                          {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
+                          <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                            {displayTag}
+                          </Text>{' '}
+                          <Trans>posts</Trans>
+                        </Text>
+                      </View>
+                    </Button>
+                  </>
+                ) : null}
+              </View>
+
+              <Button
+                label={_(msg`Close this dialog`)}
+                size="small"
+                variant="ghost"
+                color="secondary"
+                onPress={() => control.close()}>
+                <ButtonText>
+                  <Trans>Cancel</Trans>
+                </ButtonText>
+              </Button>
+            </>
+          )}
+        </Dialog.Inner>
+      </Dialog.Outer>
+    </>
+  )
+}
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
new file mode 100644
index 000000000..433622386
--- /dev/null
+++ b/src/components/TagMenu/index.web.tsx
@@ -0,0 +1,149 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {isInvalidHandle} from '#/lib/strings/handles'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
+import {NavigationProp} from '#/lib/routes/types'
+import {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+import {enforceLen} from '#/lib/strings/helpers'
+import {web} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+
+export function useTagMenuControl(): Dialog.DialogControlProps {
+  return {
+    id: '',
+    // @ts-ignore
+    ref: null,
+    open: () => {
+      throw new Error(`TagMenu controls are only available on native platforms`)
+    },
+    close: () => {
+      throw new Error(`TagMenu controls are only available on native platforms`)
+    },
+  }
+}
+
+export function TagMenu({
+  children,
+  tag,
+  authorHandle,
+}: React.PropsWithChildren<{
+  /**
+   * This should be the sanitized tag value from the facet itself, not the
+   * "display" value with a leading `#`.
+   */
+  tag: string
+  authorHandle?: string
+}>) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+  const {data: preferences} = usePreferencesQuery()
+  const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
+    useUpsertMutedWordsMutation()
+  const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
+    useRemoveMutedWordMutation()
+  const isMuted = Boolean(
+    (preferences?.moderationPrefs.mutedWords?.find(
+      m => m.value === tag && m.targets.includes('tag'),
+    ) ??
+      optimisticUpsert?.find(
+        m => m.value === tag && m.targets.includes('tag'),
+      )) &&
+      !(optimisticRemove?.value === tag),
+  )
+  const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
+
+  const dropdownItems = React.useMemo(() => {
+    return [
+      {
+        label: _(msg`See ${truncatedTag} posts`),
+        onPress() {
+          navigation.push('Hashtag', {
+            tag: encodeURIComponent(tag),
+          })
+        },
+        testID: 'tagMenuSearch',
+        icon: {
+          ios: {
+            name: 'magnifyingglass',
+          },
+          android: '',
+          web: 'magnifying-glass',
+        },
+      },
+      authorHandle &&
+        !isInvalidHandle(authorHandle) && {
+          label: _(msg`See ${truncatedTag} posts by user`),
+          onPress() {
+            navigation.push('Hashtag', {
+              tag: encodeURIComponent(tag),
+              author: authorHandle,
+            })
+          },
+          testID: 'tagMenuSearchByUser',
+          icon: {
+            ios: {
+              name: 'magnifyingglass',
+            },
+            android: '',
+            web: ['far', 'user'],
+          },
+        },
+      preferences && {
+        label: 'separator',
+      },
+      preferences && {
+        label: isMuted
+          ? _(msg`Unmute ${truncatedTag}`)
+          : _(msg`Mute ${truncatedTag}`),
+        onPress() {
+          if (isMuted) {
+            removeMutedWord({value: tag, targets: ['tag']})
+          } else {
+            upsertMutedWord([{value: tag, targets: ['tag']}])
+          }
+        },
+        testID: 'tagMenuMute',
+        icon: {
+          ios: {
+            name: 'speaker.slash',
+          },
+          android: 'ic_menu_sort_alphabetically',
+          web: isMuted ? 'eye' : ['far', 'eye-slash'],
+        },
+      },
+    ].filter(Boolean)
+  }, [
+    _,
+    authorHandle,
+    isMuted,
+    navigation,
+    preferences,
+    tag,
+    truncatedTag,
+    upsertMutedWord,
+    removeMutedWord,
+  ])
+
+  return (
+    <EventStopper>
+      <NativeDropdown
+        accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
+        accessibilityHint=""
+        // @ts-ignore
+        items={dropdownItems}
+        triggerStyle={web({
+          textAlign: 'left',
+        })}>
+        {children}
+      </NativeDropdown>
+    </EventStopper>
+  )
+}
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index b34f51018..f8b3ad1bd 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,7 +1,21 @@
 import React from 'react'
-import {Text as RNText, TextStyle, TextProps} from 'react-native'
+import {
+  Text as RNText,
+  StyleProp,
+  TextStyle,
+  TextProps as RNTextProps,
+} from 'react-native'
+import {UITextView} from 'react-native-ui-text-view'
 
 import {useTheme, atoms, web, flatten} from '#/alf'
+import {isIOS, isNative} from '#/platform/detection'
+
+export type TextProps = RNTextProps & {
+  /**
+   * Lets the user select text, to use the native copy and paste functionality.
+   */
+  selectable?: boolean
+}
 
 /**
  * Util to calculate lineHeight from a text size atom and a leading atom
@@ -25,17 +39,17 @@ export function leading<
  * If the `lineHeight` value is > 2, we assume it's an absolute value and
  * returns it as-is.
  */
-function normalizeTextStyles(styles: TextStyle[]) {
+export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
   const s = flatten(styles)
   // should always be defined on these components
   const fontSize = s.fontSize || atoms.text_md.fontSize
 
   if (s?.lineHeight) {
-    if (s.lineHeight <= 2) {
+    if (s.lineHeight !== 0 && s.lineHeight <= 2) {
       s.lineHeight = Math.round(fontSize * s.lineHeight)
     }
-  } else {
-    s.lineHeight = fontSize
+  } else if (!isNative) {
+    s.lineHeight = s.fontSize
   }
 
   return s
@@ -44,27 +58,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
 /**
  * Our main text component. Use this most of the time.
  */
-export function Text({style, ...rest}: TextProps) {
+export function Text({style, selectable, ...rest}: TextProps) {
   const t = useTheme()
   const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
-  return <RNText style={s} {...rest} />
+  return selectable && isIOS ? (
+    <UITextView style={s} {...rest} />
+  ) : (
+    <RNText selectable={selectable} style={s} {...rest} />
+  )
 }
 
 export function createHeadingElement({level}: {level: number}) {
   return function HeadingElement({style, ...rest}: TextProps) {
-    const t = useTheme()
     const attr =
       web({
         role: 'heading',
         'aria-level': level,
       }) || {}
-    return (
-      <RNText
-        {...attr}
-        {...rest}
-        style={normalizeTextStyles([t.atoms.text, flatten(style)])}
-      />
-    )
+    return <Text {...attr} {...rest} style={style} />
   }
 }
 
@@ -78,21 +89,15 @@ export const H4 = createHeadingElement({level: 4})
 export const H5 = createHeadingElement({level: 5})
 export const H6 = createHeadingElement({level: 6})
 export function P({style, ...rest}: TextProps) {
-  const t = useTheme()
   const attr =
     web({
       role: 'paragraph',
     }) || {}
   return (
-    <RNText
+    <Text
       {...attr}
       {...rest}
-      style={normalizeTextStyles([
-        atoms.text_md,
-        atoms.leading_normal,
-        t.atoms.text,
-        flatten(style),
-      ])}
+      style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
     />
   )
 }
diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
new file mode 100644
index 000000000..4a3e96e56
--- /dev/null
+++ b/src/components/dialogs/BirthDateSettings.tsx
@@ -0,0 +1,132 @@
+import React from 'react'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+import {View} from 'react-native'
+
+import * as Dialog from '#/components/Dialog'
+import {Text} from '../Typography'
+import {DateInput} from '#/view/com/util/forms/DateInput'
+import {logger} from '#/logger'
+import {
+  usePreferencesQuery,
+  usePreferencesSetBirthDateMutation,
+  UsePreferencesQueryResponse,
+} from '#/state/queries/preferences'
+import {Button, ButtonIcon, ButtonText} from '../Button'
+import {atoms as a, useTheme} from '#/alf'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {cleanError} from '#/lib/strings/errors'
+import {isIOS, isWeb} from '#/platform/detection'
+import {Loader} from '#/components/Loader'
+
+export function BirthDateSettingsDialog({
+  control,
+}: {
+  control: Dialog.DialogControlProps
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {isLoading, error, data: preferences} = usePreferencesQuery()
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner label={_(msg`My Birthday`)}>
+        <View style={[a.gap_sm, a.pb_lg]}>
+          <Text style={[a.text_2xl, a.font_bold]}>
+            <Trans>My Birthday</Trans>
+          </Text>
+          <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+            <Trans>This information is not shared with other users.</Trans>
+          </Text>
+        </View>
+
+        {isLoading ? (
+          <Loader size="xl" />
+        ) : error || !preferences ? (
+          <ErrorMessage
+            message={
+              error?.toString() ||
+              _(
+                msg`We were unable to load your birth date preferences. Please try again.`,
+              )
+            }
+            style={[a.rounded_sm]}
+          />
+        ) : (
+          <BirthdayInner control={control} preferences={preferences} />
+        )}
+
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+function BirthdayInner({
+  control,
+  preferences,
+}: {
+  control: Dialog.DialogControlProps
+  preferences: UsePreferencesQueryResponse
+}) {
+  const {_} = useLingui()
+  const [date, setDate] = React.useState(preferences.birthDate || new Date())
+  const {
+    isPending,
+    isError,
+    error,
+    mutateAsync: setBirthDate,
+  } = usePreferencesSetBirthDateMutation()
+  const hasChanged = date !== preferences.birthDate
+
+  const onSave = React.useCallback(async () => {
+    try {
+      // skip if date is the same
+      if (hasChanged) {
+        await setBirthDate({birthDate: date})
+      }
+      control.close()
+    } catch (e: any) {
+      logger.error(`setBirthDate failed`, {message: e.message})
+    }
+  }, [date, setBirthDate, control, hasChanged])
+
+  return (
+    <View style={a.gap_lg} testID="birthDateSettingsDialog">
+      <View style={isIOS && [a.w_full, a.align_center]}>
+        <DateInput
+          handleAsUTC
+          testID="birthdayInput"
+          value={date}
+          onChange={setDate}
+          buttonType="default-light"
+          buttonStyle={[a.rounded_sm]}
+          buttonLabelType="lg"
+          accessibilityLabel={_(msg`Birthday`)}
+          accessibilityHint={_(msg`Enter your birth date`)}
+          accessibilityLabelledBy="birthDate"
+        />
+      </View>
+
+      {isError ? (
+        <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
+      ) : undefined}
+
+      <View style={isWeb && [a.flex_row, a.justify_end]}>
+        <Button
+          label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)}
+          size="medium"
+          onPress={onSave}
+          variant="solid"
+          color="primary">
+          <ButtonText>
+            {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
+          </ButtonText>
+          {isPending && <ButtonIcon icon={Loader} />}
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx
new file mode 100644
index 000000000..87bd5c2ed
--- /dev/null
+++ b/src/components/dialogs/Context.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+
+import * as Dialog from '#/components/Dialog'
+
+type Control = Dialog.DialogOuterProps['control']
+
+type ControlsContext = {
+  mutedWordsDialogControl: Control
+}
+
+const ControlsContext = React.createContext({
+  mutedWordsDialogControl: {} as Control,
+})
+
+export function useGlobalDialogsControlContext() {
+  return React.useContext(ControlsContext)
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const mutedWordsDialogControl = Dialog.useDialogControl()
+  const ctx = React.useMemo<ControlsContext>(
+    () => ({mutedWordsDialogControl}),
+    [mutedWordsDialogControl],
+  )
+
+  return (
+    <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
+  )
+}
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
new file mode 100644
index 000000000..46f319adf
--- /dev/null
+++ b/src/components/dialogs/MutedWords.tsx
@@ -0,0 +1,376 @@
+import React from 'react'
+import {Keyboard, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
+
+import {
+  usePreferencesQuery,
+  useUpsertMutedWordsMutation,
+  useRemoveMutedWordMutation,
+} from '#/state/queries/preferences'
+import {isNative} from '#/platform/detection'
+import {
+  atoms as a,
+  useTheme,
+  useBreakpoints,
+  ViewStyleProp,
+  web,
+  native,
+} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
+import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
+import {Divider} from '#/components/Divider'
+import {Loader} from '#/components/Loader'
+import {logger} from '#/logger'
+import * as Dialog from '#/components/Dialog'
+import * as Toggle from '#/components/forms/Toggle'
+import * as Prompt from '#/components/Prompt'
+
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+
+export function MutedWordsDialog() {
+  const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <MutedWordsInner control={control} />
+    </Dialog.Outer>
+  )
+}
+
+function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const {
+    isLoading: isPreferencesLoading,
+    data: preferences,
+    error: preferencesError,
+  } = usePreferencesQuery()
+  const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
+  const [field, setField] = React.useState('')
+  const [options, setOptions] = React.useState(['content'])
+  const [error, setError] = React.useState('')
+
+  const submit = React.useCallback(async () => {
+    const sanitizedValue = sanitizeMutedWordValue(field)
+    const targets = ['tag', options.includes('content') && 'content'].filter(
+      Boolean,
+    ) as AppBskyActorDefs.MutedWord['targets']
+
+    if (!sanitizedValue || !targets.length) {
+      setField('')
+      setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
+      return
+    }
+
+    try {
+      // send raw value and rely on SDK as sanitization source of truth
+      await addMutedWord([{value: field, targets}])
+      setField('')
+    } catch (e: any) {
+      logger.error(`Failed to save muted word`, {message: e.message})
+      setError(e.message)
+    }
+  }, [_, field, options, addMutedWord, setField])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
+      <View onTouchStart={Keyboard.dismiss}>
+        <Text
+          style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
+          <Trans>Add muted words and tags</Trans>
+        </Text>
+        <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Posts can be muted based on their text, their tags, or both.
+          </Trans>
+        </Text>
+
+        <View style={[a.pb_lg]}>
+          <Dialog.Input
+            autoCorrect={false}
+            autoCapitalize="none"
+            autoComplete="off"
+            label={_(msg`Enter a word or tag`)}
+            placeholder={_(msg`Enter a word or tag`)}
+            value={field}
+            onChangeText={value => {
+              if (error) {
+                setError('')
+              }
+              setField(value)
+            }}
+            onSubmitEditing={submit}
+          />
+
+          <Toggle.Group
+            label={_(msg`Toggle between muted word options.`)}
+            type="radio"
+            values={options}
+            onChange={setOptions}>
+            <View
+              style={[
+                a.pt_sm,
+                a.py_sm,
+                a.flex_row,
+                a.align_center,
+                a.gap_sm,
+                a.flex_wrap,
+              ]}>
+              <Toggle.Item
+                label={_(msg`Mute this word in post text and tags`)}
+                name="content"
+                style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+                <TargetToggle>
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <Toggle.Radio />
+                    <Toggle.Label>
+                      <Trans>Mute in text & tags</Trans>
+                    </Toggle.Label>
+                  </View>
+                  <PageText size="sm" />
+                </TargetToggle>
+              </Toggle.Item>
+
+              <Toggle.Item
+                label={_(msg`Mute this word in tags only`)}
+                name="tag"
+                style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+                <TargetToggle>
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <Toggle.Radio />
+                    <Toggle.Label>
+                      <Trans>Mute in tags only</Trans>
+                    </Toggle.Label>
+                  </View>
+                  <Hashtag size="sm" />
+                </TargetToggle>
+              </Toggle.Item>
+
+              <Button
+                disabled={isPending || !field}
+                label={_(msg`Add mute word for configured settings`)}
+                size="small"
+                color="primary"
+                variant="solid"
+                style={[!gtMobile && [a.w_full, a.flex_0]]}
+                onPress={submit}>
+                <ButtonText>
+                  <Trans>Add</Trans>
+                </ButtonText>
+                <ButtonIcon icon={isPending ? Loader : Plus} />
+              </Button>
+            </View>
+          </Toggle.Group>
+
+          {error && (
+            <View
+              style={[
+                a.mb_lg,
+                a.flex_row,
+                a.rounded_sm,
+                a.p_md,
+                a.mb_xs,
+                t.atoms.bg_contrast_25,
+                {
+                  backgroundColor: t.palette.negative_400,
+                },
+              ]}>
+              <Text
+                style={[
+                  a.italic,
+                  {color: t.palette.white},
+                  native({marginTop: 2}),
+                ]}>
+                {error}
+              </Text>
+            </View>
+          )}
+
+          <Text
+            style={[
+              a.pt_xs,
+              a.text_sm,
+              a.italic,
+              a.leading_snug,
+              t.atoms.text_contrast_medium,
+            ]}>
+            <Trans>
+              We recommend avoiding common words that appear in many posts,
+              since it can result in no posts being shown.
+            </Trans>
+          </Text>
+        </View>
+
+        <Divider />
+
+        <View style={[a.pt_2xl]}>
+          <Text
+            style={[
+              a.text_md,
+              a.font_bold,
+              a.pb_md,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Your muted words</Trans>
+          </Text>
+
+          {isPreferencesLoading ? (
+            <Loader />
+          ) : preferencesError || !preferences ? (
+            <View
+              style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
+              <Text style={[a.italic, t.atoms.text_contrast_high]}>
+                <Trans>
+                  We're sorry, but we weren't able to load your muted words at
+                  this time. Please try again.
+                </Trans>
+              </Text>
+            </View>
+          ) : preferences.moderationPrefs.mutedWords.length ? (
+            [...preferences.moderationPrefs.mutedWords]
+              .reverse()
+              .map((word, i) => (
+                <MutedWordRow
+                  key={word.value + i}
+                  word={word}
+                  style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
+                />
+              ))
+          ) : (
+            <View
+              style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
+              <Text style={[a.italic, t.atoms.text_contrast_high]}>
+                <Trans>You haven't muted any words or tags yet</Trans>
+              </Text>
+            </View>
+          )}
+        </View>
+
+        {isNative && <View style={{height: 20}} />}
+      </View>
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+function MutedWordRow({
+  style,
+  word,
+}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
+  const control = Prompt.usePromptControl()
+
+  const remove = React.useCallback(async () => {
+    control.close()
+    removeMutedWord(word)
+  }, [removeMutedWord, word, control])
+
+  return (
+    <>
+      <Prompt.Basic
+        control={control}
+        title={_(msg`Are you sure?`)}
+        description={_(
+          msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
+        )}
+        onConfirm={remove}
+        confirmButtonCta={_(msg`Remove`)}
+        confirmButtonColor="negative"
+      />
+
+      <View
+        style={[
+          a.py_md,
+          a.px_lg,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.rounded_md,
+          a.gap_md,
+          style,
+        ]}>
+        <Text
+          style={[
+            a.flex_1,
+            a.leading_snug,
+            a.w_full,
+            a.font_bold,
+            t.atoms.text_contrast_high,
+            web({
+              overflowWrap: 'break-word',
+              wordBreak: 'break-word',
+            }),
+          ]}>
+          {word.value}
+        </Text>
+
+        <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
+          {word.targets.map(target => (
+            <View
+              key={target}
+              style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
+              <Text
+                style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
+                {target === 'content' ? _(msg`text`) : _(msg`tag`)}
+              </Text>
+            </View>
+          ))}
+
+          <Button
+            label={_(msg`Remove mute word from your list`)}
+            size="tiny"
+            shape="round"
+            variant="ghost"
+            color="secondary"
+            onPress={() => control.open()}
+            style={[a.ml_sm]}>
+            <ButtonIcon icon={isPending ? Loader : X} />
+          </Button>
+        </View>
+      </View>
+    </>
+  )
+}
+
+function TargetToggle({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+  const {gtMobile} = useBreakpoints()
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.gap_xs,
+        a.flex_1,
+        a.py_sm,
+        a.px_sm,
+        gtMobile && a.px_md,
+        a.rounded_sm,
+        t.atoms.bg_contrast_50,
+        (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
+        ctx.selected && [
+          {
+            backgroundColor:
+              t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
+          },
+        ],
+        ctx.disabled && {
+          opacity: 0.8,
+        },
+      ]}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
index 83fa285f5..700d15e6d 100644
--- a/src/components/forms/DateField/index.android.tsx
+++ b/src/components/forms/DateField/index.android.tsx
@@ -1,20 +1,11 @@
 import React from 'react'
-import {View, Pressable} from 'react-native'
-import DateTimePicker, {
-  BaseProps as DateTimePickerProps,
-} from '@react-native-community/datetimepicker'
-
-import {useTheme, atoms} from '#/alf'
-import {Text} from '#/components/Typography'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import * as TextField from '#/components/forms/TextField'
-import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+import DatePicker from 'react-native-date-picker'
 
+import {useTheme} from '#/alf'
 import {DateFieldProps} from '#/components/forms/DateField/types'
-import {
-  localizeDate,
-  toSimpleDateString,
-} from '#/components/forms/DateField/utils'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import * as TextField from '#/components/forms/TextField'
+import {DateFieldButton} from './index.shared'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -25,84 +16,55 @@ export function DateField({
   label,
   isInvalid,
   testID,
+  accessibilityHint,
 }: DateFieldProps) {
   const t = useTheme()
   const [open, setOpen] = React.useState(false)
-  const {
-    state: pressed,
-    onIn: onPressIn,
-    onOut: onPressOut,
-  } = useInteractionState()
-  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
 
-  const {chromeFocus, chromeError, chromeErrorHover} =
-    TextField.useSharedInputStyles()
-
-  const onChangeInternal = React.useCallback<
-    Required<DateTimePickerProps>['onChange']
-  >(
-    (_event, date) => {
+  const onChangeInternal = React.useCallback(
+    (date: Date) => {
       setOpen(false)
 
-      if (date) {
-        const formatted = toSimpleDateString(date)
-        onChangeDate(formatted)
-      }
+      const formatted = toSimpleDateString(date)
+      onChangeDate(formatted)
     },
     [onChangeDate, setOpen],
   )
 
-  return (
-    <View style={[atoms.relative, atoms.w_full]}>
-      <Pressable
-        aria-label={label}
-        accessibilityLabel={label}
-        accessibilityHint={undefined}
-        onPress={() => setOpen(true)}
-        onPressIn={onPressIn}
-        onPressOut={onPressOut}
-        onFocus={onFocus}
-        onBlur={onBlur}
-        style={[
-          {
-            paddingTop: 16,
-            paddingBottom: 16,
-            borderColor: 'transparent',
-            borderWidth: 2,
-          },
-          atoms.flex_row,
-          atoms.flex_1,
-          atoms.w_full,
-          atoms.px_lg,
-          atoms.rounded_sm,
-          t.atoms.bg_contrast_50,
-          focused || pressed ? chromeFocus : {},
-          isInvalid ? chromeError : {},
-          isInvalid && (focused || pressed) ? chromeErrorHover : {},
-        ]}>
-        <TextField.Icon icon={CalendarDays} />
+  const onPress = React.useCallback(() => {
+    setOpen(true)
+  }, [])
 
-        <Text
-          style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
-          {localizeDate(value)}
-        </Text>
-      </Pressable>
+  const onCancel = React.useCallback(() => {
+    setOpen(false)
+  }, [])
+
+  return (
+    <>
+      <DateFieldButton
+        label={label}
+        value={value}
+        onPress={onPress}
+        isInvalid={isInvalid}
+        accessibilityHint={accessibilityHint}
+      />
 
       {open && (
-        <DateTimePicker
+        <DatePicker
+          modal
+          open
+          timeZoneOffsetInMinutes={0}
+          theme={t.name === 'light' ? 'light' : 'dark'}
+          date={new Date(value)}
+          onConfirm={onChangeInternal}
+          onCancel={onCancel}
+          mode="date"
+          testID={`${testID}-datepicker`}
           aria-label={label}
           accessibilityLabel={label}
-          accessibilityHint={undefined}
-          testID={`${testID}-datepicker`}
-          mode="date"
-          timeZoneName={'Etc/UTC'}
-          display="spinner"
-          // @ts-ignore applies in iOS only -prf
-          themeVariant={t.name === 'dark' ? 'dark' : 'light'}
-          value={new Date(value)}
-          onChange={onChangeInternal}
+          accessibilityHint={accessibilityHint}
         />
       )}
-    </View>
+    </>
   )
 }
diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx
new file mode 100644
index 000000000..1f54bdc8b
--- /dev/null
+++ b/src/components/forms/DateField/index.shared.tsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+
+import {android, atoms as a, useTheme, web} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+import {Text} from '#/components/Typography'
+import {localizeDate} from './utils'
+
+// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
+// iOS: open a dialog with an inline date picker
+// Android: open the date picker modal
+
+export function DateFieldButton({
+  label,
+  value,
+  onPress,
+  isInvalid,
+  accessibilityHint,
+}: {
+  label: string
+  value: string
+  onPress: () => void
+  isInvalid?: boolean
+  accessibilityHint?: string
+}) {
+  const t = useTheme()
+
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
+    TextField.useSharedInputStyles()
+
+  return (
+    <View
+      style={[a.relative, a.w_full]}
+      {...web({
+        onMouseOver: onHoverIn,
+        onMouseOut: onHoverOut,
+      })}>
+      <Pressable
+        aria-label={label}
+        accessibilityLabel={label}
+        accessibilityHint={accessibilityHint}
+        onPress={onPress}
+        onPressIn={onPressIn}
+        onPressOut={onPressOut}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={[
+          {
+            paddingTop: 12,
+            paddingBottom: 12,
+            paddingLeft: 14,
+            paddingRight: 14,
+            borderColor: 'transparent',
+            borderWidth: 2,
+          },
+          android({
+            minHeight: 57.5,
+          }),
+          a.flex_row,
+          a.flex_1,
+          a.w_full,
+          a.rounded_sm,
+          t.atoms.bg_contrast_25,
+          a.align_center,
+          hovered ? chromeHover : {},
+          focused || pressed ? chromeFocus : {},
+          isInvalid || isInvalid ? chromeError : {},
+          (isInvalid || isInvalid) && (hovered || focused)
+            ? chromeErrorHover
+            : {},
+        ]}>
+        <TextField.Icon icon={CalendarDays} />
+        <Text
+          style={[
+            a.text_md,
+            a.pl_xs,
+            t.atoms.text,
+            {lineHeight: a.text_md.fontSize * 1.1875},
+          ]}>
+          {localizeDate(value)}
+        </Text>
+      </Pressable>
+    </View>
+  )
+}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index c359a9d46..5662bb594 100644
--- a/src/components/forms/DateField/index.tsx
+++ b/src/components/forms/DateField/index.tsx
@@ -1,13 +1,16 @@
 import React from 'react'
 import {View} from 'react-native'
-import DateTimePicker, {
-  DateTimePickerEvent,
-} from '@react-native-community/datetimepicker'
+import DatePicker from 'react-native-date-picker'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms} from '#/alf'
-import * as TextField from '#/components/forms/TextField'
-import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
 import {DateFieldProps} from '#/components/forms/DateField/types'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import * as TextField from '#/components/forms/TextField'
+import {DateFieldButton} from './index.shared'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -24,11 +27,15 @@ export function DateField({
   onChangeDate,
   testID,
   label,
+  isInvalid,
+  accessibilityHint,
 }: DateFieldProps) {
+  const {_} = useLingui()
   const t = useTheme()
+  const control = Dialog.useDialogControl()
 
   const onChangeInternal = React.useCallback(
-    (event: DateTimePickerEvent, date: Date | undefined) => {
+    (date: Date | undefined) => {
       if (date) {
         const formatted = toSimpleDateString(date)
         onChangeDate(formatted)
@@ -38,19 +45,44 @@ export function DateField({
   )
 
   return (
-    <View style={[atoms.relative, atoms.w_full]}>
-      <DateTimePicker
-        aria-label={label}
-        accessibilityLabel={label}
-        accessibilityHint={undefined}
-        testID={`${testID}-datepicker`}
-        mode="date"
-        timeZoneName={'Etc/UTC'}
-        display="spinner"
-        themeVariant={t.name === 'dark' ? 'dark' : 'light'}
-        value={new Date(value)}
-        onChange={onChangeInternal}
+    <>
+      <DateFieldButton
+        label={label}
+        value={value}
+        onPress={control.open}
+        isInvalid={isInvalid}
+        accessibilityHint={accessibilityHint}
       />
-    </View>
+      <Dialog.Outer control={control} testID={testID}>
+        <Dialog.Handle />
+        <Dialog.Inner label={label}>
+          <View style={a.gap_lg}>
+            <View style={[a.relative, a.w_full, a.align_center]}>
+              <DatePicker
+                timeZoneOffsetInMinutes={0}
+                theme={t.name === 'light' ? 'light' : 'dark'}
+                date={new Date(value)}
+                onDateChange={onChangeInternal}
+                mode="date"
+                testID={`${testID}-datepicker`}
+                aria-label={label}
+                accessibilityLabel={label}
+                accessibilityHint={accessibilityHint}
+              />
+            </View>
+            <Button
+              label={_(msg`Done`)}
+              onPress={() => control.close()}
+              size="medium"
+              color="primary"
+              variant="solid">
+              <ButtonText>
+                <Trans>Done</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        </Dialog.Inner>
+      </Dialog.Outer>
+    </>
   )
 }
diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx
index 32f38a5d1..982d32711 100644
--- a/src/components/forms/DateField/index.web.tsx
+++ b/src/components/forms/DateField/index.web.tsx
@@ -1,11 +1,12 @@
 import React from 'react'
-import {TextInput, TextInputProps, StyleSheet} from 'react-native'
+import {StyleSheet, TextInput, TextInputProps} from 'react-native'
 // @ts-ignore
 import {unstable_createElement} from 'react-native-web'
 
-import * as TextField from '#/components/forms/TextField'
-import {toSimpleDateString} from '#/components/forms/DateField/utils'
 import {DateFieldProps} from '#/components/forms/DateField/types'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import * as TextField from '#/components/forms/TextField'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -37,6 +38,7 @@ export function DateField({
   label,
   isInvalid,
   testID,
+  accessibilityHint,
 }: DateFieldProps) {
   const handleOnChange = React.useCallback(
     (e: any) => {
@@ -52,12 +54,14 @@ export function DateField({
 
   return (
     <TextField.Root isInvalid={isInvalid}>
+      <TextField.Icon icon={CalendarDays} />
       <Input
         value={value}
         label={label}
         onChange={handleOnChange}
         onChangeText={() => {}}
         testID={testID}
+        accessibilityHint={accessibilityHint}
       />
     </TextField.Root>
   )
diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts
index 129f5672d..5400cf903 100644
--- a/src/components/forms/DateField/types.ts
+++ b/src/components/forms/DateField/types.ts
@@ -4,4 +4,5 @@ export type DateFieldProps = {
   label: string
   isInvalid?: boolean
   testID?: string
+  accessibilityHint?: string
 }
diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx
new file mode 100644
index 000000000..8ab6e3f35
--- /dev/null
+++ b/src/components/forms/FormError.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Text} from '#/components/Typography'
+
+export function FormError({error}: {error?: string}) {
+  const t = useTheme()
+
+  if (!error) return null
+
+  return (
+    <View
+      style={[
+        {backgroundColor: t.palette.negative_400},
+        a.flex_row,
+        a.rounded_sm,
+        a.p_md,
+        a.gap_sm,
+      ]}>
+      <Warning fill={t.palette.white} size="md" />
+      <View style={[a.flex_1]}>
+        <Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}>
+          {error}
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx
new file mode 100644
index 000000000..f2d11062a
--- /dev/null
+++ b/src/components/forms/HostingProvider.tsx
@@ -0,0 +1,95 @@
+import React from 'react'
+import {Keyboard, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {isAndroid} from '#/platform/detection'
+import {ServerInputDialog} from '#/view/com/auth/server-input'
+import {atoms as a, useTheme} from '#/alf'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
+import {Button} from '../Button'
+import {useDialogControl} from '../Dialog'
+import {Text} from '../Typography'
+
+export function HostingProvider({
+  serviceUrl,
+  onSelectServiceUrl,
+  onOpenDialog,
+}: {
+  serviceUrl: string
+  onSelectServiceUrl: (provider: string) => void
+  onOpenDialog?: () => void
+}) {
+  const serverInputControl = useDialogControl()
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const onPressSelectService = React.useCallback(() => {
+    Keyboard.dismiss()
+    serverInputControl.open()
+    if (onOpenDialog) {
+      onOpenDialog()
+    }
+  }, [onOpenDialog, serverInputControl])
+
+  return (
+    <>
+      <ServerInputDialog
+        control={serverInputControl}
+        onSelect={onSelectServiceUrl}
+      />
+      <Button
+        label={toNiceDomain(serviceUrl)}
+        accessibilityHint={_(msg`Press to change hosting provider`)}
+        variant="solid"
+        color="secondary"
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_center,
+          a.rounded_sm,
+          a.px_md,
+          a.pr_sm,
+          a.gap_xs,
+          {paddingVertical: isAndroid ? 14 : 9},
+        ]}
+        onPress={onPressSelectService}>
+        {({hovered, pressed}) => {
+          const interacted = hovered || pressed
+          return (
+            <>
+              <View style={a.pr_xs}>
+                <Globe
+                  size="md"
+                  fill={
+                    interacted ? t.palette.contrast_800 : t.palette.contrast_500
+                  }
+                />
+              </View>
+              <Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
+              <View
+                style={[
+                  a.rounded_sm,
+                  interacted
+                    ? t.atoms.bg_contrast_300
+                    : t.atoms.bg_contrast_100,
+                  {marginLeft: 'auto', padding: 6},
+                ]}>
+                <Pencil
+                  size="sm"
+                  style={{
+                    color: interacted
+                      ? t.palette.contrast_800
+                      : t.palette.contrast_500,
+                  }}
+                />
+              </View>
+            </>
+          )
+        }}
+      </Button>
+    </>
+  )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index ebf2e4750..0bdeca645 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -1,19 +1,20 @@
 import React from 'react'
 import {
-  View,
+  AccessibilityProps,
+  StyleSheet,
   TextInput,
   TextInputProps,
   TextStyle,
+  View,
   ViewStyle,
-  StyleSheet,
-  AccessibilityProps,
 } from 'react-native'
 
+import {mergeRefs} from '#/lib/merge-refs'
 import {HITSLOP_20} from 'lib/constants'
-import {useTheme, atoms as a, web, tokens, android} from '#/alf'
-import {Text} from '#/components/Typography'
+import {android, atoms as a, useTheme, web} from '#/alf'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {Props as SVGIconProps} from '#/components/icons/common'
+import {Text} from '#/components/Typography'
 
 const Context = React.createContext<{
   inputRef: React.RefObject<TextInput> | null
@@ -72,7 +73,7 @@ export function Root({children, isInvalid = false}: RootProps) {
   return (
     <Context.Provider value={context}>
       <View
-        style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
+        style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
         {...web({
           onClick: () => inputRef.current?.focus(),
           onMouseOver: onHoverIn,
@@ -110,7 +111,7 @@ export function useSharedInputStyles() {
       {
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
-        borderColor: tokens.color.red_500,
+        borderColor: t.palette.negative_500,
       },
     ]
 
@@ -125,9 +126,10 @@ export function useSharedInputStyles() {
 
 export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
   label: string
-  value: string
-  onChangeText: (value: string) => void
+  value?: string
+  onChangeText?: (value: string) => void
   isInvalid?: boolean
+  inputRef?: React.RefObject<TextInput>
 }
 
 export function createInput(Component: typeof TextInput) {
@@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
     value,
     onChangeText,
     isInvalid,
+    inputRef,
     ...rest
   }: InputProps) {
     const t = useTheme()
@@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
       )
     }
 
+    const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
+
     return (
       <>
         <Component
           accessibilityHint={undefined}
           {...rest}
           accessibilityLabel={label}
-          ref={ctx.inputRef}
+          ref={refs}
           value={value}
           onChangeText={onChangeText}
           onFocus={ctx.onFocus}
           onBlur={ctx.onBlur}
           placeholder={placeholder || label}
           placeholderTextColor={t.palette.contrast_500}
+          keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
           hitSlop={HITSLOP_20}
           style={[
             a.relative,
@@ -271,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
       <Comp
         size="md"
         style={[
-          {color: t.palette.contrast_500, pointerEvents: 'none'},
+          {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
           ctx.hovered ? hover : {},
           ctx.focused ? focus : {},
           ctx.isInvalid && ctx.hovered ? errorHover : {},
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 9369423f2..7a4b5ac95 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -2,9 +2,17 @@ import React from 'react'
 import {Pressable, View, ViewStyle} from 'react-native'
 
 import {HITSLOP_10} from 'lib/constants'
-import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
+import {
+  useTheme,
+  atoms as a,
+  native,
+  flatten,
+  ViewStyleProp,
+  TextStyleProp,
+} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
 
 export type ItemState = {
   name: string
@@ -219,20 +227,17 @@ export function Item({
         onPressOut={onPressOut}
         onFocus={onFocus}
         onBlur={onBlur}
-        style={[
-          a.flex_row,
-          a.align_center,
-          a.gap_sm,
-          focused ? web({outline: 'none'}) : {},
-          flatten(style),
-        ]}>
+        style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
         {typeof children === 'function' ? children(state) : children}
       </Pressable>
     </ItemContext.Provider>
   )
 }
 
-export function Label({children}: React.PropsWithChildren<{}>) {
+export function Label({
+  children,
+  style,
+}: React.PropsWithChildren<TextStyleProp>) {
   const t = useTheme()
   const {disabled} = useItemContext()
   return (
@@ -241,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
         a.font_bold,
         {
           userSelect: 'none',
-          color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
+          color: disabled
+            ? t.atoms.text_contrast_low.color
+            : t.atoms.text_contrast_high.color,
         },
         native({
           paddingTop: 3,
         }),
+        flatten(style),
       ]}>
       {children}
     </Text>
@@ -256,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
 export function createSharedToggleStyles({
   theme: t,
   hovered,
-  focused,
   selected,
   disabled,
   isInvalid,
@@ -279,7 +286,7 @@ export function createSharedToggleStyles({
       borderColor: t.palette.primary_500,
     })
 
-    if (hovered || focused) {
+    if (hovered) {
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
@@ -288,7 +295,7 @@ export function createSharedToggleStyles({
       })
     }
   } else {
-    if (hovered || focused) {
+    if (hovered) {
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
@@ -300,16 +307,16 @@ export function createSharedToggleStyles({
   if (isInvalid) {
     base.push({
       backgroundColor:
-        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
       borderColor:
         t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
     })
 
-    if (hovered || focused) {
+    if (hovered) {
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
-        borderColor: t.palette.negative_500,
+        borderColor: t.palette.negative_600,
       })
     }
   }
@@ -331,15 +338,14 @@ export function createSharedToggleStyles({
 export function Checkbox() {
   const t = useTheme()
   const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
-  const {baseStyles, baseHoverStyles, indicatorStyles} =
-    createSharedToggleStyles({
-      theme: t,
-      hovered,
-      focused,
-      selected,
-      disabled,
-      isInvalid,
-    })
+  const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
+    theme: t,
+    hovered,
+    focused,
+    selected,
+    disabled,
+    isInvalid,
+  })
   return (
     <View
       style={[
@@ -353,23 +359,9 @@ export function Checkbox() {
           width: 20,
         },
         baseStyles,
-        hovered || focused ? baseHoverStyles : {},
+        hovered ? baseHoverStyles : {},
       ]}>
-      {selected ? (
-        <View
-          style={[
-            a.absolute,
-            a.rounded_2xs,
-            {height: 12, width: 12},
-            selected
-              ? {
-                  backgroundColor: t.palette.primary_500,
-                }
-              : {},
-            indicatorStyles,
-          ]}
-        />
-      ) : null}
+      {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
     </View>
   )
 }
@@ -399,7 +391,7 @@ export function Switch() {
           width: 30,
         },
         baseStyles,
-        hovered || focused ? baseHoverStyles : {},
+        hovered ? baseHoverStyles : {},
       ]}>
       <View
         style={[
@@ -451,7 +443,7 @@ export function Radio() {
           width: 20,
         },
         baseStyles,
-        hovered || focused ? baseHoverStyles : {},
+        hovered ? baseHoverStyles : {},
       ]}>
       {selected ? (
         <View
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 7e1bd70b9..9cdaaaa9d 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle'
 
 export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
   AccessibilityProps &
-  React.PropsWithChildren<{testID?: string}>
+  React.PropsWithChildren<{
+    testID?: string
+  }>
 
 export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
   multiple?: boolean
@@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
         native({
           paddingBottom: 10,
         }),
-        a.px_sm,
+        a.px_md,
         t.atoms.bg,
         t.atoms.border_contrast_low,
         baseStyles,
         activeStyles,
-        (state.hovered || state.focused || state.pressed) && hoverStyles,
+        (state.hovered || state.pressed) && hoverStyles,
       ]}>
       {typeof children === 'string' ? (
         <Text
diff --git a/src/components/hooks/useDelayedLoading.ts b/src/components/hooks/useDelayedLoading.ts
new file mode 100644
index 000000000..6c7e2ede0
--- /dev/null
+++ b/src/components/hooks/useDelayedLoading.ts
@@ -0,0 +1,15 @@
+import React from 'react'
+
+export function useDelayedLoading(delay: number, initialState: boolean = true) {
+  const [isLoading, setIsLoading] = React.useState(initialState)
+
+  React.useEffect(() => {
+    let timeout: NodeJS.Timeout
+    // on initial load, show a loading spinner for a hot sec to prevent flash
+    if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
+
+    return () => timeout && clearTimeout(timeout)
+  }, [isLoading, delay])
+
+  return isLoading
+}
diff --git a/src/components/hooks/useOnKeyboard.ts b/src/components/hooks/useOnKeyboard.ts
new file mode 100644
index 000000000..5de681a42
--- /dev/null
+++ b/src/components/hooks/useOnKeyboard.ts
@@ -0,0 +1,12 @@
+import React from 'react'
+import {Keyboard} from 'react-native'
+
+export function useOnKeyboardDidShow(cb: () => unknown) {
+  React.useEffect(() => {
+    const subscription = Keyboard.addListener('keyboardDidShow', cb)
+
+    return () => {
+      subscription.remove()
+    }
+  }, [cb])
+}
diff --git a/src/components/icons/ArrowTriangle.tsx b/src/components/icons/ArrowTriangle.tsx
new file mode 100644
index 000000000..b27b719ae
--- /dev/null
+++ b/src/components/icons/ArrowTriangle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({
+  path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z',
+})
diff --git a/src/components/icons/Bars.tsx b/src/components/icons/Bars.tsx
new file mode 100644
index 000000000..7b1415a4b
--- /dev/null
+++ b/src/components/icons/Bars.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/Bubble.tsx b/src/components/icons/Bubble.tsx
new file mode 100644
index 000000000..d4e08f6d2
--- /dev/null
+++ b/src/components/icons/Bubble.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
+})
diff --git a/src/components/icons/Calendar.tsx b/src/components/icons/Calendar.tsx
new file mode 100644
index 000000000..b3816f28b
--- /dev/null
+++ b/src/components/icons/Calendar.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z',
+})
diff --git a/src/components/icons/Camera.tsx b/src/components/icons/Camera.tsx
new file mode 100644
index 000000000..ced8e7442
--- /dev/null
+++ b/src/components/icons/Camera.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
+})
+
+export const Camera_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
+})
diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx
index 24316c784..fe9883baf 100644
--- a/src/components/icons/Check.tsx
+++ b/src/components/icons/Check.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
 export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
 })
+
+export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
+})
diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx
index b1a9deea0..a04e6e009 100644
--- a/src/components/icons/Chevron.tsx
+++ b/src/components/icons/Chevron.tsx
@@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
 export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
 })
+
+export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z',
+})
+
+export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/CircleBanSign.tsx b/src/components/icons/CircleBanSign.tsx
new file mode 100644
index 000000000..543985d43
--- /dev/null
+++ b/src/components/icons/CircleBanSign.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z',
+})
diff --git a/src/components/icons/Clipboard.tsx b/src/components/icons/Clipboard.tsx
new file mode 100644
index 000000000..0135992b4
--- /dev/null
+++ b/src/components/icons/Clipboard.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
+})
diff --git a/src/components/icons/DotGrid.tsx b/src/components/icons/DotGrid.tsx
new file mode 100644
index 000000000..c50d7a440
--- /dev/null
+++ b/src/components/icons/DotGrid.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
+})
diff --git a/src/components/icons/Envelope.tsx b/src/components/icons/Envelope.tsx
new file mode 100644
index 000000000..8e40346cd
--- /dev/null
+++ b/src/components/icons/Envelope.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
+})
diff --git a/src/components/icons/Filter.tsx b/src/components/icons/Filter.tsx
new file mode 100644
index 000000000..02ac1c71b
--- /dev/null
+++ b/src/components/icons/Filter.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Filter_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z',
+})
diff --git a/src/components/icons/Flag.tsx b/src/components/icons/Flag.tsx
new file mode 100644
index 000000000..d986db75a
--- /dev/null
+++ b/src/components/icons/Flag.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Flag_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z',
+})
diff --git a/src/components/icons/Gear.tsx b/src/components/icons/Gear.tsx
new file mode 100644
index 000000000..980b7413b
--- /dev/null
+++ b/src/components/icons/Gear.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
+})
diff --git a/src/components/icons/Group.tsx b/src/components/icons/Group.tsx
new file mode 100644
index 000000000..9e5ab8893
--- /dev/null
+++ b/src/components/icons/Group.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
+})
diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx
deleted file mode 100644
index 2bb16ba87..000000000
--- a/src/components/icons/Group3.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import {createSinglePathSVG} from './TEMPLATE'
-
-export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
-  path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
-})
diff --git a/src/components/icons/Heart2.tsx b/src/components/icons/Heart2.tsx
new file mode 100644
index 000000000..07f5a1d2c
--- /dev/null
+++ b/src/components/icons/Heart2.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z',
+})
+
+export const Heart2_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z',
+})
diff --git a/src/components/icons/Lock.tsx b/src/components/icons/Lock.tsx
new file mode 100644
index 000000000..87830b379
--- /dev/null
+++ b/src/components/icons/Lock.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/MagnifyingGlass2.tsx b/src/components/icons/MagnifyingGlass2.tsx
new file mode 100644
index 000000000..3ca403400
--- /dev/null
+++ b/src/components/icons/MagnifyingGlass2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
+})
diff --git a/src/components/icons/Mute.tsx b/src/components/icons/Mute.tsx
new file mode 100644
index 000000000..006570787
--- /dev/null
+++ b/src/components/icons/Mute.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
+})
diff --git a/src/components/icons/PageText.tsx b/src/components/icons/PageText.tsx
new file mode 100644
index 000000000..25fbde339
--- /dev/null
+++ b/src/components/icons/PageText.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
+})
diff --git a/src/components/icons/Pencil.tsx b/src/components/icons/Pencil.tsx
new file mode 100644
index 000000000..854d51a3b
--- /dev/null
+++ b/src/components/icons/Pencil.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/PeopleRemove2.tsx b/src/components/icons/PeopleRemove2.tsx
new file mode 100644
index 000000000..3d16ed968
--- /dev/null
+++ b/src/components/icons/PeopleRemove2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PeopleRemove2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z',
+})
diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx
new file mode 100644
index 000000000..6d09148c9
--- /dev/null
+++ b/src/components/icons/Person.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
+})
diff --git a/src/components/icons/PersonCheck.tsx b/src/components/icons/PersonCheck.tsx
new file mode 100644
index 000000000..097271d89
--- /dev/null
+++ b/src/components/icons/PersonCheck.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
+})
diff --git a/src/components/icons/PersonX.tsx b/src/components/icons/PersonX.tsx
new file mode 100644
index 000000000..a015e1376
--- /dev/null
+++ b/src/components/icons/PersonX.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/RaisingHand.tsx b/src/components/icons/RaisingHand.tsx
new file mode 100644
index 000000000..cd023cb7e
--- /dev/null
+++ b/src/components/icons/RaisingHand.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z',
+})
diff --git a/src/components/icons/Shield.tsx b/src/components/icons/Shield.tsx
new file mode 100644
index 000000000..5038d5c24
--- /dev/null
+++ b/src/components/icons/Shield.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
+})
diff --git a/src/components/icons/Speaker.tsx b/src/components/icons/Speaker.tsx
new file mode 100644
index 000000000..365d5e114
--- /dev/null
+++ b/src/components/icons/Speaker.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SpeakerVolumeFull_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/SquareArrowTopRight.tsx b/src/components/icons/SquareArrowTopRight.tsx
new file mode 100644
index 000000000..7701e26e5
--- /dev/null
+++ b/src/components/icons/SquareArrowTopRight.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z',
+})
diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx
new file mode 100644
index 000000000..425599cbc
--- /dev/null
+++ b/src/components/icons/SquareBehindSquare4.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
+})
diff --git a/src/components/icons/StreamingLive.tsx b/src/components/icons/StreamingLive.tsx
new file mode 100644
index 000000000..8ab5099da
--- /dev/null
+++ b/src/components/icons/StreamingLive.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const StreamingLive_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
+})
diff --git a/src/components/icons/Ticket.tsx b/src/components/icons/Ticket.tsx
new file mode 100644
index 000000000..1a8059c2a
--- /dev/null
+++ b/src/components/icons/Ticket.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
+})
diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx
new file mode 100644
index 000000000..678ac3fcb
--- /dev/null
+++ b/src/components/icons/Times.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/Trash.tsx b/src/components/icons/Trash.tsx
new file mode 100644
index 000000000..d09a3311f
--- /dev/null
+++ b/src/components/icons/Trash.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Warning.tsx b/src/components/icons/Warning.tsx
new file mode 100644
index 000000000..fc84b2894
--- /dev/null
+++ b/src/components/icons/Warning.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Warning_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z',
+})
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
new file mode 100644
index 000000000..1e8f36d31
--- /dev/null
+++ b/src/components/moderation/ContentHider.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {isJustAMute} from '#/lib/moderation'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+
+import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ContentHider({
+  testID,
+  modui,
+  ignoreMute,
+  style,
+  childContainerStyle,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  modui: ModerationUI | undefined
+  ignoreMute?: boolean
+  style?: StyleProp<ViewStyle>
+  childContainerStyle?: StyleProp<ViewStyle>
+}>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const [override, setOverride] = React.useState(false)
+  const control = useModerationDetailsDialogControl()
+
+  const blur = modui?.blurs[0]
+  const desc = useModerationCauseDescription(blur)
+
+  if (!blur || (ignoreMute && isJustAMute(modui))) {
+    return (
+      <View testID={testID} style={[styles.outer, style]}>
+        {children}
+      </View>
+    )
+  }
+
+  return (
+    <View testID={testID} style={[a.overflow_hidden, style]}>
+      <ModerationDetailsDialog control={control} modcause={blur} />
+
+      <Button
+        onPress={() => {
+          if (!modui.noOverride) {
+            setOverride(v => !v)
+          } else {
+            control.open()
+          }
+        }}
+        label={desc.name}
+        accessibilityHint={
+          modui.noOverride
+            ? _(msg`Learn more about the moderation applied to this content.`)
+            : override
+            ? _(msg`Hide the content`)
+            : _(msg`Show the content`)
+        }>
+        {state => (
+          <View
+            style={[
+              a.flex_row,
+              a.w_full,
+              a.justify_start,
+              a.align_center,
+              a.py_md,
+              a.px_lg,
+              a.gap_xs,
+              a.rounded_sm,
+              t.atoms.bg_contrast_25,
+              gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl],
+              (state.hovered || state.pressed) && t.atoms.bg_contrast_50,
+            ]}>
+            <desc.icon
+              size="md"
+              fill={t.atoms.text_contrast_medium.color}
+              style={{marginLeft: -2}}
+            />
+            <Text
+              style={[
+                a.flex_1,
+                a.text_left,
+                a.font_bold,
+                a.leading_snug,
+                gtMobile && [a.font_semibold],
+                t.atoms.text_contrast_medium,
+                web({
+                  marginBottom: 1,
+                }),
+              ]}>
+              {desc.name}
+            </Text>
+            {!modui.noOverride && (
+              <Text
+                style={[
+                  a.font_bold,
+                  a.leading_snug,
+                  gtMobile && [a.font_semibold],
+                  t.atoms.text_contrast_high,
+                  web({
+                    marginBottom: 1,
+                  }),
+                ]}>
+                {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
+              </Text>
+            )}
+          </View>
+        )}
+      </Button>
+
+      {desc.source && blur.type === 'label' && !override && (
+        <Button
+          onPress={() => {
+            control.open()
+          }}
+          label={_(
+            msg`Learn more about the moderation applied to this content.`,
+          )}
+          style={[a.pt_sm]}>
+          {state => (
+            <Text
+              style={[
+                a.flex_1,
+                a.text_sm,
+                a.font_normal,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+                a.text_left,
+              ]}>
+              {desc.sourceType === 'user' ? (
+                <Trans>Labeled by the author.</Trans>
+              ) : (
+                <Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans>
+              )}{' '}
+              <Text
+                style={[
+                  {color: t.palette.primary_500},
+                  a.text_sm,
+                  state.hovered && [web({textDecoration: 'underline'})],
+                ]}>
+                <Trans>Learn more.</Trans>
+              </Text>
+            </Text>
+          )}
+        </Button>
+      )}
+
+      {override && <View style={childContainerStyle}>{children}</View>}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    overflow: 'hidden',
+  },
+  cover: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    borderRadius: 8,
+    marginTop: 4,
+    paddingVertical: 14,
+    paddingLeft: 14,
+    paddingRight: 18,
+  },
+  showBtn: {
+    marginLeft: 'auto',
+    alignSelf: 'center',
+  },
+})
diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx
new file mode 100644
index 000000000..028bd1a39
--- /dev/null
+++ b/src/components/moderation/LabelPreference.tsx
@@ -0,0 +1,293 @@
+import React from 'react'
+import {View} from 'react-native'
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
+import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
+import {
+  usePreferencesQuery,
+  usePreferencesSetContentLabelMutation,
+} from '#/state/queries/preferences'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
+
+export function Outer({children}: React.PropsWithChildren<{}>) {
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.gap_md,
+        a.px_lg,
+        a.py_lg,
+        a.justify_between,
+        a.flex_wrap,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function Content({
+  children,
+  name,
+  description,
+}: React.PropsWithChildren<{
+  name: string
+  description: string
+}>) {
+  const t = useTheme()
+  const {gtPhone} = useBreakpoints()
+
+  return (
+    <View style={[a.gap_xs, a.flex_1]}>
+      <Text style={[a.font_bold, gtPhone ? a.text_sm : a.text_md]}>{name}</Text>
+      <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
+        {description}
+      </Text>
+
+      {children}
+    </View>
+  )
+}
+
+export function Buttons({
+  name,
+  values,
+  onChange,
+  ignoreLabel,
+  warnLabel,
+  hideLabel,
+}: {
+  name: string
+  values: ToggleButton.GroupProps['values']
+  onChange: ToggleButton.GroupProps['onChange']
+  ignoreLabel?: string
+  warnLabel?: string
+  hideLabel?: string
+}) {
+  const {_} = useLingui()
+  const {gtPhone} = useBreakpoints()
+
+  return (
+    <View style={[{minHeight: 35}, gtPhone ? undefined : a.w_full]}>
+      <ToggleButton.Group
+        label={_(
+          msg`Configure content filtering setting for category: ${name}`,
+        )}
+        values={values}
+        onChange={onChange}>
+        {ignoreLabel && (
+          <ToggleButton.Button name="ignore" label={ignoreLabel}>
+            {ignoreLabel}
+          </ToggleButton.Button>
+        )}
+        {warnLabel && (
+          <ToggleButton.Button name="warn" label={warnLabel}>
+            {warnLabel}
+          </ToggleButton.Button>
+        )}
+        {hideLabel && (
+          <ToggleButton.Button name="hide" label={hideLabel}>
+            {hideLabel}
+          </ToggleButton.Button>
+        )}
+      </ToggleButton.Group>
+    </View>
+  )
+}
+
+/**
+ * For use on the global Moderation screen to set prefs for a "global" label,
+ * not scoped to a single labeler.
+ */
+export function GlobalLabelPreference({
+  labelDefinition,
+  disabled,
+}: {
+  labelDefinition: InterpretedLabelValueDefinition
+  disabled?: boolean
+}) {
+  const {_} = useLingui()
+
+  const {identifier} = labelDefinition
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const savedPref = preferences?.moderationPrefs.labels[identifier]
+  const pref = variables?.visibility ?? savedPref ?? 'warn'
+
+  const allLabelStrings = useGlobalLabelStrings()
+  const labelStrings =
+    labelDefinition.identifier in allLabelStrings
+      ? allLabelStrings[labelDefinition.identifier]
+      : {
+          name: labelDefinition.identifier,
+          description: `Labeled "${labelDefinition.identifier}"`,
+        }
+
+  const labelOptions = {
+    hide: _(msg`Hide`),
+    warn: _(msg`Warn`),
+    ignore: _(msg`Show`),
+  }
+
+  return (
+    <Outer>
+      <Content
+        name={labelStrings.name}
+        description={labelStrings.description}
+      />
+      {!disabled && (
+        <Buttons
+          name={labelStrings.name.toLowerCase()}
+          values={[pref]}
+          onChange={values => {
+            mutate({
+              label: identifier,
+              visibility: values[0] as LabelPreference,
+              labelerDid: undefined,
+            })
+          }}
+          ignoreLabel={labelOptions.ignore}
+          warnLabel={labelOptions.warn}
+          hideLabel={labelOptions.hide}
+        />
+      )}
+    </Outer>
+  )
+}
+
+/**
+ * For use on individual labeler pages
+ */
+export function LabelerLabelPreference({
+  labelDefinition,
+  disabled,
+  labelerDid,
+}: {
+  labelDefinition: InterpretedLabelValueDefinition
+  disabled?: boolean
+  labelerDid?: string
+}) {
+  const {i18n} = useLingui()
+  const t = useTheme()
+  const {gtPhone} = useBreakpoints()
+
+  const isGlobalLabel = !labelDefinition.definedBy
+  const {identifier} = labelDefinition
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const savedPref =
+    labelerDid && !isGlobalLabel
+      ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
+          ?.labels[identifier]
+      : preferences?.moderationPrefs.labels[identifier]
+  const pref =
+    variables?.visibility ??
+    savedPref ??
+    labelDefinition.defaultSetting ??
+    'warn'
+
+  // does the 'warn' setting make sense for this label?
+  const canWarn = !(
+    labelDefinition.blurs === 'none' && labelDefinition.severity === 'none'
+  )
+  // is this label adult only?
+  const adultOnly = labelDefinition.flags.includes('adult')
+  // is this label disabled because it's adult only?
+  const adultDisabled =
+    adultOnly && !preferences?.moderationPrefs.adultContentEnabled
+  // are there any reasons we cant configure this label here?
+  const cantConfigure = isGlobalLabel || adultDisabled
+  const showConfig = !disabled && (gtPhone || !cantConfigure)
+
+  // adjust the pref based on whether warn is available
+  let prefAdjusted = pref
+  if (adultDisabled) {
+    prefAdjusted = 'hide'
+  } else if (!canWarn && pref === 'warn') {
+    prefAdjusted = 'ignore'
+  }
+
+  // grab localized descriptions of the label and its settings
+  const currentPrefLabel = useLabelBehaviorDescription(
+    labelDefinition,
+    prefAdjusted,
+  )
+  const hideLabel = useLabelBehaviorDescription(labelDefinition, 'hide')
+  const warnLabel = useLabelBehaviorDescription(labelDefinition, 'warn')
+  const ignoreLabel = useLabelBehaviorDescription(labelDefinition, 'ignore')
+  const globalLabelStrings = useGlobalLabelStrings()
+  const labelStrings = getLabelStrings(
+    i18n.locale,
+    globalLabelStrings,
+    labelDefinition,
+  )
+
+  return (
+    <Outer>
+      <Content name={labelStrings.name} description={labelStrings.description}>
+        {cantConfigure && (
+          <View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
+            <CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
+
+            <Text
+              style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
+              {adultDisabled ? (
+                <Trans>Adult content is disabled.</Trans>
+              ) : isGlobalLabel ? (
+                <Trans>
+                  Configured in{' '}
+                  <InlineLink to="/moderation" style={a.text_sm}>
+                    moderation settings
+                  </InlineLink>
+                  .
+                </Trans>
+              ) : null}
+            </Text>
+          </View>
+        )}
+      </Content>
+
+      {showConfig && (
+        <View style={[gtPhone ? undefined : a.w_full]}>
+          {cantConfigure ? (
+            <View
+              style={[
+                {minHeight: 35},
+                a.px_md,
+                a.py_md,
+                a.rounded_sm,
+                a.border,
+                t.atoms.border_contrast_low,
+              ]}>
+              <Text style={[a.font_bold, t.atoms.text_contrast_low]}>
+                {currentPrefLabel}
+              </Text>
+            </View>
+          ) : (
+            <Buttons
+              name={labelStrings.name.toLowerCase()}
+              values={[pref]}
+              onChange={values => {
+                mutate({
+                  label: identifier,
+                  visibility: values[0] as LabelPreference,
+                  labelerDid,
+                })
+              }}
+              ignoreLabel={ignoreLabel}
+              warnLabel={canWarn ? warnLabel : undefined}
+              hideLabel={hideLabel}
+            />
+          )}
+        </View>
+      )}
+    </Outer>
+  )
+}
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
new file mode 100644
index 000000000..099769fa7
--- /dev/null
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -0,0 +1,83 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {
+  LabelsOnMeDialog,
+  useLabelsOnMeDialogControl,
+} from '#/components/moderation/LabelsOnMeDialog'
+
+export function LabelsOnMe({
+  details,
+  labels,
+  size,
+  style,
+}: {
+  details: {did: string} | {uri: string; cid: string}
+  labels: ComAtprotoLabelDefs.Label[] | undefined
+  size?: ButtonSize
+  style?: StyleProp<ViewStyle>
+}) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const isAccount = 'did' in details
+  const control = useLabelsOnMeDialogControl()
+
+  if (!labels || !currentAccount) {
+    return null
+  }
+  labels = labels.filter(
+    l => !l.val.startsWith('!') && l.src !== currentAccount.did,
+  )
+  if (!labels.length) {
+    return null
+  }
+
+  const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
+  return (
+    <View style={[a.flex_row, style]}>
+      <LabelsOnMeDialog control={control} subject={details} labels={labels} />
+
+      <Button
+        variant="solid"
+        color="secondary"
+        size={size || 'small'}
+        label={_(msg`View information about these labels`)}
+        onPress={() => {
+          control.open()
+        }}>
+        <ButtonIcon position="left" icon={CircleInfo} />
+        <ButtonText style={[a.leading_snug]}>
+          {labels.length}{' '}
+          {labels.length === 1 ? (
+            <Trans>label has been placed on this {labelTarget}</Trans>
+          ) : (
+            <Trans>labels have been placed on this {labelTarget}</Trans>
+          )}
+        </ButtonText>
+      </Button>
+    </View>
+  )
+}
+
+export function LabelsOnMyPost({
+  post,
+  style,
+}: {
+  post: AppBskyFeedDefs.PostView
+  style?: StyleProp<ViewStyle>
+}) {
+  const {currentAccount} = useSession()
+  if (post.author.did !== currentAccount?.did) {
+    return null
+  }
+  return (
+    <LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
+  )
+}
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
new file mode 100644
index 000000000..6eddbc7ce
--- /dev/null
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -0,0 +1,262 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
+
+import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {getAgent} from '#/state/session'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonText} from '#/components/Button'
+import {InlineLink} from '#/components/Link'
+import * as Toast from '#/view/com/util/Toast'
+import {Divider} from '../Divider'
+
+export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
+
+type Subject =
+  | {
+      uri: string
+      cid: string
+    }
+  | {
+      did: string
+    }
+
+export interface LabelsOnMeDialogProps {
+  control: Dialog.DialogOuterProps['control']
+  subject: Subject
+  labels: ComAtprotoLabelDefs.Label[]
+}
+
+export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
+  const {_} = useLingui()
+  const [appealingLabel, setAppealingLabel] = React.useState<
+    ComAtprotoLabelDefs.Label | undefined
+  >(undefined)
+  const {subject, labels} = props
+  const isAccount = 'did' in subject
+
+  return (
+    <Dialog.ScrollableInner
+      label={
+        isAccount
+          ? _(msg`The following labels were applied to your account.`)
+          : _(msg`The following labels were applied to your content.`)
+      }>
+      {appealingLabel ? (
+        <AppealForm
+          label={appealingLabel}
+          subject={subject}
+          control={props.control}
+          onPressBack={() => setAppealingLabel(undefined)}
+        />
+      ) : (
+        <>
+          <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
+            {isAccount ? (
+              <Trans>Labels on your account</Trans>
+            ) : (
+              <Trans>Labels on your content</Trans>
+            )}
+          </Text>
+          <Text style={[a.text_md, a.leading_snug]}>
+            <Trans>
+              You may appeal these labels if you feel they were placed in error.
+            </Trans>
+          </Text>
+
+          <View style={[a.py_lg, a.gap_md]}>
+            {labels.map(label => (
+              <Label
+                key={`${label.val}-${label.src}`}
+                label={label}
+                control={props.control}
+                onPressAppeal={label => setAppealingLabel(label)}
+              />
+            ))}
+          </View>
+        </>
+      )}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <LabelsOnMeDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function Label({
+  label,
+  control,
+  onPressAppeal,
+}: {
+  label: ComAtprotoLabelDefs.Label
+  control: Dialog.DialogOuterProps['control']
+  onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {labeler, strings} = useLabelInfo(label)
+  return (
+    <View
+      style={[
+        a.border,
+        t.atoms.border_contrast_low,
+        a.rounded_sm,
+        a.overflow_hidden,
+      ]}>
+      <View style={[a.p_md, a.gap_sm, a.flex_row]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <Text style={[a.font_bold, a.text_md]}>{strings.name}</Text>
+          <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
+            {strings.description}
+          </Text>
+        </View>
+        <View>
+          <Button
+            variant="solid"
+            color="secondary"
+            size="small"
+            label={_(msg`Appeal`)}
+            onPress={() => onPressAppeal(label)}>
+            <ButtonText>
+              <Trans>Appeal</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </View>
+
+      <Divider />
+
+      <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Source:</Trans>{' '}
+          <InlineLink
+            to={makeProfileLink(
+              labeler ? labeler.creator : {did: label.src, handle: ''},
+            )}
+            onPress={() => control.close()}>
+            {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
+          </InlineLink>
+        </Text>
+      </View>
+    </View>
+  )
+}
+
+function AppealForm({
+  label,
+  subject,
+  control,
+  onPressBack,
+}: {
+  label: ComAtprotoLabelDefs.Label
+  subject: Subject
+  control: Dialog.DialogOuterProps['control']
+  onPressBack: () => void
+}) {
+  const {_} = useLingui()
+  const {labeler, strings} = useLabelInfo(label)
+  const {gtMobile} = useBreakpoints()
+  const [details, setDetails] = React.useState('')
+  const isAccountReport = 'did' in subject
+
+  const onSubmit = async () => {
+    try {
+      const $type = !isAccountReport
+        ? 'com.atproto.repo.strongRef'
+        : 'com.atproto.admin.defs#repoRef'
+      await getAgent()
+        .withProxy('atproto_labeler', label.src)
+        .createModerationReport({
+          reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+          subject: {
+            $type,
+            ...subject,
+          },
+          reason: details,
+        })
+      Toast.show(_(msg`Appeal submitted.`))
+    } finally {
+      control.close()
+    }
+  }
+
+  return (
+    <>
+      <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
+        <Trans>Appeal "{strings.name}" label</Trans>
+      </Text>
+      <Text style={[a.text_md, a.leading_snug]}>
+        <Trans>
+          This appeal will be sent to{' '}
+          <InlineLink
+            to={makeProfileLink(
+              labeler ? labeler.creator : {did: label.src, handle: ''},
+            )}
+            onPress={() => control.close()}
+            style={[a.text_md, a.leading_snug]}>
+            {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
+          </InlineLink>
+          .
+        </Trans>
+      </Text>
+      <View style={[a.my_md]}>
+        <Dialog.Input
+          label={_(msg`Text input field`)}
+          placeholder={_(
+            msg`Please explain why you think this label was incorrectly applied by ${
+              labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
+            }`,
+          )}
+          value={details}
+          onChangeText={setDetails}
+          autoFocus={true}
+          numberOfLines={3}
+          multiline
+          maxLength={300}
+        />
+      </View>
+
+      <View
+        style={
+          gtMobile
+            ? [a.flex_row, a.justify_between]
+            : [{flexDirection: 'column-reverse'}, a.gap_sm]
+        }>
+        <Button
+          testID="backBtn"
+          variant="solid"
+          color="secondary"
+          size="medium"
+          onPress={onPressBack}
+          label={_(msg`Back`)}>
+          {_(msg`Back`)}
+        </Button>
+        <Button
+          testID="submitBtn"
+          variant="solid"
+          color="primary"
+          size="medium"
+          onPress={onSubmit}
+          label={_(msg`Submit`)}>
+          {_(msg`Submit`)}
+        </Button>
+      </View>
+    </>
+  )
+}
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
new file mode 100644
index 000000000..da490cb43
--- /dev/null
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ModerationCause} from '@atproto/api'
+
+import {listUriToHref} from '#/lib/strings/url-helpers'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {makeProfileLink} from '#/lib/routes/links'
+
+import {isNative} from '#/platform/detection'
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {InlineLink} from '#/components/Link'
+import {Divider} from '#/components/Divider'
+
+export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
+
+export interface ModerationDetailsDialogProps {
+  control: Dialog.DialogOuterProps['control']
+  modcause: ModerationCause
+}
+
+export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+      <ModerationDetailsDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ModerationDetailsDialogInner({
+  modcause,
+  control,
+}: ModerationDetailsDialogProps & {
+  control: Dialog.DialogOuterProps['control']
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const desc = useModerationCauseDescription(modcause)
+
+  let name
+  let description
+  if (!modcause) {
+    name = _(msg`Content Warning`)
+    description = _(
+      msg`Moderator has chosen to set a general warning on the content.`,
+    )
+  } else if (modcause.type === 'blocking') {
+    if (modcause.source.type === 'list') {
+      const list = modcause.source.list
+      name = _(msg`User Blocked by List`)
+      description = (
+        <Trans>
+          This user is included in the{' '}
+          <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
+            {list.name}
+          </InlineLink>{' '}
+          list which you have blocked.
+        </Trans>
+      )
+    } else {
+      name = _(msg`User Blocked`)
+      description = _(
+        msg`You have blocked this user. You cannot view their content.`,
+      )
+    }
+  } else if (modcause.type === 'blocked-by') {
+    name = _(msg`User Blocks You`)
+    description = _(
+      msg`This user has blocked you. You cannot view their content.`,
+    )
+  } else if (modcause.type === 'block-other') {
+    name = _(msg`Content Not Available`)
+    description = _(
+      msg`This content is not available because one of the users involved has blocked the other.`,
+    )
+  } else if (modcause.type === 'muted') {
+    if (modcause.source.type === 'list') {
+      const list = modcause.source.list
+      name = _(msg`Account Muted by List`)
+      description = (
+        <Trans>
+          This user is included in the{' '}
+          <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
+            {list.name}
+          </InlineLink>{' '}
+          list which you have muted.
+        </Trans>
+      )
+    } else {
+      name = _(msg`Account Muted`)
+      description = _(msg`You have muted this account.`)
+    }
+  } else if (modcause.type === 'mute-word') {
+    name = _(msg`Post Hidden by Muted Word`)
+    description = _(msg`You've chosen to hide a word or tag within this post.`)
+  } else if (modcause.type === 'hidden') {
+    name = _(msg`Post Hidden by You`)
+    description = _(msg`You have hidden this post.`)
+  } else if (modcause.type === 'label') {
+    name = desc.name
+    description = desc.description
+  } else {
+    // should never happen
+    name = ''
+    description = ''
+  }
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Moderation details`)}>
+      <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
+        {name}
+      </Text>
+      <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
+        {description}
+      </Text>
+
+      {modcause.type === 'label' && (
+        <>
+          <Divider />
+          <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
+            <Trans>
+              This label was applied by{' '}
+              {modcause.source.type === 'user' ? (
+                <Trans>the author</Trans>
+              ) : (
+                <InlineLink
+                  to={makeProfileLink({did: modcause.label.src, handle: ''})}
+                  onPress={() => control.close()}
+                  style={a.text_md}>
+                  {desc.source}
+                </InlineLink>
+              )}
+              .
+            </Trans>
+          </Text>
+        </>
+      )}
+
+      {isNative && <View style={{height: 40}} />}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
new file mode 100644
index 000000000..0bfe69678
--- /dev/null
+++ b/src/components/moderation/PostAlerts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {ModerationUI, ModerationCause} from '@atproto/api'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {getModerationCauseKey} from '#/lib/moderation'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function PostAlerts({
+  modui,
+  style,
+}: {
+  modui: ModerationUI
+  includeMute?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  if (!modui.alert && !modui.inform) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_col, a.gap_xs, style]}>
+      <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
+        {modui.alerts.map(cause => (
+          <PostLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+        {modui.informs.map(cause => (
+          <PostLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+      </View>
+    </View>
+  )
+}
+
+function PostLabel({cause}: {cause: ModerationCause}) {
+  const control = useModerationDetailsDialogControl()
+  const desc = useModerationCauseDescription(cause)
+
+  return (
+    <>
+      <Button
+        label={desc.name}
+        variant="solid"
+        color="secondary"
+        size="small"
+        shape="default"
+        onPress={() => {
+          control.open()
+        }}
+        style={[a.px_sm, a.py_xs, a.gap_xs]}>
+        <ButtonIcon icon={desc.icon} position="left" />
+        <ButtonText style={[a.text_left, a.leading_snug]}>
+          {desc.name}
+        </ButtonText>
+      </Button>
+
+      <ModerationDetailsDialog control={control} modcause={cause} />
+    </>
+  )
+}
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
new file mode 100644
index 000000000..464ee2077
--- /dev/null
+++ b/src/components/moderation/PostHider.tsx
@@ -0,0 +1,129 @@
+import React, {ComponentProps} from 'react'
+import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {addStyle} from 'lib/styles'
+
+import {useTheme, atoms as a} from '#/alf'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+import {Text} from '#/components/Typography'
+// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
+import {Link} from '#/view/com/util/Link'
+
+interface Props extends ComponentProps<typeof Link> {
+  iconSize: number
+  iconStyles: StyleProp<ViewStyle>
+  modui: ModerationUI
+}
+
+export function PostHider({
+  testID,
+  href,
+  modui,
+  style,
+  children,
+  iconSize,
+  iconStyles,
+  ...props
+}: Props) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [override, setOverride] = React.useState(false)
+  const control = useModerationDetailsDialogControl()
+  const blur = modui.blurs[0]
+  const desc = useModerationCauseDescription(blur)
+
+  if (!blur) {
+    return (
+      <Link
+        testID={testID}
+        style={style}
+        href={href}
+        accessible={false}
+        {...props}>
+        {children}
+      </Link>
+    )
+  }
+
+  return !override ? (
+    <Pressable
+      onPress={() => {
+        if (!modui.noOverride) {
+          setOverride(v => !v)
+        }
+      }}
+      accessibilityRole="button"
+      accessibilityHint={
+        override ? _(msg`Hide the content`) : _(msg`Show the content`)
+      }
+      accessibilityLabel=""
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        a.py_md,
+        {
+          paddingLeft: 6,
+          paddingRight: 18,
+        },
+        override ? {paddingBottom: 0} : undefined,
+        t.atoms.bg,
+      ]}>
+      <ModerationDetailsDialog control={control} modcause={blur} />
+      <Pressable
+        onPress={() => {
+          control.open()
+        }}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Learn more about this warning`)}
+        accessibilityHint="">
+        <View
+          style={[
+            t.atoms.bg_contrast_25,
+            a.align_center,
+            a.justify_center,
+            {
+              width: iconSize,
+              height: iconSize,
+              borderRadius: iconSize,
+            },
+            iconStyles,
+          ]}>
+          <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
+        </View>
+      </Pressable>
+      <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
+        {desc.name}
+      </Text>
+      {!modui.noOverride && (
+        <Text style={[{color: t.palette.primary_500}]}>
+          {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
+        </Text>
+      )}
+    </Pressable>
+  ) : (
+    <Link
+      testID={testID}
+      style={addStyle(style, styles.child)}
+      href={href}
+      accessible={false}
+      {...props}>
+      {children}
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  child: {
+    borderWidth: 0,
+    borderTopWidth: 0,
+    borderRadius: 8,
+  },
+})
diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx
new file mode 100644
index 000000000..dfc2aa557
--- /dev/null
+++ b/src/components/moderation/ProfileHeaderAlerts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {ModerationCause, ModerationDecision} from '@atproto/api'
+
+import {getModerationCauseKey} from 'lib/moderation'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ProfileHeaderAlerts({
+  moderation,
+  style,
+}: {
+  moderation: ModerationDecision
+  style?: StyleProp<ViewStyle>
+}) {
+  const modui = moderation.ui('profileView')
+  if (!modui.alert && !modui.inform) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_col, a.gap_xs, style]}>
+      <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
+        {modui.alerts.map(cause => (
+          <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+        {modui.informs.map(cause => (
+          <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+      </View>
+    </View>
+  )
+}
+
+function ProfileLabel({cause}: {cause: ModerationCause}) {
+  const control = useModerationDetailsDialogControl()
+  const desc = useModerationCauseDescription(cause)
+
+  return (
+    <>
+      <Button
+        label={desc.name}
+        variant="solid"
+        color="secondary"
+        size="small"
+        shape="default"
+        onPress={() => {
+          control.open()
+        }}
+        style={[a.px_sm, a.py_xs, a.gap_xs]}>
+        <ButtonIcon icon={desc.icon} position="left" />
+        <ButtonText style={[a.text_left, a.leading_snug]}>
+          {desc.name}
+        </ButtonText>
+      </Button>
+
+      <ModerationDetailsDialog control={control} modcause={cause} />
+    </>
+  )
+}
diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx
new file mode 100644
index 000000000..4e3a9680f
--- /dev/null
+++ b/src/components/moderation/ScreenHider.tsx
@@ -0,0 +1,172 @@
+import React from 'react'
+import {
+  TouchableWithoutFeedback,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {ModerationUI} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {NavigationProp} from 'lib/routes/types'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+
+import {useTheme, atoms as a} from '#/alf'
+import {CenteredView} from '#/view/com/util/Views'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ScreenHider({
+  testID,
+  screenDescription,
+  modui,
+  style,
+  containerStyle,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  screenDescription: string
+  modui: ModerationUI
+  style?: StyleProp<ViewStyle>
+  containerStyle?: StyleProp<ViewStyle>
+}>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [override, setOverride] = React.useState(false)
+  const navigation = useNavigation<NavigationProp>()
+  const {isMobile} = useWebMediaQueries()
+  const control = useModerationDetailsDialogControl()
+  const blur = modui.blurs[0]
+  const desc = useModerationCauseDescription(blur)
+
+  if (!blur || override) {
+    return (
+      <View testID={testID} style={style}>
+        {children}
+      </View>
+    )
+  }
+
+  const isNoPwi = !!modui.blurs.find(
+    cause =>
+      cause.type === 'label' &&
+      cause.labelDef.identifier === '!no-unauthenticated',
+  )
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        {
+          paddingTop: 100,
+          paddingBottom: 150,
+        },
+        t.atoms.bg,
+        containerStyle,
+      ]}
+      sideBorders>
+      <View style={[a.align_center, a.mb_md]}>
+        <View
+          style={[
+            t.atoms.bg_contrast_975,
+            a.align_center,
+            a.justify_center,
+            {
+              borderRadius: 25,
+              width: 50,
+              height: 50,
+            },
+          ]}>
+          <desc.icon width={24} fill={t.atoms.bg.backgroundColor} />
+        </View>
+      </View>
+      <Text
+        style={[
+          a.text_4xl,
+          a.font_semibold,
+          a.text_center,
+          a.mb_md,
+          t.atoms.text,
+        ]}>
+        {isNoPwi ? (
+          <Trans>Sign-in Required</Trans>
+        ) : (
+          <Trans>Content Warning</Trans>
+        )}
+      </Text>
+      <Text
+        style={[
+          a.text_lg,
+          a.mb_md,
+          a.px_lg,
+          a.text_center,
+          t.atoms.text_contrast_medium,
+        ]}>
+        {isNoPwi ? (
+          <Trans>
+            This account has requested that users sign in to view their profile.
+          </Trans>
+        ) : (
+          <>
+            <Trans>This {screenDescription} has been flagged:</Trans>
+            <Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
+              {desc.name}.{' '}
+            </Text>
+            <TouchableWithoutFeedback
+              onPress={() => {
+                control.open()
+              }}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Learn more about this warning`)}
+              accessibilityHint="">
+              <Text style={[a.text_lg, {color: t.palette.primary_500}]}>
+                <Trans>Learn More</Trans>
+              </Text>
+            </TouchableWithoutFeedback>
+
+            <ModerationDetailsDialog control={control} modcause={blur} />
+          </>
+        )}{' '}
+      </Text>
+      {isMobile && <View style={a.flex_1} />}
+      <View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}>
+        <Button
+          variant="solid"
+          color="primary"
+          size="large"
+          style={[a.rounded_full]}
+          label={_(msg`Go back`)}
+          onPress={() => {
+            if (navigation.canGoBack()) {
+              navigation.goBack()
+            } else {
+              navigation.navigate('Home')
+            }
+          }}>
+          <ButtonText>
+            <Trans>Go back</Trans>
+          </ButtonText>
+        </Button>
+        {!modui.noOverride && (
+          <Button
+            variant="solid"
+            color="secondary"
+            size="large"
+            style={[a.rounded_full]}
+            label={_(msg`Show anyway`)}
+            onPress={() => setOverride(v => !v)}>
+            <ButtonText>
+              <Trans>Show anyway</Trans>
+            </ButtonText>
+          </Button>
+        )}
+      </View>
+    </CenteredView>
+  )
+}