diff --git a/src/App.native.tsx b/src/App.native.tsx
index c9342f0469..ac985e5605 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -66,6 +66,7 @@ import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
@@ -128,55 +129,57 @@ function InnerApp() {
return (
-
-
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 959a69c540..af39bee47a 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -56,6 +56,7 @@ import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
@@ -107,54 +108,56 @@ function InnerApp() {
return (
-
-
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/src/components/ContextMenu/Backdrop.ios.tsx b/src/components/ContextMenu/Backdrop.ios.tsx
new file mode 100644
index 0000000000..27a4ed1d87
--- /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
+ intensity?: number
+ onPress?: () => void
+}) {
+ const {_} = useLingui()
+
+ const animatedProps = useAnimatedProps(() => ({
+ intensity: interpolate(
+ animation.get(),
+ [0, 1],
+ [0, intensity],
+ Extrapolation.CLAMP,
+ ),
+ }))
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ContextMenu/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx
new file mode 100644
index 0000000000..857be7c44d
--- /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
+ intensity?: number
+ onPress?: () => void
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: interpolate(
+ animation.get(),
+ [0, 1],
+ [0, intensity / 100],
+ Extrapolation.CLAMP,
+ ),
+ }))
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ContextMenu/context.tsx b/src/components/ContextMenu/context.tsx
new file mode 100644
index 0000000000..213d87a8c3
--- /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(null)
+
+export const ItemContext = React.createContext(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 0000000000..d172935d6c
--- /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 (
+
+ {children}
+
+
+ )
+}
+
+export function Root({children}: {children: React.ReactNode}) {
+ const [measurement, setMeasurement] = useState(null)
+ const animationSV = useSharedValue(0)
+ const translationSV = useSharedValue(0)
+ const isFocused = useIsFocused()
+
+ const clearMeasurement = useCallback(() => setMeasurement(null), [])
+
+ const context = useMemo(
+ () => ({
+ 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 {children}
+}
+
+export function Trigger({children, label, contentLabel, style}: TriggerProps) {
+ const context = useContextMenuContext()
+ const playHaptic = useHaptics()
+ const {top: topInset} = useSafeAreaInsets()
+ const ref = useRef(null)
+ const isFocused = useIsFocused()
+ const [image, setImage] = useState(null)
+ const [pendingMeasurement, setPendingMeasurement] =
+ useState(null)
+
+ const open = useNonReactiveCallback(async () => {
+ playHaptic()
+ Keyboard.dismiss()
+ const [measurement, capture] = await Promise.all([
+ new Promise(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 ''
+ }),
+ ])
+ 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 (
+ <>
+
+
+ {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,
+ },
+ })}
+
+
+ {isFocused && image && measurement && (
+
+ {
+ if (pendingMeasurement) {
+ context.open(pendingMeasurement)
+ setPendingMeasurement(null)
+ }
+ }}
+ />
+
+ )}
+ >
+ )
+}
+
+/**
+ * an image of the underlying trigger with a grow animation
+ */
+function TriggerClone({
+ translation,
+ animation,
+ image,
+ measurement,
+ onDisplay,
+ label,
+}: {
+ translation: SharedValue
+ animation: SharedValue
+ 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 (
+
+
+
+ )
+}
+
+const MENU_WIDTH = 230
+
+export function Outer({
+ children,
+ style,
+ align = 'left',
+}: {
+ children: React.ReactNode
+ style?: StyleProp
+ 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 (
+
+
+
+ {/* containing element - stays the same size, so we measure it
+ to determine if a translation is necessary. also has the positioning */}
+
+ {/* scaling element - has the scale/fade animation on it */}
+
+ {/* 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 */}
+
+ {flattenReactChildren(children).map((child, i) => {
+ return React.isValidElement(child) &&
+ (child.type === Item || child.type === Divider) ? (
+
+ {i > 0 ? (
+
+ ) : null}
+ {React.cloneElement(child, {
+ // @ts-expect-error not typed
+ style: {
+ borderRadius: 0,
+ borderWidth: 0,
+ },
+ })}
+
+ ) : null
+ })}
+
+
+
+
+
+ )
+}
+
+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 (
+ {
+ 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],
+ ]}>
+
+ {children}
+
+
+ )
+}
+
+export function ItemText({children, style}: ItemTextProps) {
+ const t = useTheme()
+ const {disabled} = useContextMenuItemContext()
+ return (
+
+ {children}
+
+ )
+}
+
+export function ItemIcon({icon: Comp}: ItemIconProps) {
+ const t = useTheme()
+ const {disabled} = useContextMenuItemContext()
+ return (
+
+ )
+}
+
+export function ItemRadio({selected}: {selected: boolean}) {
+ const t = useTheme()
+ return (
+
+ {selected ? (
+
+ ) : null}
+
+ )
+}
+
+export function LabelText({children}: {children: React.ReactNode}) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
+export function Divider() {
+ const t = useTheme()
+ return (
+
+ )
+}
diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx
new file mode 100644
index 0000000000..f7e3b0c34f
--- /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 0000000000..0b3fedc55e
--- /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
+ /* Translation in Y axis to ensure everything's onscreen */
+ translationSV: SharedValue
+ 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
+}
+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 908ad352ea..d810a03de4 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({
- // @ts-ignore
- control: null,
-})
+export const Context = React.createContext(null)
-export const ItemContext = React.createContext({
- disabled: false,
-})
+export const ItemContext = React.createContext(null)
export function useMenuContext() {
const context = React.useContext(Context)
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 06b9e7e557..a843177716 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(
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 7bf4dde181..07339ef08c 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 a087fed3fb..385086d7cd 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 (
-
-
- {children}
-
-
-
+
+ {trigger =>
+ // will always be true, since this file is platform split
+ trigger.isNative && (
+
+ {/* {isNative && (
+
+ {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
+
+ {emoji}
+
+ ))}
+
+ )} */}
+
+ {children}
+
+
+ )
+ }
+
)
}
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 29cc89dd18..188d18eb7b 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 && (
-
-
-
- )}
+ style={[
+ a.justify_center,
+ isFromSelf
+ ? [a.mr_xl, {marginLeft: 'auto'}]
+ : [a.ml_xl, {marginRight: 'auto'}],
+ ]}>
+
+ {({props, state, isNative, control}) => {
+ // always false, file is platform split
+ if (isNative) return null
+ const showMenuTrigger = showActions || control.isOpen ? 1 : 0
+ return (
+
+
+
+ )
+ }}
+
+
+
{children}
- {!isFromSelf && (
-
-
-
- )}
)
}
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageContextMenu.tsx
similarity index 53%
rename from src/components/dms/MessageMenu.tsx
rename to src/components/dms/MessageContextMenu.tsx
index cff5f9dd4e..b5542690f0 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 (
<>
-
- {isWeb && (
-
-
- {({props, state}) => (
-
-
-
- )}
-
-
- )}
+
+
+ {children}
+
-
+
{message.text.length > 0 && (
<>
-
-
- {_(msg`Translate`)}
-
-
-
- {_(msg`Copy message text`)}
-
-
-
-
+
+ {_(msg`Translate`)}
+
+
+
+
+ {_(msg`Copy message text`)}
+
+
+
+
>
)}
-
- deleteControl.open()}>
- {_(msg`Delete for me`)}
-
-
- {!isFromSelf && (
- reportControl.open()}>
- {_(msg`Report`)}
-
-
- )}
-
-
-
+ deleteControl.open()}>
+ {_(msg`Delete for me`)}
+
+
+ {!isFromSelf && (
+ reportControl.open()}>
+ {_(msg`Report`)}
+
+
+ )}
+
+
)
}
-MessageMenu = React.memo(MessageMenu)
+MessageContextMenu = React.memo(MessageContextMenu)
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index f9eb4d3af7..f1c6189d06 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 (
-
-
+
+
+
+
)