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