about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-03-28 08:43:40 +0200
committerGitHub <noreply@github.com>2025-03-28 08:43:40 +0200
commit55a40c2436b68dea850e54a65c5dd197132c08e4 (patch)
treee6d4d2d45ce5a3475aa4f73556910ff7d818986f
parentac2c2a9a1d2d09442a497dc0dcfd8bc0bf715372 (diff)
downloadvoidsky-55a40c2436b68dea850e54a65c5dd197132c08e4.tar.zst
[DMs] Emoji reaction picker (#8023)
-rw-r--r--package.json1
-rw-r--r--src/components/ContextMenu/Backdrop.ios.tsx54
-rw-r--r--src/components/ContextMenu/Backdrop.tsx8
-rw-r--r--src/components/ContextMenu/index.tsx414
-rw-r--r--src/components/ContextMenu/index.web.tsx7
-rw-r--r--src/components/ContextMenu/types.ts40
-rw-r--r--src/components/Menu/index.tsx2
-rw-r--r--src/components/Menu/index.web.tsx2
-rw-r--r--src/components/dms/ActionsWrapper.tsx24
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx40
-rw-r--r--src/components/dms/EmojiPopup.android.tsx82
-rw-r--r--src/components/dms/EmojiPopup.tsx1
-rw-r--r--src/components/dms/EmojiReactionPicker.tsx118
-rw-r--r--src/components/dms/EmojiReactionPicker.web.tsx86
-rw-r--r--src/components/dms/MessageContextMenu.tsx12
-rw-r--r--yarn.lock5
16 files changed, 698 insertions, 198 deletions
diff --git a/package.json b/package.json
index 7126d290d..191005c58 100644
--- a/package.json
+++ b/package.json
@@ -178,6 +178,7 @@
     "react-native-compressor": "1.10.3",
     "react-native-date-picker": "^5.0.7",
     "react-native-drawer-layout": "^4.1.1",
+    "react-native-emoji-popup": "^0.1.2",
     "react-native-gesture-handler": "2.20.2",
     "react-native-get-random-values": "~1.11.0",
     "react-native-image-crop-picker": "^0.41.6",
diff --git a/src/components/ContextMenu/Backdrop.ios.tsx b/src/components/ContextMenu/Backdrop.ios.tsx
index 27a4ed1d8..60a8fda44 100644
--- a/src/components/ContextMenu/Backdrop.ios.tsx
+++ b/src/components/ContextMenu/Backdrop.ios.tsx
@@ -2,26 +2,36 @@ import {Pressable} from 'react-native'
 import Animated, {
   Extrapolation,
   interpolate,
-  SharedValue,
+  type SharedValue,
   useAnimatedProps,
+  useAnimatedStyle,
 } 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'
+import {atoms as a, useTheme} from '#/alf'
+import {useContextMenuContext} from './context'
 
 const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
 
-export function Backdrop({
-  animation,
-  intensity = 50,
-  onPress,
-}: {
+type Props = {
   animation: SharedValue<number>
   intensity?: number
   onPress?: () => void
-}) {
+}
+
+export function Backdrop(props: Props) {
+  const {mode} = useContextMenuContext()
+  switch (mode) {
+    case 'full':
+      return <BlurredBackdrop {...props} />
+    case 'auxiliary-only':
+      return <OpacityBackdrop {...props} />
+  }
+}
+
+function BlurredBackdrop({animation, intensity = 50, onPress}: Props) {
   const {_} = useLingui()
 
   const animatedProps = useAnimatedProps(() => ({
@@ -37,7 +47,7 @@ export function Backdrop({
     <AnimatedBlurView
       animatedProps={animatedProps}
       style={[a.absolute, a.inset_0]}
-      tint="systemThinMaterialDark">
+      tint="systemMaterialDark">
       <Pressable
         style={a.flex_1}
         accessibilityLabel={_(msg`Close menu`)}
@@ -47,3 +57,29 @@ export function Backdrop({
     </AnimatedBlurView>
   )
 }
+
+function OpacityBackdrop({animation, onPress}: Props) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    opacity: interpolate(
+      animation.get(),
+      [0, 1],
+      [0, 0.05],
+      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/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx
index 857be7c44..027bf9849 100644
--- a/src/components/ContextMenu/Backdrop.tsx
+++ b/src/components/ContextMenu/Backdrop.tsx
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {atoms as a, useTheme} from '#/alf'
+import {useContextMenuContext} from './context'
 
 export function Backdrop({
   animation,
@@ -21,12 +22,17 @@ export function Backdrop({
 }) {
   const t = useTheme()
   const {_} = useLingui()
+  const {mode} = useContextMenuContext()
+
+  const reduced = mode === 'auxiliary-only'
+
+  const target = reduced ? 0.05 : intensity / 100
 
   const animatedStyle = useAnimatedStyle(() => ({
     opacity: interpolate(
       animation.get(),
       [0, 1],
-      [0, intensity / 100],
+      [0, target],
       Extrapolation.CLAMP,
     ),
   }))
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index 840e2caea..90c448782 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -9,37 +9,37 @@ import React, {
 import {
   BackHandler,
   Keyboard,
-  LayoutChangeEvent,
+  type LayoutChangeEvent,
   Pressable,
-  StyleProp,
+  type StyleProp,
   useWindowDimensions,
   View,
-  ViewStyle,
+  type ViewStyle,
 } from 'react-native'
 import {
   Gesture,
   GestureDetector,
-  GestureStateChangeEvent,
-  GestureUpdateEvent,
-  PanGestureHandlerEventPayload,
+  type GestureStateChangeEvent,
+  type GestureUpdateEvent,
+  type PanGestureHandlerEventPayload,
 } from 'react-native-gesture-handler'
 import Animated, {
   clamp,
   interpolate,
   runOnJS,
-  SharedValue,
+  type SharedValue,
   useAnimatedReaction,
   useAnimatedStyle,
   useSharedValue,
   withSpring,
-  WithSpringConfig,
+  type 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 {Image, type ImageErrorEventData} from 'expo-image'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useIsFocused} from '@react-navigation/native'
@@ -60,12 +60,13 @@ import {
   useContextMenuMenuContext,
 } from '#/components/ContextMenu/context'
 import {
-  ContextType,
-  ItemIconProps,
-  ItemProps,
-  ItemTextProps,
-  Measurement,
-  TriggerProps,
+  type AuxiliaryViewProps,
+  type ContextType,
+  type ItemIconProps,
+  type ItemProps,
+  type ItemTextProps,
+  type Measurement,
+  type TriggerProps,
 } from '#/components/ContextMenu/types'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {createPortalGroup} from '#/components/Portal'
@@ -79,7 +80,14 @@ export {
 
 const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
 
-const SPRING: WithSpringConfig = {
+const SPRING_IN: WithSpringConfig = {
+  mass: isIOS ? 1.25 : 0.75,
+  damping: 50,
+  stiffness: 1100,
+  restDisplacementThreshold: 0.01,
+}
+
+const SPRING_OUT: WithSpringConfig = {
   mass: isIOS ? 1.25 : 0.75,
   damping: 150,
   stiffness: 1000,
@@ -100,6 +108,7 @@ export function Provider({children}: {children: React.ReactNode}) {
 
 export function Root({children}: {children: React.ReactNode}) {
   const playHaptic = useHaptics()
+  const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
   const [measurement, setMeasurement] = useState<Measurement | null>(null)
   const animationSV = useSharedValue(0)
   const translationSV = useSharedValue(0)
@@ -134,13 +143,15 @@ export function Root({children}: {children: React.ReactNode}) {
         measurement,
         animationSV,
         translationSV,
-        open: (evt: Measurement) => {
+        mode,
+        open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
           setMeasurement(evt)
-          animationSV.set(withSpring(1, SPRING))
+          setMode(mode)
+          animationSV.set(withSpring(1, SPRING_IN))
         },
         close: () => {
           animationSV.set(
-            withSpring(0, SPRING, finished => {
+            withSpring(0, SPRING_OUT, finished => {
               if (finished) {
                 hoverablesSV.set({})
                 translationSV.set(0)
@@ -192,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
       hoveredMenuItem,
       setHoveredMenuItem,
       playHaptic,
+      mode,
     ],
   )
 
@@ -216,45 +228,49 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
   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 [pendingMeasurement, setPendingMeasurement] = useState<{
+    measurement: Measurement
+    mode: 'full' | 'auxiliary-only'
+  } | null>(null)
+
+  const open = useNonReactiveCallback(
+    async (mode: 'full' | 'auxiliary-only') => {
+      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, mode})
+    },
+  )
 
   const doubleTapGesture = useMemo(() => {
     return Gesture.Tap()
       .numberOfTaps(2)
       .hitSlop(HITSLOP_10)
-      .onEnd(open)
+      .onEnd(() => open('auxiliary-only'))
       .runOnJS(true)
   }, [open])
 
@@ -283,17 +299,19 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
       .averageTouches(true)
       .onStart(() => {
         'worklet'
-        runOnJS(open)()
+        runOnJS(open)('full')
       })
       .onUpdate(evt => {
         'worklet'
         const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
         hoveredItemSV.set(item)
       })
-      .onEnd(evt => {
+      .onEnd(() => {
         'worklet'
-        const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
-        hoveredItemSV.set(null)
+        // don't recalculate hovered item - if they haven't moved their finger from
+        // the initial press, it's jarring to then select the item underneath
+        // as the menu may have slid into place beneath their finger
+        const item = hoveredItemSV.get()
         if (item) {
           runOnJS(onTouchUpMenuItem)(item)
         }
@@ -305,7 +323,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
     pressAndHoldGesture,
   )
 
-  const measurement = context.measurement || pendingMeasurement
+  const measurement = context.measurement || pendingMeasurement?.measurement
 
   return (
     <>
@@ -343,7 +361,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
             measurement={measurement}
             onDisplay={() => {
               if (pendingMeasurement) {
-                context.open(pendingMeasurement)
+                context.open(
+                  pendingMeasurement.measurement,
+                  pendingMeasurement.mode,
+                )
                 setPendingMeasurement(null)
               }
             }}
@@ -416,7 +437,90 @@ function TriggerClone({
   )
 }
 
-const MENU_WIDTH = 230
+export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
+  const context = useContextMenuContext()
+  const {width: screenWidth} = useWindowDimensions()
+  const {top: topInset} = useSafeAreaInsets()
+  const ensureOnScreenTranslationSV = useSharedValue(0)
+
+  const {isOpen, mode, measurement, translationSV, animationSV} = context
+
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      opacity: clamp(animationSV.get(), 0, 1),
+      transform: [
+        {
+          translateY:
+            (ensureOnScreenTranslationSV.get() || translationSV.get()) *
+            animationSV.get(),
+        },
+        {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
+      ],
+    }
+  })
+
+  const menuContext = useMemo(() => ({align}), [align])
+
+  const onLayout = useCallback(() => {
+    if (!measurement) return
+
+    let translation = 0
+
+    // vibes based, just assuming it'll fit within this space. revisit if we use
+    // AuxiliaryView for something tall
+    const TOP_INSET = topInset + 80
+
+    const distanceMessageFromTop = measurement.y - TOP_INSET
+    if (distanceMessageFromTop < 0) {
+      translation = -distanceMessageFromTop
+    }
+
+    // normally, the context menu is responsible for measuring itself and moving everything into the right place
+    // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
+    if (mode === 'auxiliary-only') {
+      translationSV.set(translation)
+      ensureOnScreenTranslationSV.set(0)
+    }
+    // however, we also need to make sure that for super tall triggers, we don't go off the screen
+    // so we have an additional cap on the standard transform every other element has
+    // note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
+    // we'll just have to live with it for now, fixing it would be possible but be a large complexity
+    // increase for an edge case
+    else {
+      ensureOnScreenTranslationSV.set(translation)
+    }
+  }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
+
+  if (!isOpen || !measurement) return null
+
+  return (
+    <Portal>
+      <Context.Provider value={context}>
+        <MenuContext.Provider value={menuContext}>
+          <Animated.View
+            onLayout={onLayout}
+            style={[
+              a.absolute,
+              {
+                top: measurement.y,
+                transformOrigin:
+                  align === 'left' ? 'bottom left' : 'bottom right',
+              },
+              align === 'left'
+                ? {left: measurement.x}
+                : {right: screenWidth - measurement.x - measurement.width},
+              animatedStyle,
+              a.z_20,
+            ]}>
+            {children}
+          </Animated.View>
+        </MenuContext.Provider>
+      </Context.Provider>
+    </Portal>
+  )
+}
+
+const MENU_WIDTH = 240
 
 export function Outer({
   children,
@@ -491,85 +595,95 @@ export function Outer({
       <Context.Provider value={context}>
         <MenuContext.Provider value={menuContext}>
           <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:
-                      screenWidth -
-                      context.measurement.x -
-                      context.measurement.width,
-                  },
-              animatedContainerStyle,
-            ]}>
-            {/* scaling element - has the scale/fade animation on it */}
+          {context.mode === 'full' && (
+            /* 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.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
+                a.absolute,
+                a.z_10,
+                a.mt_xs,
                 {
-                  transformOrigin:
-                    // "top right" doesn't seem to work on android, so set explicity in pixels
-                    align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
+                  width: MENU_WIDTH,
+                  top: context.measurement.y + context.measurement.height,
                 },
-                animatedStyle,
-                style,
+                align === 'left'
+                  ? {left: context.measurement.x}
+                  : {
+                      right:
+                        screenWidth -
+                        context.measurement.x -
+                        context.measurement.width,
+                    },
+                animatedContainerStyle,
               ]}>
-              {/* 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
+              {/* scaling element - has the scale/fade animation on it */}
+              <Animated.View
                 style={[
-                  a.flex_1,
                   a.rounded_md,
-                  a.overflow_hidden,
-                  a.border,
-                  t.atoms.border_contrast_low,
+                  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:
+                      // "top right" doesn't seem to work on android, so set explicitly in pixels
+                      align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
+                  },
+                  animatedStyle,
+                  style,
                 ]}>
-                {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>
+                {/* 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>
-          </Animated.View>
+          )}
         </MenuContext.Provider>
       </Context.Provider>
     </Portal>
   )
 }
 
-export function Item({children, label, style, onPress, ...rest}: ItemProps) {
+export function Item({
+  children,
+  label,
+  unstyled,
+  style,
+  onPress,
+  position,
+  ...rest
+}: ItemProps) {
   const t = useTheme()
   const context = useContextMenuContext()
   const playHaptic = useHaptics()
@@ -590,16 +704,22 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
 
       const layout = evt.nativeEvent.layout
 
+      const yOffset = position
+        ? position.y
+        : measurement.y + measurement.height + tokens.space.xs
+      const xOffset = position
+        ? position.x
+        : align === 'left'
+        ? measurement.x
+        : measurement.x + measurement.width - layout.width
+
       registerHoverable(
         id,
         {
           width: layout.width,
           height: layout.height,
-          y: measurement.y + measurement.height + tokens.space.xs + layout.y,
-          x:
-            align === 'left'
-              ? measurement.x
-              : measurement.x + measurement.width - layout.width,
+          y: yOffset + layout.y,
+          x: xOffset + layout.x,
         },
         () => {
           close()
@@ -607,7 +727,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
         },
       )
     },
-    [id, measurement, registerHoverable, close, onPress, align],
+    [id, measurement, registerHoverable, close, onPress, align, position],
   )
 
   const itemContext = useMemo(
@@ -637,22 +757,26 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
         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},
+        !unstyled && [
+          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},
+          (focused || pressed || context.hoveredMenuItem === id) &&
+            !rest.disabled &&
+            t.atoms.bg_contrast_50,
+        ],
         style,
-        (focused || pressed || context.hoveredMenuItem === id) &&
-          !rest.disabled && [t.atoms.bg_contrast_50],
       ]}>
       <ItemContext.Provider value={itemContext}>
-        {children}
+        {typeof children === 'function'
+          ? children(focused || pressed || context.hoveredMenuItem === id)
+          : children}
       </ItemContext.Provider>
     </Pressable>
   )
@@ -667,7 +791,7 @@ export function ItemText({children, style}: ItemTextProps) {
       ellipsizeMode="middle"
       style={[
         a.flex_1,
-        a.text_sm,
+        a.text_md,
         a.font_bold,
         t.atoms.text_contrast_high,
         {paddingTop: 3},
@@ -684,7 +808,7 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
   const {disabled} = useContextMenuItemContext()
   return (
     <Comp
-      size="md"
+      size="lg"
       fill={
         disabled
           ? t.atoms.text_contrast_low.color
diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx
index f7e3b0c34..41fa62474 100644
--- a/src/components/ContextMenu/index.web.tsx
+++ b/src/components/ContextMenu/index.web.tsx
@@ -1,5 +1,12 @@
+import {type AuxiliaryViewProps} from './types'
+
 export * from '#/components/Menu'
 
 export function Provider({children}: {children: React.ReactNode}) {
   return children
 }
+
+// native only
+export function AuxiliaryView({}: AuxiliaryViewProps) {
+  return null
+}
diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts
index 2084ecf19..265a746ca 100644
--- a/src/components/ContextMenu/types.ts
+++ b/src/components/ContextMenu/types.ts
@@ -1,16 +1,16 @@
-import React from 'react'
 import {
-  AccessibilityRole,
-  GestureResponderEvent,
-  StyleProp,
-  ViewStyle,
+  type AccessibilityRole,
+  type GestureResponderEvent,
+  type StyleProp,
+  type ViewStyle,
 } from 'react-native'
-import {SharedValue} from 'react-native-reanimated'
+import {type SharedValue} from 'react-native-reanimated'
+import type React from 'react'
 
-import * as Dialog from '#/components/Dialog'
+import type * as Dialog from '#/components/Dialog'
 import {
-  ItemProps as MenuItemProps,
-  RadixPassThroughTriggerProps,
+  type ItemProps as MenuItemProps,
+  type RadixPassThroughTriggerProps,
 } from '#/components/Menu/types'
 
 export type {
@@ -19,9 +19,19 @@ export type {
   ItemTextProps,
 } from '#/components/Menu/types'
 
-// Same as Menu.ItemProps, but onPress is not guaranteed to get an event
-export type ItemProps = Omit<MenuItemProps, 'onPress'> & {
+export type AuxiliaryViewProps = {
+  children?: React.ReactNode
+  align?: 'left' | 'right'
+}
+
+export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
+  // remove default styles (i.e. for emoji reactions)
+  unstyled?: boolean
   onPress: (evt?: GestureResponderEvent) => void
+  children?: React.ReactNode | ((hovered: boolean) => React.ReactNode)
+  // absolute position of the parent element. if undefined, assumed to
+  // be in the context menu. use this if using AuxiliaryView
+  position?: Measurement
 }
 
 export type Measurement = {
@@ -38,7 +48,8 @@ export type ContextType = {
   animationSV: SharedValue<number>
   /* Translation in Y axis to ensure everything's onscreen */
   translationSV: SharedValue<number>
-  open: (evt: Measurement) => void
+  mode: 'full' | 'auxiliary-only'
+  open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
   close: () => void
   registerHoverable: (
     id: string,
@@ -76,7 +87,10 @@ export type TriggerProps = {
 export type TriggerChildProps =
   | {
       isNative: true
-      control: {isOpen: boolean; open: () => void}
+      control: {
+        isOpen: boolean
+        open: (mode: 'full' | 'auxiliary-only') => void
+      }
       state: {
         hovered: false
         focused: false
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index a84317771..76fc74dc1 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -30,6 +30,8 @@ export {
   useDialogControl as useMenuControl,
 } from '#/components/Dialog'
 
+export {useMenuContext}
+
 export function Root({
   children,
   control,
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 07339ef08..ae021dcf1 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -26,6 +26,8 @@ import {
 import {Portal} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 
+export {useMenuContext}
+
 export function useMenuControl(): Dialog.DialogControlProps {
   const id = React.useId()
   const [isOpen, setIsOpen] = React.useState(false)
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index 385086d7c..120a5f8ad 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -23,28 +23,6 @@ export function ActionsWrapper({
         // 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%'},
@@ -56,7 +34,7 @@ export function ActionsWrapper({
               accessibilityActions={[
                 {name: 'activate', label: _(msg`Open message options`)},
               ]}
-              onAccessibilityAction={trigger.control.open}>
+              onAccessibilityAction={() => trigger.control.open('full')}>
               {children}
             </View>
           </View>
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 188d18eb7..82113eba8 100644
--- a/src/components/dms/ActionsWrapper.web.tsx
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -4,7 +4,9 @@ import {ChatBskyConvoDefs} from '@atproto/api'
 
 import {atoms as a, useTheme} from '#/alf'
 import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
-import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
+import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
+import {EmojiReactionPicker} from './EmojiReactionPicker'
 
 export function ActionsWrapper({
   message,
@@ -47,10 +49,35 @@ export function ActionsWrapper({
       <View
         style={[
           a.justify_center,
+          a.flex_row,
+          a.align_center,
+          a.gap_xs,
           isFromSelf
-            ? [a.mr_xl, {marginLeft: 'auto'}]
-            : [a.ml_xl, {marginRight: 'auto'}],
+            ? [a.mr_md, {marginLeft: 'auto'}]
+            : [a.ml_md, {marginRight: 'auto'}],
         ]}>
+        <EmojiReactionPicker 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_xs,
+                  a.rounded_full,
+                  (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                ]}>
+                <EmojiSmileIcon
+                  size="md"
+                  style={t.atoms.text_contrast_medium}
+                />
+              </Pressable>
+            )
+          }}
+        </EmojiReactionPicker>
         <MessageContextMenu message={message}>
           {({props, state, isNative, control}) => {
             // always false, file is platform split
@@ -61,11 +88,14 @@ export function ActionsWrapper({
                 {...props}
                 style={[
                   {opacity: showMenuTrigger},
-                  a.p_sm,
+                  a.p_xs,
                   a.rounded_full,
                   (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
                 ]}>
-                <DotsHorizontalIcon size="md" style={t.atoms.text} />
+                <DotsHorizontalIcon
+                  size="md"
+                  style={t.atoms.text_contrast_medium}
+                />
               </Pressable>
             )
           }}
diff --git a/src/components/dms/EmojiPopup.android.tsx b/src/components/dms/EmojiPopup.android.tsx
new file mode 100644
index 000000000..05369cf3e
--- /dev/null
+++ b/src/components/dms/EmojiPopup.android.tsx
@@ -0,0 +1,82 @@
+import {useState} from 'react'
+import {Modal, Pressable, View} from 'react-native'
+// @ts-expect-error internal component, not supposed to be used directly
+// waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753
+import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent'
+import {Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+export function EmojiPopup({
+  children,
+  onEmojiSelected,
+}: {
+  children: React.ReactNode
+  onEmojiSelected: (emoji: string) => void
+}) {
+  const [modalVisible, setModalVisible] = useState(false)
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <>
+      <Pressable
+        accessibilityLabel={_('Open full emoji list')}
+        accessibilityHint=""
+        accessibilityRole="button"
+        onPress={() => setModalVisible(true)}>
+        {children}
+      </Pressable>
+
+      <Modal
+        animationType="slide"
+        transparent={true}
+        visible={modalVisible}
+        onRequestClose={() => setModalVisible(false)}>
+        <View style={[a.flex_1, {backgroundColor: t.palette.white}]}>
+          <View
+            style={[
+              t.atoms.bg,
+              a.pl_lg,
+              a.pr_md,
+              a.py_sm,
+              a.w_full,
+              a.align_center,
+              a.flex_row,
+              a.justify_between,
+              a.border_b,
+              t.atoms.border_contrast_low,
+            ]}>
+            <Text style={[a.font_bold, a.text_md]}>
+              <Trans>Add Reaction</Trans>
+            </Text>
+            <Button
+              label={_('Close')}
+              onPress={() => setModalVisible(false)}
+              size="small"
+              variant="ghost"
+              color="secondary"
+              shape="round">
+              <ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} />
+            </Button>
+          </View>
+          <EmojiPopupView
+            onEmojiSelected={({
+              nativeEvent: {emoji},
+            }: {
+              nativeEvent: {emoji: string}
+            }) => {
+              setModalVisible(false)
+              onEmojiSelected(emoji)
+            }}
+            style={[a.flex_1, a.w_full]}
+          />
+        </View>
+      </Modal>
+    </>
+  )
+}
diff --git a/src/components/dms/EmojiPopup.tsx b/src/components/dms/EmojiPopup.tsx
new file mode 100644
index 000000000..a8f2f83e7
--- /dev/null
+++ b/src/components/dms/EmojiPopup.tsx
@@ -0,0 +1 @@
+export {EmojiPopup} from 'react-native-emoji-popup'
diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx
new file mode 100644
index 000000000..a98cebf9a
--- /dev/null
+++ b/src/components/dms/EmojiReactionPicker.tsx
@@ -0,0 +1,118 @@
+import {useMemo, useState} from 'react'
+import {Alert, useWindowDimensions, View} from 'react-native'
+import {type ChatBskyConvoDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from '#/state/session'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import * as ContextMenu from '#/components/ContextMenu'
+import {
+  useContextMenuContext,
+  useContextMenuMenuContext,
+} from '#/components/ContextMenu/context'
+import {
+  EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon,
+  EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon,
+} from '#/components/icons/Emoji'
+import {type TriggerProps} from '#/components/Menu/types'
+import {Text} from '#/components/Typography'
+import {EmojiPopup} from './EmojiPopup'
+
+export function EmojiReactionPicker({
+  message,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  children?: TriggerProps['children']
+}) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const t = useTheme()
+  const isFromSelf = message.sender?.did === currentAccount?.did
+  const {measurement, close} = useContextMenuContext()
+  const {align} = useContextMenuMenuContext()
+  const [layout, setLayout] = useState({width: 0, height: 0})
+  const {width: screenWidth} = useWindowDimensions()
+
+  // 1 in 100 chance of showing heart eyes icon
+  const EmojiIcon = useMemo(() => {
+    return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon
+  }, [])
+
+  const handleEmojiSelect = (emoji: string) => {
+    Alert.alert(emoji)
+  }
+
+  const position = useMemo(() => {
+    return {
+      x: align === 'left' ? 12 : screenWidth - layout.width - 12,
+      y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
+      height: layout.height,
+      width: layout.width,
+    }
+  }, [measurement, align, screenWidth, layout])
+
+  return (
+    <View
+      onLayout={evt => setLayout(evt.nativeEvent.layout)}
+      style={[
+        a.rounded_full,
+        a.absolute,
+        {bottom: '100%'},
+        isFromSelf ? a.right_0 : a.left_0,
+        t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
+        a.flex_row,
+        a.p_xs,
+        a.gap_xs,
+        a.mb_xs,
+        a.z_20,
+        a.border,
+        t.atoms.border_contrast_low,
+        a.shadow_md,
+      ]}>
+      {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
+        <ContextMenu.Item
+          position={position}
+          label={_(msg`React with ${emoji}`)}
+          key={emoji}
+          onPress={() => handleEmojiSelect(emoji)}
+          unstyled>
+          {hovered => (
+            <View
+              style={[
+                a.rounded_full,
+                hovered && {backgroundColor: t.palette.primary_500},
+                {height: 40, width: 40},
+                a.justify_center,
+                a.align_center,
+              ]}>
+              <Text style={[a.text_center, {fontSize: 30}]} emoji>
+                {emoji}
+              </Text>
+            </View>
+          )}
+        </ContextMenu.Item>
+      ))}
+      <EmojiPopup
+        onEmojiSelected={emoji => {
+          close()
+          handleEmojiSelect(emoji)
+        }}>
+        <View
+          style={[
+            a.rounded_full,
+            t.scheme === 'light'
+              ? t.atoms.bg_contrast_25
+              : t.atoms.bg_contrast_50,
+            {height: 40, width: 40},
+            a.justify_center,
+            a.align_center,
+            a.border,
+            t.atoms.border_contrast_low,
+          ]}>
+          <EmojiIcon size="xl" fill={t.palette.contrast_400} />
+        </View>
+      </EmojiPopup>
+    </View>
+  )
+}
diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx
new file mode 100644
index 000000000..bd51b4fd2
--- /dev/null
+++ b/src/components/dms/EmojiReactionPicker.web.tsx
@@ -0,0 +1,86 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto/api'
+import EmojiPicker from '@emoji-mart/react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web'
+import {PressableWithHover} from '#/view/com/util/PressableWithHover'
+import {atoms as a} from '#/alf'
+import {useTheme} from '#/alf'
+import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
+import * as Menu from '#/components/Menu'
+import {TriggerProps} from '#/components/Menu/types'
+import {Text} from '#/components/Typography'
+
+export function EmojiReactionPicker({
+  children,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  children?: TriggerProps['children']
+}) {
+  if (!children)
+    throw new Error('EmojiReactionPicker requires the children prop on web')
+
+  const {_} = useLingui()
+
+  return (
+    <Menu.Root>
+      <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
+      <Menu.Outer>
+        <MenuInner />
+      </Menu.Outer>
+    </Menu.Root>
+  )
+}
+
+function MenuInner() {
+  const t = useTheme()
+  const {control} = Menu.useMenuContext()
+
+  const [expanded, setExpanded] = useState(false)
+
+  const handleEmojiPickerResponse = (emoji: Emoji) => {
+    handleEmojiSelect(emoji.native)
+  }
+
+  const handleEmojiSelect = (emoji: string) => {
+    control.close()
+    window.alert(emoji)
+  }
+
+  return expanded ? (
+    <EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} />
+  ) : (
+    <View style={[a.flex_row, a.gap_xs]}>
+      {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
+        <PressableWithHover
+          key={emoji}
+          onPress={() => handleEmojiSelect(emoji)}
+          hoverStyle={{backgroundColor: t.palette.primary_100}}
+          style={[
+            a.rounded_xs,
+            {height: 40, width: 40},
+            a.justify_center,
+            a.align_center,
+          ]}>
+          <Text style={[a.text_center, {fontSize: 30}]} emoji>
+            {emoji}
+          </Text>
+        </PressableWithHover>
+      ))}
+      <PressableWithHover
+        onPress={() => setExpanded(true)}
+        hoverStyle={{backgroundColor: t.palette.primary_100}}
+        style={[
+          a.rounded_xs,
+          {height: 40, width: 40},
+          a.justify_center,
+          a.align_center,
+        ]}>
+        <DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
+      </PressableWithHover>
+    </View>
+  )
+}
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index b5542690f..5591bec69 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -1,19 +1,20 @@
 import React from 'react'
 import {LayoutAnimation} from 'react-native'
 import * as Clipboard from 'expo-clipboard'
-import {ChatBskyConvoDefs, RichText} from '@atproto/api'
+import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
 import {msg} from '@lingui/macro'
 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 {isNative} 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 * as ContextMenu from '#/components/ContextMenu'
-import {TriggerProps} from '#/components/ContextMenu/types'
+import {type TriggerProps} from '#/components/ContextMenu/types'
 import {ReportDialog} from '#/components/dms/ReportDialog'
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
@@ -21,6 +22,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
 import * as Prompt from '#/components/Prompt'
 import {usePromptControl} from '#/components/Prompt'
+import {EmojiReactionPicker} from './EmojiReactionPicker'
 
 export let MessageContextMenu = ({
   message,
@@ -77,6 +79,12 @@ export let MessageContextMenu = ({
   return (
     <>
       <ContextMenu.Root>
+        {isNative && (
+          <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
+            <EmojiReactionPicker message={message} />
+          </ContextMenu.AuxiliaryView>
+        )}
+
         <ContextMenu.Trigger
           label={_(msg`Message options`)}
           contentLabel={_(
diff --git a/yarn.lock b/yarn.lock
index 324361e16..f27a845a1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16733,6 +16733,11 @@ react-native-drawer-layout@^4.1.1:
   dependencies:
     use-latest-callback "^0.2.1"
 
+react-native-emoji-popup@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/react-native-emoji-popup/-/react-native-emoji-popup-0.1.2.tgz#7cd3874ba0496031e6f3e24de77e0df895168ce6"
+  integrity sha512-YxuAwubxe6VLNfTyMlpw9g2WQVUIuJb4flWVZjfR7r6fmVvXw4Sxo6ZD6m/fG9AQP3pHkZptzNUr4gdF23m3ZQ==
+
 react-native-gesture-handler@2.20.2:
   version "2.20.2"
   resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66"