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/ContextMenu/Backdrop.ios.tsx49
-rw-r--r--src/components/ContextMenu/Backdrop.tsx45
-rw-r--r--src/components/ContextMenu/context.tsx31
-rw-r--r--src/components/ContextMenu/index.tsx591
-rw-r--r--src/components/ContextMenu/index.web.tsx5
-rw-r--r--src/components/ContextMenu/types.ts97
-rw-r--r--src/components/Menu/context.tsx11
-rw-r--r--src/components/Menu/index.tsx2
-rw-r--r--src/components/Menu/index.web.tsx2
-rw-r--r--src/components/dms/ActionsWrapper.tsx125
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx68
-rw-r--r--src/components/dms/MessageContextMenu.tsx (renamed from src/components/dms/MessageMenu.tsx)128
-rw-r--r--src/components/dms/MessageItemEmbed.tsx33
13 files changed, 988 insertions, 199 deletions
diff --git a/src/components/ContextMenu/Backdrop.ios.tsx b/src/components/ContextMenu/Backdrop.ios.tsx
new file mode 100644
index 000000000..27a4ed1d8
--- /dev/null
+++ b/src/components/ContextMenu/Backdrop.ios.tsx
@@ -0,0 +1,49 @@
+import {Pressable} from 'react-native'
+import Animated, {
+  Extrapolation,
+  interpolate,
+  SharedValue,
+  useAnimatedProps,
+} from 'react-native-reanimated'
+import {BlurView} from 'expo-blur'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a} from '#/alf'
+
+const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
+
+export function Backdrop({
+  animation,
+  intensity = 50,
+  onPress,
+}: {
+  animation: SharedValue<number>
+  intensity?: number
+  onPress?: () => void
+}) {
+  const {_} = useLingui()
+
+  const animatedProps = useAnimatedProps(() => ({
+    intensity: interpolate(
+      animation.get(),
+      [0, 1],
+      [0, intensity],
+      Extrapolation.CLAMP,
+    ),
+  }))
+
+  return (
+    <AnimatedBlurView
+      animatedProps={animatedProps}
+      style={[a.absolute, a.inset_0]}
+      tint="systemThinMaterialDark">
+      <Pressable
+        style={a.flex_1}
+        accessibilityLabel={_(msg`Close menu`)}
+        accessibilityHint={_(msg`Tap to close context menu`)}
+        onPress={onPress}
+      />
+    </AnimatedBlurView>
+  )
+}
diff --git a/src/components/ContextMenu/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx
new file mode 100644
index 000000000..857be7c44
--- /dev/null
+++ b/src/components/ContextMenu/Backdrop.tsx
@@ -0,0 +1,45 @@
+import {Pressable} from 'react-native'
+import Animated, {
+  Extrapolation,
+  interpolate,
+  SharedValue,
+  useAnimatedStyle,
+} from 'react-native-reanimated'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+
+export function Backdrop({
+  animation,
+  intensity = 50,
+  onPress,
+}: {
+  animation: SharedValue<number>
+  intensity?: number
+  onPress?: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    opacity: interpolate(
+      animation.get(),
+      [0, 1],
+      [0, intensity / 100],
+      Extrapolation.CLAMP,
+    ),
+  }))
+
+  return (
+    <Animated.View
+      style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
+      <Pressable
+        style={a.flex_1}
+        accessibilityLabel={_(msg`Close menu`)}
+        accessibilityHint={_(msg`Tap to close context menu`)}
+        onPress={onPress}
+      />
+    </Animated.View>
+  )
+}
diff --git a/src/components/ContextMenu/context.tsx b/src/components/ContextMenu/context.tsx
new file mode 100644
index 000000000..213d87a8c
--- /dev/null
+++ b/src/components/ContextMenu/context.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+
+import type {ContextType, ItemContextType} from '#/components/ContextMenu/types'
+
+export const Context = React.createContext<ContextType | null>(null)
+
+export const ItemContext = React.createContext<ItemContextType | null>(null)
+
+export function useContextMenuContext() {
+  const context = React.useContext(Context)
+
+  if (!context) {
+    throw new Error(
+      'useContextMenuContext must be used within a Context.Provider',
+    )
+  }
+
+  return context
+}
+
+export function useContextMenuItemContext() {
+  const context = React.useContext(ItemContext)
+
+  if (!context) {
+    throw new Error(
+      'useContextMenuItemContext must be used within a Context.Provider',
+    )
+  }
+
+  return context
+}
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
new file mode 100644
index 000000000..d172935d6
--- /dev/null
+++ b/src/components/ContextMenu/index.tsx
@@ -0,0 +1,591 @@
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import {
+  BackHandler,
+  Keyboard,
+  LayoutChangeEvent,
+  Pressable,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import Animated, {
+  clamp,
+  interpolate,
+  runOnJS,
+  SharedValue,
+  useAnimatedStyle,
+  useSharedValue,
+  withSpring,
+  WithSpringConfig,
+} from 'react-native-reanimated'
+import {
+  useSafeAreaFrame,
+  useSafeAreaInsets,
+} from 'react-native-safe-area-context'
+import {captureRef} from 'react-native-view-shot'
+import {Image, ImageErrorEventData} from 'expo-image'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useIsFocused} from '@react-navigation/native'
+import flattenReactChildren from 'react-keyed-flatten-children'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {useHaptics} from '#/lib/haptics'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {logger} from '#/logger'
+import {isAndroid, isIOS} from '#/platform/detection'
+import {atoms as a, platform, useTheme} from '#/alf'
+import {
+  Context,
+  ItemContext,
+  useContextMenuContext,
+  useContextMenuItemContext,
+} from '#/components/ContextMenu/context'
+import {
+  ContextType,
+  ItemIconProps,
+  ItemProps,
+  ItemTextProps,
+  Measurement,
+  TriggerProps,
+} from '#/components/ContextMenu/types'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {createPortalGroup} from '#/components/Portal'
+import {Text} from '#/components/Typography'
+import {Backdrop} from './Backdrop'
+
+export {
+  type DialogControlProps as ContextMenuControlProps,
+  useDialogControl as useContextMenuControl,
+} from '#/components/Dialog'
+
+const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
+
+const SPRING: WithSpringConfig = {
+  mass: isIOS ? 1.25 : 0.75,
+  damping: 150,
+  stiffness: 1000,
+  restDisplacementThreshold: 0.01,
+}
+
+/**
+ * Needs placing near the top of the provider stack, but BELOW the theme provider.
+ */
+export function Provider({children}: {children: React.ReactNode}) {
+  return (
+    <PortalProvider>
+      {children}
+      <Outlet />
+    </PortalProvider>
+  )
+}
+
+export function Root({children}: {children: React.ReactNode}) {
+  const [measurement, setMeasurement] = useState<Measurement | null>(null)
+  const animationSV = useSharedValue(0)
+  const translationSV = useSharedValue(0)
+  const isFocused = useIsFocused()
+
+  const clearMeasurement = useCallback(() => setMeasurement(null), [])
+
+  const context = useMemo<ContextType>(
+    () => ({
+      isOpen: !!measurement && isFocused,
+      measurement,
+      animationSV,
+      translationSV,
+      open: (evt: Measurement) => {
+        setMeasurement(evt)
+        animationSV.set(withSpring(1, SPRING))
+      },
+      close: () => {
+        animationSV.set(
+          withSpring(0, SPRING, finished => {
+            if (finished) {
+              translationSV.set(0)
+              runOnJS(clearMeasurement)()
+            }
+          }),
+        )
+      },
+    }),
+    [
+      measurement,
+      setMeasurement,
+      isFocused,
+      animationSV,
+      translationSV,
+      clearMeasurement,
+    ],
+  )
+
+  useEffect(() => {
+    if (isAndroid && context.isOpen) {
+      const listener = BackHandler.addEventListener('hardwareBackPress', () => {
+        context.close()
+        return true
+      })
+
+      return () => listener.remove()
+    }
+  }, [context])
+
+  return <Context.Provider value={context}>{children}</Context.Provider>
+}
+
+export function Trigger({children, label, contentLabel, style}: TriggerProps) {
+  const context = useContextMenuContext()
+  const playHaptic = useHaptics()
+  const {top: topInset} = useSafeAreaInsets()
+  const ref = useRef<View>(null)
+  const isFocused = useIsFocused()
+  const [image, setImage] = useState<string | null>(null)
+  const [pendingMeasurement, setPendingMeasurement] =
+    useState<Measurement | null>(null)
+
+  const open = useNonReactiveCallback(async () => {
+    playHaptic()
+    Keyboard.dismiss()
+    const [measurement, capture] = await Promise.all([
+      new Promise<Measurement>(resolve => {
+        ref.current?.measureInWindow((x, y, width, height) =>
+          resolve({
+            x,
+            y:
+              y +
+              platform({
+                default: 0,
+                android: topInset, // not included in measurement
+              }),
+            width,
+            height,
+          }),
+        )
+      }),
+      captureRef(ref, {result: 'data-uri'}).catch(err => {
+        logger.error(err instanceof Error ? err : String(err), {
+          message: 'Failed to capture image of context menu trigger',
+        })
+        // will cause the image to fail to load, but it will get handled gracefully
+        return '<failed capture>'
+      }),
+    ])
+    setImage(capture)
+    setPendingMeasurement(measurement)
+  })
+
+  const doubleTapGesture = useMemo(() => {
+    return Gesture.Tap()
+      .numberOfTaps(2)
+      .hitSlop(HITSLOP_10)
+      .onEnd(open)
+      .runOnJS(true)
+  }, [open])
+
+  const pressAndHoldGesture = useMemo(() => {
+    return Gesture.LongPress()
+      .onStart(() => {
+        runOnJS(open)()
+      })
+      .cancelsTouchesInView(false)
+  }, [open])
+
+  const composedGestures = Gesture.Exclusive(
+    doubleTapGesture,
+    pressAndHoldGesture,
+  )
+
+  const {translationSV, animationSV} = context
+
+  const measurement = context.measurement || pendingMeasurement
+
+  return (
+    <>
+      <GestureDetector gesture={composedGestures}>
+        <View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
+          {children({
+            isNative: true,
+            control: {isOpen: context.isOpen, open},
+            state: {
+              pressed: false,
+              hovered: false,
+              focused: false,
+            },
+            props: {
+              ref: null,
+              onPress: null,
+              onFocus: null,
+              onBlur: null,
+              onPressIn: null,
+              onPressOut: null,
+              accessibilityHint: null,
+              accessibilityLabel: label,
+              accessibilityRole: null,
+            },
+          })}
+        </View>
+      </GestureDetector>
+      {isFocused && image && measurement && (
+        <Portal>
+          <TriggerClone
+            label={contentLabel}
+            translation={translationSV}
+            animation={animationSV}
+            image={image}
+            measurement={measurement}
+            onDisplay={() => {
+              if (pendingMeasurement) {
+                context.open(pendingMeasurement)
+                setPendingMeasurement(null)
+              }
+            }}
+          />
+        </Portal>
+      )}
+    </>
+  )
+}
+
+/**
+ * an image of the underlying trigger with a grow animation
+ */
+function TriggerClone({
+  translation,
+  animation,
+  image,
+  measurement,
+  onDisplay,
+  label,
+}: {
+  translation: SharedValue<number>
+  animation: SharedValue<number>
+  image: string
+  measurement: Measurement
+  onDisplay: () => void
+  label: string
+}) {
+  const {_} = useLingui()
+
+  const animatedStyles = useAnimatedStyle(() => ({
+    transform: [{translateY: translation.get() * animation.get()}],
+  }))
+
+  const handleError = useCallback(
+    (evt: ImageErrorEventData) => {
+      logger.error('Context menu image load error', {message: evt.error})
+      onDisplay()
+    },
+    [onDisplay],
+  )
+
+  return (
+    <Animated.View
+      style={[
+        a.absolute,
+        {
+          top: measurement.y,
+          left: measurement.x,
+          width: measurement.width,
+          height: measurement.height,
+        },
+        a.z_10,
+        a.pointer_events_none,
+        animatedStyles,
+      ]}>
+      <Image
+        onDisplay={onDisplay}
+        onError={handleError}
+        source={image}
+        style={{
+          width: measurement.width,
+          height: measurement.height,
+        }}
+        accessibilityLabel={label}
+        accessibilityHint={_(msg`The subject of the context menu`)}
+        accessibilityIgnoresInvertColors={false}
+      />
+    </Animated.View>
+  )
+}
+
+const MENU_WIDTH = 230
+
+export function Outer({
+  children,
+  style,
+  align = 'left',
+}: {
+  children: React.ReactNode
+  style?: StyleProp<ViewStyle>
+  align?: 'left' | 'right'
+}) {
+  const t = useTheme()
+  const context = useContextMenuContext()
+  const insets = useSafeAreaInsets()
+  const frame = useSafeAreaFrame()
+
+  const {animationSV, translationSV} = context
+
+  const animatedContainerStyle = useAnimatedStyle(() => ({
+    transform: [{translateY: translationSV.get() * animationSV.get()}],
+  }))
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    opacity: clamp(animationSV.get(), 0, 1),
+    transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}],
+  }))
+
+  const onLayout = useCallback(
+    (evt: LayoutChangeEvent) => {
+      if (!context.measurement) return // should not happen
+      let translation = 0
+
+      // pure vibes based
+      const TOP_INSET = insets.top + 80
+      const BOTTOM_INSET_IOS = insets.bottom + 20
+      const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
+
+      const {height} = evt.nativeEvent.layout
+      const topPosition = context.measurement.y + context.measurement.height + 4
+      const bottomPosition = topPosition + height
+      const safeAreaBottomLimit =
+        frame.height -
+        platform({
+          ios: BOTTOM_INSET_IOS,
+          android: BOTTOM_INSET_ANDROID,
+          default: 0,
+        })
+      const diff = bottomPosition - safeAreaBottomLimit
+      if (diff > 0) {
+        translation = -diff
+      } else {
+        const distanceMessageFromTop = context.measurement.y - TOP_INSET
+        if (distanceMessageFromTop < 0) {
+          translation = -Math.max(distanceMessageFromTop, diff)
+        }
+      }
+
+      if (translation !== 0) {
+        translationSV.set(translation)
+      }
+    },
+    [context.measurement, frame.height, insets, translationSV],
+  )
+
+  if (!context.isOpen || !context.measurement) return null
+
+  return (
+    <Portal>
+      <Context.Provider value={context}>
+        <Backdrop animation={animationSV} onPress={context.close} />
+        {/* containing element - stays the same size, so we measure it
+         to determine if a translation is necessary. also has the positioning */}
+        <Animated.View
+          onLayout={onLayout}
+          style={[
+            a.absolute,
+            a.z_10,
+            a.mt_xs,
+            {
+              width: MENU_WIDTH,
+              top: context.measurement.y + context.measurement.height,
+            },
+            align === 'left'
+              ? {left: context.measurement.x}
+              : {
+                  right:
+                    frame.x +
+                    frame.width -
+                    context.measurement.x -
+                    context.measurement.width,
+                },
+            animatedContainerStyle,
+          ]}>
+          {/* scaling element - has the scale/fade animation on it */}
+          <Animated.View
+            style={[
+              a.rounded_md,
+              a.shadow_md,
+              t.atoms.bg_contrast_25,
+              a.w_full,
+              // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
+              // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
+              // in the typecheck CI - presumably because of RNW overriding the types
+              {
+                transformOrigin:
+                  align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
+              },
+              animatedStyle,
+              style,
+            ]}>
+            {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
+              so put the shadow on the scaling element and the overflow on the innermost element */}
+            <View
+              style={[
+                a.flex_1,
+                a.rounded_md,
+                a.overflow_hidden,
+                a.border,
+                t.atoms.border_contrast_low,
+              ]}>
+              {flattenReactChildren(children).map((child, i) => {
+                return React.isValidElement(child) &&
+                  (child.type === Item || child.type === Divider) ? (
+                  <React.Fragment key={i}>
+                    {i > 0 ? (
+                      <View style={[a.border_b, t.atoms.border_contrast_low]} />
+                    ) : null}
+                    {React.cloneElement(child, {
+                      // @ts-expect-error not typed
+                      style: {
+                        borderRadius: 0,
+                        borderWidth: 0,
+                      },
+                    })}
+                  </React.Fragment>
+                ) : null
+              })}
+            </View>
+          </Animated.View>
+        </Animated.View>
+      </Context.Provider>
+    </Portal>
+  )
+}
+
+export function Item({children, label, style, onPress, ...rest}: ItemProps) {
+  const t = useTheme()
+  const context = useContextMenuContext()
+  const playHaptic = useHaptics()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  return (
+    <Pressable
+      {...rest}
+      accessibilityHint=""
+      accessibilityLabel={label}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onPress={e => {
+        context.close()
+        onPress?.(e)
+      }}
+      onPressIn={e => {
+        onPressIn()
+        rest.onPressIn?.(e)
+        playHaptic('Light')
+      }}
+      onPressOut={e => {
+        onPressOut()
+        rest.onPressOut?.(e)
+      }}
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        a.py_sm,
+        a.px_md,
+        a.rounded_md,
+        a.border,
+        t.atoms.bg_contrast_25,
+        t.atoms.border_contrast_low,
+        {minHeight: 40},
+        style,
+        (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
+      ]}>
+      <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
+        {children}
+      </ItemContext.Provider>
+    </Pressable>
+  )
+}
+
+export function ItemText({children, style}: ItemTextProps) {
+  const t = useTheme()
+  const {disabled} = useContextMenuItemContext()
+  return (
+    <Text
+      numberOfLines={2}
+      ellipsizeMode="middle"
+      style={[
+        a.flex_1,
+        a.text_sm,
+        a.font_bold,
+        t.atoms.text_contrast_high,
+        {paddingTop: 3},
+        style,
+        disabled && t.atoms.text_contrast_low,
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
+export function ItemIcon({icon: Comp}: ItemIconProps) {
+  const t = useTheme()
+  const {disabled} = useContextMenuItemContext()
+  return (
+    <Comp
+      size="md"
+      fill={
+        disabled
+          ? t.atoms.text_contrast_low.color
+          : t.atoms.text_contrast_medium.color
+      }
+    />
+  )
+}
+
+export function ItemRadio({selected}: {selected: boolean}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.rounded_full,
+        t.atoms.border_contrast_high,
+        {
+          borderWidth: 1,
+          height: 20,
+          width: 20,
+        },
+      ]}>
+      {selected ? (
+        <View
+          style={[
+            a.absolute,
+            a.rounded_full,
+            {height: 14, width: 14},
+            selected ? {backgroundColor: t.palette.primary_500} : {},
+          ]}
+        />
+      ) : null}
+    </View>
+  )
+}
+
+export function LabelText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[a.font_bold, t.atoms.text_contrast_medium, {marginBottom: -8}]}>
+      {children}
+    </Text>
+  )
+}
+
+export function Divider() {
+  const t = useTheme()
+  return (
+    <View
+      style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]}
+    />
+  )
+}
diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx
new file mode 100644
index 000000000..f7e3b0c34
--- /dev/null
+++ b/src/components/ContextMenu/index.web.tsx
@@ -0,0 +1,5 @@
+export * from '#/components/Menu'
+
+export function Provider({children}: {children: React.ReactNode}) {
+  return children
+}
diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts
new file mode 100644
index 000000000..0b3fedc55
--- /dev/null
+++ b/src/components/ContextMenu/types.ts
@@ -0,0 +1,97 @@
+import React from 'react'
+import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native'
+import {SharedValue} from 'react-native-reanimated'
+
+import * as Dialog from '#/components/Dialog'
+import {RadixPassThroughTriggerProps} from '#/components/Menu/types'
+
+export type {
+  GroupProps,
+  ItemIconProps,
+  ItemProps,
+  ItemTextProps,
+} from '#/components/Menu/types'
+
+export type Measurement = {
+  x: number
+  y: number
+  width: number
+  height: number
+}
+
+export type ContextType = {
+  isOpen: boolean
+  measurement: Measurement | null
+  /* Spring animation between 0 and 1 */
+  animationSV: SharedValue<number>
+  /* Translation in Y axis to ensure everything's onscreen */
+  translationSV: SharedValue<number>
+  open: (evt: Measurement) => void
+  close: () => void
+}
+
+export type ItemContextType = {
+  disabled: boolean
+}
+
+export type TriggerProps = {
+  children(props: TriggerChildProps): React.ReactNode
+  label: string
+  /**
+   * When activated, this is the accessibility label for the entire thing that has been triggered.
+   * For example, if the trigger is a message bubble, use the message content.
+   *
+   * @platform ios, android
+   */
+  contentLabel: string
+  hint?: string
+  role?: AccessibilityRole
+  style?: StyleProp<ViewStyle>
+}
+export type TriggerChildProps =
+  | {
+      isNative: true
+      control: {isOpen: boolean; open: () => void}
+      state: {
+        hovered: false
+        focused: false
+        pressed: false
+      }
+      /**
+       * 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: {
+        ref: null
+        onPress: null
+        onFocus: null
+        onBlur: null
+        onPressIn: null
+        onPressOut: null
+        accessibilityHint: null
+        accessibilityLabel: string
+        accessibilityRole: null
+      }
+    }
+  | {
+      isNative: false
+      control: Dialog.DialogOuterProps['control']
+      state: {
+        hovered: false
+        focused: false
+        pressed: false
+      }
+      props: RadixPassThroughTriggerProps & {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onMouseEnter: () => void
+        onMouseLeave: () => void
+        accessibilityHint?: string
+        accessibilityLabel: string
+        accessibilityRole: AccessibilityRole
+      }
+    }
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
index 908ad352e..d810a03de 100644
--- a/src/components/Menu/context.tsx
+++ b/src/components/Menu/context.tsx
@@ -2,14 +2,9 @@ import React from 'react'
 
 import type {ContextType, ItemContextType} from '#/components/Menu/types'
 
-export const Context = React.createContext<ContextType>({
-  // @ts-ignore
-  control: null,
-})
-
-export const ItemContext = React.createContext<ItemContextType>({
-  disabled: false,
-})
+export const Context = React.createContext<ContextType | null>(null)
+
+export const ItemContext = React.createContext<ItemContextType | null>(null)
 
 export function useMenuContext() {
   const context = React.useContext(Context)
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 06b9e7e55..a84317771 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -34,7 +34,7 @@ export function Root({
   children,
   control,
 }: React.PropsWithChildren<{
-  control?: Dialog.DialogOuterProps['control']
+  control?: Dialog.DialogControlProps
 }>) {
   const defaultControl = Dialog.useDialogControl()
   const context = React.useMemo<ContextType>(
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 7bf4dde18..07339ef08 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -50,7 +50,7 @@ export function Root({
   children,
   control,
 }: React.PropsWithChildren<{
-  control?: Dialog.DialogOuterProps['control']
+  control?: Dialog.DialogControlProps
 }>) {
   const {_} = useLingui()
   const defaultControl = useMenuControl()
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index a087fed3f..385086d7c 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -1,22 +1,10 @@
-import React from 'react'
-import {Keyboard} from 'react-native'
-import {Gesture, GestureDetector} from 'react-native-gesture-handler'
-import Animated, {
-  cancelAnimation,
-  runOnJS,
-  useAnimatedStyle,
-  useSharedValue,
-  withTiming,
-} from 'react-native-reanimated'
+import {View} from 'react-native'
 import {ChatBskyConvoDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {HITSLOP_10} from '#/lib/constants'
-import {useHaptics} from '#/lib/haptics'
 import {atoms as a} from '#/alf'
-import {MessageMenu} from '#/components/dms/MessageMenu'
-import {useMenuControl} from '#/components/Menu'
+import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
 
 export function ActionsWrapper({
   message,
@@ -28,71 +16,52 @@ export function ActionsWrapper({
   children: React.ReactNode
 }) {
   const {_} = useLingui()
-  const playHaptic = useHaptics()
-  const menuControl = useMenuControl()
-
-  const scale = useSharedValue(1)
-
-  const animatedStyle = useAnimatedStyle(() => ({
-    transform: [{scale: scale.get()}],
-  }))
-
-  const open = React.useCallback(() => {
-    playHaptic()
-    Keyboard.dismiss()
-    menuControl.open()
-  }, [menuControl, playHaptic])
-
-  const shrink = React.useCallback(() => {
-    'worklet'
-    cancelAnimation(scale)
-    scale.set(() => withTiming(1, {duration: 200}))
-  }, [scale])
-
-  const doubleTapGesture = Gesture.Tap()
-    .numberOfTaps(2)
-    .hitSlop(HITSLOP_10)
-    .onEnd(open)
-    .runOnJS(true)
-
-  const pressAndHoldGesture = Gesture.LongPress()
-    .onStart(() => {
-      'worklet'
-      scale.set(() =>
-        withTiming(1.05, {duration: 200}, finished => {
-          if (!finished) return
-          runOnJS(open)()
-          shrink()
-        }),
-      )
-    })
-    .onTouchesUp(shrink)
-    .onTouchesMove(shrink)
-    .cancelsTouchesInView(false)
-
-  const composedGestures = Gesture.Exclusive(
-    doubleTapGesture,
-    pressAndHoldGesture,
-  )
 
   return (
-    <GestureDetector gesture={composedGestures}>
-      <Animated.View
-        style={[
-          {
-            maxWidth: '80%',
-          },
-          isFromSelf ? a.self_end : a.self_start,
-          animatedStyle,
-        ]}
-        accessible={true}
-        accessibilityActions={[
-          {name: 'activate', label: _(msg`Open message options`)},
-        ]}
-        onAccessibilityAction={open}>
-        {children}
-        <MessageMenu message={message} control={menuControl} />
-      </Animated.View>
-    </GestureDetector>
+    <MessageContextMenu message={message}>
+      {trigger =>
+        // will always be true, since this file is platform split
+        trigger.isNative && (
+          <View style={[a.flex_1, a.relative]}>
+            {/* {isNative && (
+              <View
+                style={[
+                  a.rounded_full,
+                  a.absolute,
+                  {bottom: '100%'},
+                  isFromSelf ? a.right_0 : a.left_0,
+                  t.atoms.bg,
+                  a.flex_row,
+                  a.shadow_lg,
+                  a.py_xs,
+                  a.px_md,
+                  a.gap_md,
+                  a.mb_xs,
+                ]}>
+                {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
+                  <Text key={emoji} style={[a.text_center, {fontSize: 32}]}>
+                    {emoji}
+                  </Text>
+                ))}
+              </View>
+            )} */}
+            <View
+              style={[
+                {maxWidth: '80%'},
+                isFromSelf
+                  ? [a.self_end, a.align_end]
+                  : [a.self_start, a.align_start],
+              ]}
+              accessible={true}
+              accessibilityActions={[
+                {name: 'activate', label: _(msg`Open message options`)},
+              ]}
+              onAccessibilityAction={trigger.control.open}>
+              {children}
+            </View>
+          </View>
+        )
+      }
+    </MessageContextMenu>
   )
 }
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 29cc89dd1..188d18eb7 100644
--- a/src/components/dms/ActionsWrapper.web.tsx
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
-import {StyleSheet, View} from 'react-native'
+import {Pressable, View} from 'react-native'
 import {ChatBskyConvoDefs} from '@atproto/api'
 
-import {atoms as a} from '#/alf'
-import {MessageMenu} from '#/components/dms/MessageMenu'
-import {useMenuControl} from '#/components/Menu'
+import {atoms as a, useTheme} from '#/alf'
+import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid'
 
 export function ActionsWrapper({
   message,
@@ -15,8 +15,8 @@ export function ActionsWrapper({
   isFromSelf: boolean
   children: React.ReactNode
 }) {
-  const menuControl = useMenuControl()
   const viewRef = React.useRef(null)
+  const t = useTheme()
 
   const [showActions, setShowActions] = React.useState(false)
 
@@ -42,39 +42,39 @@ export function ActionsWrapper({
       onMouseLeave={onMouseLeave}
       onFocus={onFocus}
       onBlur={onMouseLeave}
-      style={StyleSheet.flatten([a.flex_1, a.flex_row])}
+      style={[a.flex_1, isFromSelf ? a.flex_row : a.flex_row_reverse]}
       ref={viewRef}>
-      {isFromSelf && (
-        <View
-          style={[
-            a.mr_xl,
-            a.justify_center,
-            {
-              marginLeft: 'auto',
-            },
-          ]}>
-          <MessageMenu
-            message={message}
-            control={menuControl}
-            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
-          />
-        </View>
-      )}
       <View
-        style={{
-          maxWidth: '80%',
-        }}>
+        style={[
+          a.justify_center,
+          isFromSelf
+            ? [a.mr_xl, {marginLeft: 'auto'}]
+            : [a.ml_xl, {marginRight: 'auto'}],
+        ]}>
+        <MessageContextMenu message={message}>
+          {({props, state, isNative, control}) => {
+            // always false, file is platform split
+            if (isNative) return null
+            const showMenuTrigger = showActions || control.isOpen ? 1 : 0
+            return (
+              <Pressable
+                {...props}
+                style={[
+                  {opacity: showMenuTrigger},
+                  a.p_sm,
+                  a.rounded_full,
+                  (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                ]}>
+                <DotsHorizontalIcon size="md" style={t.atoms.text} />
+              </Pressable>
+            )
+          }}
+        </MessageContextMenu>
+      </View>
+      <View
+        style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
         {children}
       </View>
-      {!isFromSelf && (
-        <View style={[a.flex_row, a.align_center, a.ml_xl]}>
-          <MessageMenu
-            message={message}
-            control={menuControl}
-            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
-          />
-        </View>
-      )}
     </View>
   )
 }
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index cff5f9dd4..b5542690f 100644
--- a/src/components/dms/MessageMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {LayoutAnimation, Pressable, View} from 'react-native'
+import {LayoutAnimation} from 'react-native'
 import * as Clipboard from 'expo-clipboard'
 import {ChatBskyConvoDefs, RichText} from '@atproto/api'
 import {msg} from '@lingui/macro'
@@ -8,33 +8,28 @@ import {useLingui} from '@lingui/react'
 import {useOpenLink} from '#/lib/hooks/useOpenLink'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {getTranslatorLink} from '#/locale/helpers'
-import {isWeb} from '#/platform/detection'
 import {useConvoActive} from '#/state/messages/convo'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useTheme} from '#/alf'
+import * as ContextMenu from '#/components/ContextMenu'
+import {TriggerProps} from '#/components/ContextMenu/types'
 import {ReportDialog} from '#/components/dms/ReportDialog'
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
-import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
-import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
 import {usePromptControl} from '#/components/Prompt'
-import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
 
-export let MessageMenu = ({
+export let MessageContextMenu = ({
   message,
-  control,
-  triggerOpacity,
+  children,
 }: {
-  triggerOpacity?: number
   message: ChatBskyConvoDefs.MessageView
-  control: Menu.MenuControlProps
+  children: TriggerProps['children']
 }): React.ReactNode => {
   const {_} = useLingui()
-  const t = useTheme()
   const {currentAccount} = useSession()
   const convo = useConvoActive()
   const deleteControl = usePromptControl()
@@ -75,69 +70,64 @@ export let MessageMenu = ({
       .catch(() => Toast.show(_(msg`Failed to delete message`)))
   }, [_, convo, message.id])
 
+  const sender = convo.convo.members.find(
+    member => member.did === message.sender.did,
+  )
+
   return (
     <>
-      <Menu.Root control={control}>
-        {isWeb && (
-          <View style={{opacity: triggerOpacity}}>
-            <Menu.Trigger label={_(msg`Chat settings`)}>
-              {({props, state}) => (
-                <Pressable
-                  {...props}
-                  style={[
-                    a.p_sm,
-                    a.rounded_full,
-                    (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
-                  ]}>
-                  <DotsHorizontal size="md" style={t.atoms.text} />
-                </Pressable>
-              )}
-            </Menu.Trigger>
-          </View>
-        )}
+      <ContextMenu.Root>
+        <ContextMenu.Trigger
+          label={_(msg`Message options`)}
+          contentLabel={_(
+            msg`Message from @${
+              sender?.handle ?? // should always be defined
+              'unknown'
+            }: ${message.text}`,
+          )}>
+          {children}
+        </ContextMenu.Trigger>
 
-        <Menu.Outer>
+        <ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
           {message.text.length > 0 && (
             <>
-              <Menu.Group>
-                <Menu.Item
-                  testID="messageDropdownTranslateBtn"
-                  label={_(msg`Translate`)}
-                  onPress={onPressTranslateMessage}>
-                  <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={Translate} position="right" />
-                </Menu.Item>
-                <Menu.Item
-                  testID="messageDropdownCopyBtn"
-                  label={_(msg`Copy message text`)}
-                  onPress={onCopyMessage}>
-                  <Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={ClipboardIcon} position="right" />
-                </Menu.Item>
-              </Menu.Group>
-              <Menu.Divider />
+              <ContextMenu.Item
+                testID="messageDropdownTranslateBtn"
+                label={_(msg`Translate`)}
+                onPress={onPressTranslateMessage}>
+                <ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
+                <ContextMenu.ItemIcon icon={Translate} position="right" />
+              </ContextMenu.Item>
+              <ContextMenu.Item
+                testID="messageDropdownCopyBtn"
+                label={_(msg`Copy message text`)}
+                onPress={onCopyMessage}>
+                <ContextMenu.ItemText>
+                  {_(msg`Copy message text`)}
+                </ContextMenu.ItemText>
+                <ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
+              </ContextMenu.Item>
+              <ContextMenu.Divider />
             </>
           )}
-          <Menu.Group>
-            <Menu.Item
-              testID="messageDropdownDeleteBtn"
-              label={_(msg`Delete message for me`)}
-              onPress={() => deleteControl.open()}>
-              <Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
-              <Menu.ItemIcon icon={Trash} position="right" />
-            </Menu.Item>
-            {!isFromSelf && (
-              <Menu.Item
-                testID="messageDropdownReportBtn"
-                label={_(msg`Report message`)}
-                onPress={() => reportControl.open()}>
-                <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
-                <Menu.ItemIcon icon={Warning} position="right" />
-              </Menu.Item>
-            )}
-          </Menu.Group>
-        </Menu.Outer>
-      </Menu.Root>
+          <ContextMenu.Item
+            testID="messageDropdownDeleteBtn"
+            label={_(msg`Delete message for me`)}
+            onPress={() => deleteControl.open()}>
+            <ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
+            <ContextMenu.ItemIcon icon={Trash} position="right" />
+          </ContextMenu.Item>
+          {!isFromSelf && (
+            <ContextMenu.Item
+              testID="messageDropdownReportBtn"
+              label={_(msg`Report message`)}
+              onPress={() => reportControl.open()}>
+              <ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
+              <ContextMenu.ItemIcon icon={Warning} position="right" />
+            </ContextMenu.Item>
+          )}
+        </ContextMenu.Outer>
+      </ContextMenu.Root>
 
       <ReportDialog
         currentScreen="conversation"
@@ -158,4 +148,4 @@ export let MessageMenu = ({
     </>
   )
 }
-MessageMenu = React.memo(MessageMenu)
+MessageContextMenu = React.memo(MessageContextMenu)
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index f9eb4d3af..f1c6189d0 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -1,9 +1,9 @@
 import React from 'react'
-import {View} from 'react-native'
+import {useWindowDimensions, View} from 'react-native'
 import {AppBskyEmbedRecord} from '@atproto/api'
 
 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
-import {atoms as a, native, useTheme} from '#/alf'
+import {atoms as a, native, tokens, useTheme, web} from '#/alf'
 import {MessageContextProvider} from './MessageContext'
 
 let MessageItemEmbed = ({
@@ -12,15 +12,32 @@ let MessageItemEmbed = ({
   embed: AppBskyEmbedRecord.View
 }): React.ReactNode => {
   const t = useTheme()
+  const screen = useWindowDimensions()
 
   return (
     <MessageContextProvider>
-      <View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
-        <PostEmbeds
-          embed={embed}
-          allowNestedQuotes
-          viewContext={PostEmbedViewContext.Feed}
-        />
+      <View
+        style={[
+          a.my_xs,
+          t.atoms.bg,
+          a.rounded_md,
+          native({
+            flexBasis: 0,
+            width: Math.min(screen.width, 600) / 1.4,
+          }),
+          web({
+            width: '100%',
+            minWidth: 280,
+            maxWidth: 360,
+          }),
+        ]}>
+        <View style={{marginTop: tokens.space.sm * -1}}>
+          <PostEmbeds
+            embed={embed}
+            allowNestedQuotes
+            viewContext={PostEmbedViewContext.Feed}
+          />
+        </View>
       </View>
     </MessageContextProvider>
   )