diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/ContextMenu/Backdrop.ios.tsx | 54 | ||||
-rw-r--r-- | src/components/ContextMenu/Backdrop.tsx | 8 | ||||
-rw-r--r-- | src/components/ContextMenu/index.tsx | 414 | ||||
-rw-r--r-- | src/components/ContextMenu/index.web.tsx | 7 | ||||
-rw-r--r-- | src/components/ContextMenu/types.ts | 40 | ||||
-rw-r--r-- | src/components/Menu/index.tsx | 2 | ||||
-rw-r--r-- | src/components/Menu/index.web.tsx | 2 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.tsx | 24 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.web.tsx | 40 | ||||
-rw-r--r-- | src/components/dms/EmojiPopup.android.tsx | 82 | ||||
-rw-r--r-- | src/components/dms/EmojiPopup.tsx | 1 | ||||
-rw-r--r-- | src/components/dms/EmojiReactionPicker.tsx | 118 | ||||
-rw-r--r-- | src/components/dms/EmojiReactionPicker.web.tsx | 86 | ||||
-rw-r--r-- | src/components/dms/MessageContextMenu.tsx | 12 |
14 files changed, 692 insertions, 198 deletions
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={_( |