Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Samuel Newman
Eric Bailey
and committed by
GitHub
c4785ef9 f6f253b4

+1182 -387
+52 -49
src/App.native.tsx
··· 66 66 import {Shell} from '#/view/shell' 67 67 import {ThemeProvider as Alf} from '#/alf' 68 68 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' 69 + import {Provider as ContextMenuProvider} from '#/components/ContextMenu' 69 70 import {NuxDialogs} from '#/components/dialogs/nuxs' 70 71 import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' 71 72 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' ··· 128 129 return ( 129 130 <Alf theme={theme}> 130 131 <ThemeProvider theme={theme}> 131 - <Splash isReady={isReady && hasCheckedReferrer}> 132 - <RootSiblingParent> 133 - <VideoVolumeProvider> 134 - <React.Fragment 135 - // Resets the entire tree below when it changes: 136 - key={currentAccount?.did}> 137 - <QueryProvider currentDid={currentAccount?.did}> 138 - <ComposerProvider> 139 - <StatsigProvider> 140 - <MessagesProvider> 141 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 142 - <LabelDefsProvider> 143 - <ModerationOptsProvider> 144 - <LoggedOutViewProvider> 145 - <SelectedFeedProvider> 146 - <HiddenRepliesProvider> 147 - <HomeBadgeProvider> 148 - <UnreadNotifsProvider> 149 - <BackgroundNotificationPreferencesProvider> 150 - <MutedThreadsProvider> 151 - <ProgressGuideProvider> 152 - <TrendingConfigProvider> 153 - <GestureHandlerRootView 154 - style={s.h100pct}> 155 - <IntentDialogProvider> 156 - <TestCtrls /> 157 - <Shell /> 158 - <NuxDialogs /> 159 - </IntentDialogProvider> 160 - </GestureHandlerRootView> 161 - </TrendingConfigProvider> 162 - </ProgressGuideProvider> 163 - </MutedThreadsProvider> 164 - </BackgroundNotificationPreferencesProvider> 165 - </UnreadNotifsProvider> 166 - </HomeBadgeProvider> 167 - </HiddenRepliesProvider> 168 - </SelectedFeedProvider> 169 - </LoggedOutViewProvider> 170 - </ModerationOptsProvider> 171 - </LabelDefsProvider> 172 - </MessagesProvider> 173 - </StatsigProvider> 174 - </ComposerProvider> 175 - </QueryProvider> 176 - </React.Fragment> 177 - </VideoVolumeProvider> 178 - </RootSiblingParent> 179 - </Splash> 132 + <ContextMenuProvider> 133 + <Splash isReady={isReady && hasCheckedReferrer}> 134 + <RootSiblingParent> 135 + <VideoVolumeProvider> 136 + <React.Fragment 137 + // Resets the entire tree below when it changes: 138 + key={currentAccount?.did}> 139 + <QueryProvider currentDid={currentAccount?.did}> 140 + <ComposerProvider> 141 + <StatsigProvider> 142 + <MessagesProvider> 143 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 144 + <LabelDefsProvider> 145 + <ModerationOptsProvider> 146 + <LoggedOutViewProvider> 147 + <SelectedFeedProvider> 148 + <HiddenRepliesProvider> 149 + <HomeBadgeProvider> 150 + <UnreadNotifsProvider> 151 + <BackgroundNotificationPreferencesProvider> 152 + <MutedThreadsProvider> 153 + <ProgressGuideProvider> 154 + <TrendingConfigProvider> 155 + <GestureHandlerRootView 156 + style={s.h100pct}> 157 + <IntentDialogProvider> 158 + <TestCtrls /> 159 + <Shell /> 160 + <NuxDialogs /> 161 + </IntentDialogProvider> 162 + </GestureHandlerRootView> 163 + </TrendingConfigProvider> 164 + </ProgressGuideProvider> 165 + </MutedThreadsProvider> 166 + </BackgroundNotificationPreferencesProvider> 167 + </UnreadNotifsProvider> 168 + </HomeBadgeProvider> 169 + </HiddenRepliesProvider> 170 + </SelectedFeedProvider> 171 + </LoggedOutViewProvider> 172 + </ModerationOptsProvider> 173 + </LabelDefsProvider> 174 + </MessagesProvider> 175 + </StatsigProvider> 176 + </ComposerProvider> 177 + </QueryProvider> 178 + </React.Fragment> 179 + </VideoVolumeProvider> 180 + </RootSiblingParent> 181 + </Splash> 182 + </ContextMenuProvider> 180 183 </ThemeProvider> 181 184 </Alf> 182 185 )
+51 -48
src/App.web.tsx
··· 56 56 import {Shell} from '#/view/shell/index' 57 57 import {ThemeProvider as Alf} from '#/alf' 58 58 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' 59 + import {Provider as ContextMenuProvider} from '#/components/ContextMenu' 59 60 import {NuxDialogs} from '#/components/dialogs/nuxs' 60 61 import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' 61 62 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' ··· 107 108 return ( 108 109 <Alf theme={theme}> 109 110 <ThemeProvider theme={theme}> 110 - <RootSiblingParent> 111 - <VideoVolumeProvider> 112 - <ActiveVideoProvider> 113 - <React.Fragment 114 - // Resets the entire tree below when it changes: 115 - key={currentAccount?.did}> 116 - <QueryProvider currentDid={currentAccount?.did}> 117 - <ComposerProvider> 118 - <StatsigProvider> 119 - <MessagesProvider> 120 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 121 - <LabelDefsProvider> 122 - <ModerationOptsProvider> 123 - <LoggedOutViewProvider> 124 - <SelectedFeedProvider> 125 - <HiddenRepliesProvider> 126 - <HomeBadgeProvider> 127 - <UnreadNotifsProvider> 128 - <BackgroundNotificationPreferencesProvider> 129 - <MutedThreadsProvider> 130 - <SafeAreaProvider> 131 - <ProgressGuideProvider> 132 - <TrendingConfigProvider> 133 - <IntentDialogProvider> 134 - <Shell /> 135 - <NuxDialogs /> 136 - </IntentDialogProvider> 137 - </TrendingConfigProvider> 138 - </ProgressGuideProvider> 139 - </SafeAreaProvider> 140 - </MutedThreadsProvider> 141 - </BackgroundNotificationPreferencesProvider> 142 - </UnreadNotifsProvider> 143 - </HomeBadgeProvider> 144 - </HiddenRepliesProvider> 145 - </SelectedFeedProvider> 146 - </LoggedOutViewProvider> 147 - </ModerationOptsProvider> 148 - </LabelDefsProvider> 149 - </MessagesProvider> 150 - </StatsigProvider> 151 - </ComposerProvider> 152 - </QueryProvider> 153 - <ToastContainer /> 154 - </React.Fragment> 155 - </ActiveVideoProvider> 156 - </VideoVolumeProvider> 157 - </RootSiblingParent> 111 + <ContextMenuProvider> 112 + <RootSiblingParent> 113 + <VideoVolumeProvider> 114 + <ActiveVideoProvider> 115 + <React.Fragment 116 + // Resets the entire tree below when it changes: 117 + key={currentAccount?.did}> 118 + <QueryProvider currentDid={currentAccount?.did}> 119 + <ComposerProvider> 120 + <StatsigProvider> 121 + <MessagesProvider> 122 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 123 + <LabelDefsProvider> 124 + <ModerationOptsProvider> 125 + <LoggedOutViewProvider> 126 + <SelectedFeedProvider> 127 + <HiddenRepliesProvider> 128 + <HomeBadgeProvider> 129 + <UnreadNotifsProvider> 130 + <BackgroundNotificationPreferencesProvider> 131 + <MutedThreadsProvider> 132 + <SafeAreaProvider> 133 + <ProgressGuideProvider> 134 + <TrendingConfigProvider> 135 + <IntentDialogProvider> 136 + <Shell /> 137 + <NuxDialogs /> 138 + </IntentDialogProvider> 139 + </TrendingConfigProvider> 140 + </ProgressGuideProvider> 141 + </SafeAreaProvider> 142 + </MutedThreadsProvider> 143 + </BackgroundNotificationPreferencesProvider> 144 + </UnreadNotifsProvider> 145 + </HomeBadgeProvider> 146 + </HiddenRepliesProvider> 147 + </SelectedFeedProvider> 148 + </LoggedOutViewProvider> 149 + </ModerationOptsProvider> 150 + </LabelDefsProvider> 151 + </MessagesProvider> 152 + </StatsigProvider> 153 + </ComposerProvider> 154 + </QueryProvider> 155 + <ToastContainer /> 156 + </React.Fragment> 157 + </ActiveVideoProvider> 158 + </VideoVolumeProvider> 159 + </RootSiblingParent> 160 + </ContextMenuProvider> 158 161 </ThemeProvider> 159 162 </Alf> 160 163 )
+49
src/components/ContextMenu/Backdrop.ios.tsx
··· 1 + import {Pressable} from 'react-native' 2 + import Animated, { 3 + Extrapolation, 4 + interpolate, 5 + SharedValue, 6 + useAnimatedProps, 7 + } from 'react-native-reanimated' 8 + import {BlurView} from 'expo-blur' 9 + import {msg} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {atoms as a} from '#/alf' 13 + 14 + const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) 15 + 16 + export function Backdrop({ 17 + animation, 18 + intensity = 50, 19 + onPress, 20 + }: { 21 + animation: SharedValue<number> 22 + intensity?: number 23 + onPress?: () => void 24 + }) { 25 + const {_} = useLingui() 26 + 27 + const animatedProps = useAnimatedProps(() => ({ 28 + intensity: interpolate( 29 + animation.get(), 30 + [0, 1], 31 + [0, intensity], 32 + Extrapolation.CLAMP, 33 + ), 34 + })) 35 + 36 + return ( 37 + <AnimatedBlurView 38 + animatedProps={animatedProps} 39 + style={[a.absolute, a.inset_0]} 40 + tint="systemThinMaterialDark"> 41 + <Pressable 42 + style={a.flex_1} 43 + accessibilityLabel={_(msg`Close menu`)} 44 + accessibilityHint={_(msg`Tap to close context menu`)} 45 + onPress={onPress} 46 + /> 47 + </AnimatedBlurView> 48 + ) 49 + }
+45
src/components/ContextMenu/Backdrop.tsx
··· 1 + import {Pressable} from 'react-native' 2 + import Animated, { 3 + Extrapolation, 4 + interpolate, 5 + SharedValue, 6 + useAnimatedStyle, 7 + } from 'react-native-reanimated' 8 + import {msg} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {atoms as a, useTheme} from '#/alf' 12 + 13 + export function Backdrop({ 14 + animation, 15 + intensity = 50, 16 + onPress, 17 + }: { 18 + animation: SharedValue<number> 19 + intensity?: number 20 + onPress?: () => void 21 + }) { 22 + const t = useTheme() 23 + const {_} = useLingui() 24 + 25 + const animatedStyle = useAnimatedStyle(() => ({ 26 + opacity: interpolate( 27 + animation.get(), 28 + [0, 1], 29 + [0, intensity / 100], 30 + Extrapolation.CLAMP, 31 + ), 32 + })) 33 + 34 + return ( 35 + <Animated.View 36 + style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}> 37 + <Pressable 38 + style={a.flex_1} 39 + accessibilityLabel={_(msg`Close menu`)} 40 + accessibilityHint={_(msg`Tap to close context menu`)} 41 + onPress={onPress} 42 + /> 43 + </Animated.View> 44 + ) 45 + }
+31
src/components/ContextMenu/context.tsx
··· 1 + import React from 'react' 2 + 3 + import type {ContextType, ItemContextType} from '#/components/ContextMenu/types' 4 + 5 + export const Context = React.createContext<ContextType | null>(null) 6 + 7 + export const ItemContext = React.createContext<ItemContextType | null>(null) 8 + 9 + export function useContextMenuContext() { 10 + const context = React.useContext(Context) 11 + 12 + if (!context) { 13 + throw new Error( 14 + 'useContextMenuContext must be used within a Context.Provider', 15 + ) 16 + } 17 + 18 + return context 19 + } 20 + 21 + export function useContextMenuItemContext() { 22 + const context = React.useContext(ItemContext) 23 + 24 + if (!context) { 25 + throw new Error( 26 + 'useContextMenuItemContext must be used within a Context.Provider', 27 + ) 28 + } 29 + 30 + return context 31 + }
+591
src/components/ContextMenu/index.tsx
··· 1 + import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 + import { 3 + BackHandler, 4 + Keyboard, 5 + LayoutChangeEvent, 6 + Pressable, 7 + StyleProp, 8 + View, 9 + ViewStyle, 10 + } from 'react-native' 11 + import {Gesture, GestureDetector} from 'react-native-gesture-handler' 12 + import Animated, { 13 + clamp, 14 + interpolate, 15 + runOnJS, 16 + SharedValue, 17 + useAnimatedStyle, 18 + useSharedValue, 19 + withSpring, 20 + WithSpringConfig, 21 + } from 'react-native-reanimated' 22 + import { 23 + useSafeAreaFrame, 24 + useSafeAreaInsets, 25 + } from 'react-native-safe-area-context' 26 + import {captureRef} from 'react-native-view-shot' 27 + import {Image, ImageErrorEventData} from 'expo-image' 28 + import {msg} from '@lingui/macro' 29 + import {useLingui} from '@lingui/react' 30 + import {useIsFocused} from '@react-navigation/native' 31 + import flattenReactChildren from 'react-keyed-flatten-children' 32 + 33 + import {HITSLOP_10} from '#/lib/constants' 34 + import {useHaptics} from '#/lib/haptics' 35 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 36 + import {logger} from '#/logger' 37 + import {isAndroid, isIOS} from '#/platform/detection' 38 + import {atoms as a, platform, useTheme} from '#/alf' 39 + import { 40 + Context, 41 + ItemContext, 42 + useContextMenuContext, 43 + useContextMenuItemContext, 44 + } from '#/components/ContextMenu/context' 45 + import { 46 + ContextType, 47 + ItemIconProps, 48 + ItemProps, 49 + ItemTextProps, 50 + Measurement, 51 + TriggerProps, 52 + } from '#/components/ContextMenu/types' 53 + import {useInteractionState} from '#/components/hooks/useInteractionState' 54 + import {createPortalGroup} from '#/components/Portal' 55 + import {Text} from '#/components/Typography' 56 + import {Backdrop} from './Backdrop' 57 + 58 + export { 59 + type DialogControlProps as ContextMenuControlProps, 60 + useDialogControl as useContextMenuControl, 61 + } from '#/components/Dialog' 62 + 63 + const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() 64 + 65 + const SPRING: WithSpringConfig = { 66 + mass: isIOS ? 1.25 : 0.75, 67 + damping: 150, 68 + stiffness: 1000, 69 + restDisplacementThreshold: 0.01, 70 + } 71 + 72 + /** 73 + * Needs placing near the top of the provider stack, but BELOW the theme provider. 74 + */ 75 + export function Provider({children}: {children: React.ReactNode}) { 76 + return ( 77 + <PortalProvider> 78 + {children} 79 + <Outlet /> 80 + </PortalProvider> 81 + ) 82 + } 83 + 84 + export function Root({children}: {children: React.ReactNode}) { 85 + const [measurement, setMeasurement] = useState<Measurement | null>(null) 86 + const animationSV = useSharedValue(0) 87 + const translationSV = useSharedValue(0) 88 + const isFocused = useIsFocused() 89 + 90 + const clearMeasurement = useCallback(() => setMeasurement(null), []) 91 + 92 + const context = useMemo<ContextType>( 93 + () => ({ 94 + isOpen: !!measurement && isFocused, 95 + measurement, 96 + animationSV, 97 + translationSV, 98 + open: (evt: Measurement) => { 99 + setMeasurement(evt) 100 + animationSV.set(withSpring(1, SPRING)) 101 + }, 102 + close: () => { 103 + animationSV.set( 104 + withSpring(0, SPRING, finished => { 105 + if (finished) { 106 + translationSV.set(0) 107 + runOnJS(clearMeasurement)() 108 + } 109 + }), 110 + ) 111 + }, 112 + }), 113 + [ 114 + measurement, 115 + setMeasurement, 116 + isFocused, 117 + animationSV, 118 + translationSV, 119 + clearMeasurement, 120 + ], 121 + ) 122 + 123 + useEffect(() => { 124 + if (isAndroid && context.isOpen) { 125 + const listener = BackHandler.addEventListener('hardwareBackPress', () => { 126 + context.close() 127 + return true 128 + }) 129 + 130 + return () => listener.remove() 131 + } 132 + }, [context]) 133 + 134 + return <Context.Provider value={context}>{children}</Context.Provider> 135 + } 136 + 137 + export function Trigger({children, label, contentLabel, style}: TriggerProps) { 138 + const context = useContextMenuContext() 139 + const playHaptic = useHaptics() 140 + const {top: topInset} = useSafeAreaInsets() 141 + const ref = useRef<View>(null) 142 + const isFocused = useIsFocused() 143 + const [image, setImage] = useState<string | null>(null) 144 + const [pendingMeasurement, setPendingMeasurement] = 145 + useState<Measurement | null>(null) 146 + 147 + const open = useNonReactiveCallback(async () => { 148 + playHaptic() 149 + Keyboard.dismiss() 150 + const [measurement, capture] = await Promise.all([ 151 + new Promise<Measurement>(resolve => { 152 + ref.current?.measureInWindow((x, y, width, height) => 153 + resolve({ 154 + x, 155 + y: 156 + y + 157 + platform({ 158 + default: 0, 159 + android: topInset, // not included in measurement 160 + }), 161 + width, 162 + height, 163 + }), 164 + ) 165 + }), 166 + captureRef(ref, {result: 'data-uri'}).catch(err => { 167 + logger.error(err instanceof Error ? err : String(err), { 168 + message: 'Failed to capture image of context menu trigger', 169 + }) 170 + // will cause the image to fail to load, but it will get handled gracefully 171 + return '<failed capture>' 172 + }), 173 + ]) 174 + setImage(capture) 175 + setPendingMeasurement(measurement) 176 + }) 177 + 178 + const doubleTapGesture = useMemo(() => { 179 + return Gesture.Tap() 180 + .numberOfTaps(2) 181 + .hitSlop(HITSLOP_10) 182 + .onEnd(open) 183 + .runOnJS(true) 184 + }, [open]) 185 + 186 + const pressAndHoldGesture = useMemo(() => { 187 + return Gesture.LongPress() 188 + .onStart(() => { 189 + runOnJS(open)() 190 + }) 191 + .cancelsTouchesInView(false) 192 + }, [open]) 193 + 194 + const composedGestures = Gesture.Exclusive( 195 + doubleTapGesture, 196 + pressAndHoldGesture, 197 + ) 198 + 199 + const {translationSV, animationSV} = context 200 + 201 + const measurement = context.measurement || pendingMeasurement 202 + 203 + return ( 204 + <> 205 + <GestureDetector gesture={composedGestures}> 206 + <View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}> 207 + {children({ 208 + isNative: true, 209 + control: {isOpen: context.isOpen, open}, 210 + state: { 211 + pressed: false, 212 + hovered: false, 213 + focused: false, 214 + }, 215 + props: { 216 + ref: null, 217 + onPress: null, 218 + onFocus: null, 219 + onBlur: null, 220 + onPressIn: null, 221 + onPressOut: null, 222 + accessibilityHint: null, 223 + accessibilityLabel: label, 224 + accessibilityRole: null, 225 + }, 226 + })} 227 + </View> 228 + </GestureDetector> 229 + {isFocused && image && measurement && ( 230 + <Portal> 231 + <TriggerClone 232 + label={contentLabel} 233 + translation={translationSV} 234 + animation={animationSV} 235 + image={image} 236 + measurement={measurement} 237 + onDisplay={() => { 238 + if (pendingMeasurement) { 239 + context.open(pendingMeasurement) 240 + setPendingMeasurement(null) 241 + } 242 + }} 243 + /> 244 + </Portal> 245 + )} 246 + </> 247 + ) 248 + } 249 + 250 + /** 251 + * an image of the underlying trigger with a grow animation 252 + */ 253 + function TriggerClone({ 254 + translation, 255 + animation, 256 + image, 257 + measurement, 258 + onDisplay, 259 + label, 260 + }: { 261 + translation: SharedValue<number> 262 + animation: SharedValue<number> 263 + image: string 264 + measurement: Measurement 265 + onDisplay: () => void 266 + label: string 267 + }) { 268 + const {_} = useLingui() 269 + 270 + const animatedStyles = useAnimatedStyle(() => ({ 271 + transform: [{translateY: translation.get() * animation.get()}], 272 + })) 273 + 274 + const handleError = useCallback( 275 + (evt: ImageErrorEventData) => { 276 + logger.error('Context menu image load error', {message: evt.error}) 277 + onDisplay() 278 + }, 279 + [onDisplay], 280 + ) 281 + 282 + return ( 283 + <Animated.View 284 + style={[ 285 + a.absolute, 286 + { 287 + top: measurement.y, 288 + left: measurement.x, 289 + width: measurement.width, 290 + height: measurement.height, 291 + }, 292 + a.z_10, 293 + a.pointer_events_none, 294 + animatedStyles, 295 + ]}> 296 + <Image 297 + onDisplay={onDisplay} 298 + onError={handleError} 299 + source={image} 300 + style={{ 301 + width: measurement.width, 302 + height: measurement.height, 303 + }} 304 + accessibilityLabel={label} 305 + accessibilityHint={_(msg`The subject of the context menu`)} 306 + accessibilityIgnoresInvertColors={false} 307 + /> 308 + </Animated.View> 309 + ) 310 + } 311 + 312 + const MENU_WIDTH = 230 313 + 314 + export function Outer({ 315 + children, 316 + style, 317 + align = 'left', 318 + }: { 319 + children: React.ReactNode 320 + style?: StyleProp<ViewStyle> 321 + align?: 'left' | 'right' 322 + }) { 323 + const t = useTheme() 324 + const context = useContextMenuContext() 325 + const insets = useSafeAreaInsets() 326 + const frame = useSafeAreaFrame() 327 + 328 + const {animationSV, translationSV} = context 329 + 330 + const animatedContainerStyle = useAnimatedStyle(() => ({ 331 + transform: [{translateY: translationSV.get() * animationSV.get()}], 332 + })) 333 + 334 + const animatedStyle = useAnimatedStyle(() => ({ 335 + opacity: clamp(animationSV.get(), 0, 1), 336 + transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}], 337 + })) 338 + 339 + const onLayout = useCallback( 340 + (evt: LayoutChangeEvent) => { 341 + if (!context.measurement) return // should not happen 342 + let translation = 0 343 + 344 + // pure vibes based 345 + const TOP_INSET = insets.top + 80 346 + const BOTTOM_INSET_IOS = insets.bottom + 20 347 + const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn 348 + 349 + const {height} = evt.nativeEvent.layout 350 + const topPosition = context.measurement.y + context.measurement.height + 4 351 + const bottomPosition = topPosition + height 352 + const safeAreaBottomLimit = 353 + frame.height - 354 + platform({ 355 + ios: BOTTOM_INSET_IOS, 356 + android: BOTTOM_INSET_ANDROID, 357 + default: 0, 358 + }) 359 + const diff = bottomPosition - safeAreaBottomLimit 360 + if (diff > 0) { 361 + translation = -diff 362 + } else { 363 + const distanceMessageFromTop = context.measurement.y - TOP_INSET 364 + if (distanceMessageFromTop < 0) { 365 + translation = -Math.max(distanceMessageFromTop, diff) 366 + } 367 + } 368 + 369 + if (translation !== 0) { 370 + translationSV.set(translation) 371 + } 372 + }, 373 + [context.measurement, frame.height, insets, translationSV], 374 + ) 375 + 376 + if (!context.isOpen || !context.measurement) return null 377 + 378 + return ( 379 + <Portal> 380 + <Context.Provider value={context}> 381 + <Backdrop animation={animationSV} onPress={context.close} /> 382 + {/* containing element - stays the same size, so we measure it 383 + to determine if a translation is necessary. also has the positioning */} 384 + <Animated.View 385 + onLayout={onLayout} 386 + style={[ 387 + a.absolute, 388 + a.z_10, 389 + a.mt_xs, 390 + { 391 + width: MENU_WIDTH, 392 + top: context.measurement.y + context.measurement.height, 393 + }, 394 + align === 'left' 395 + ? {left: context.measurement.x} 396 + : { 397 + right: 398 + frame.x + 399 + frame.width - 400 + context.measurement.x - 401 + context.measurement.width, 402 + }, 403 + animatedContainerStyle, 404 + ]}> 405 + {/* scaling element - has the scale/fade animation on it */} 406 + <Animated.View 407 + style={[ 408 + a.rounded_md, 409 + a.shadow_md, 410 + t.atoms.bg_contrast_25, 411 + a.w_full, 412 + // @ts-ignore react-native-web expects string, and this file is platform-split -sfn 413 + // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error 414 + // in the typecheck CI - presumably because of RNW overriding the types 415 + { 416 + transformOrigin: 417 + align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], 418 + }, 419 + animatedStyle, 420 + style, 421 + ]}> 422 + {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, 423 + so put the shadow on the scaling element and the overflow on the innermost element */} 424 + <View 425 + style={[ 426 + a.flex_1, 427 + a.rounded_md, 428 + a.overflow_hidden, 429 + a.border, 430 + t.atoms.border_contrast_low, 431 + ]}> 432 + {flattenReactChildren(children).map((child, i) => { 433 + return React.isValidElement(child) && 434 + (child.type === Item || child.type === Divider) ? ( 435 + <React.Fragment key={i}> 436 + {i > 0 ? ( 437 + <View style={[a.border_b, t.atoms.border_contrast_low]} /> 438 + ) : null} 439 + {React.cloneElement(child, { 440 + // @ts-expect-error not typed 441 + style: { 442 + borderRadius: 0, 443 + borderWidth: 0, 444 + }, 445 + })} 446 + </React.Fragment> 447 + ) : null 448 + })} 449 + </View> 450 + </Animated.View> 451 + </Animated.View> 452 + </Context.Provider> 453 + </Portal> 454 + ) 455 + } 456 + 457 + export function Item({children, label, style, onPress, ...rest}: ItemProps) { 458 + const t = useTheme() 459 + const context = useContextMenuContext() 460 + const playHaptic = useHaptics() 461 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 462 + const { 463 + state: pressed, 464 + onIn: onPressIn, 465 + onOut: onPressOut, 466 + } = useInteractionState() 467 + 468 + return ( 469 + <Pressable 470 + {...rest} 471 + accessibilityHint="" 472 + accessibilityLabel={label} 473 + onFocus={onFocus} 474 + onBlur={onBlur} 475 + onPress={e => { 476 + context.close() 477 + onPress?.(e) 478 + }} 479 + onPressIn={e => { 480 + onPressIn() 481 + rest.onPressIn?.(e) 482 + playHaptic('Light') 483 + }} 484 + onPressOut={e => { 485 + onPressOut() 486 + rest.onPressOut?.(e) 487 + }} 488 + style={[ 489 + a.flex_row, 490 + a.align_center, 491 + a.gap_sm, 492 + a.py_sm, 493 + a.px_md, 494 + a.rounded_md, 495 + a.border, 496 + t.atoms.bg_contrast_25, 497 + t.atoms.border_contrast_low, 498 + {minHeight: 40}, 499 + style, 500 + (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50], 501 + ]}> 502 + <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}> 503 + {children} 504 + </ItemContext.Provider> 505 + </Pressable> 506 + ) 507 + } 508 + 509 + export function ItemText({children, style}: ItemTextProps) { 510 + const t = useTheme() 511 + const {disabled} = useContextMenuItemContext() 512 + return ( 513 + <Text 514 + numberOfLines={2} 515 + ellipsizeMode="middle" 516 + style={[ 517 + a.flex_1, 518 + a.text_sm, 519 + a.font_bold, 520 + t.atoms.text_contrast_high, 521 + {paddingTop: 3}, 522 + style, 523 + disabled && t.atoms.text_contrast_low, 524 + ]}> 525 + {children} 526 + </Text> 527 + ) 528 + } 529 + 530 + export function ItemIcon({icon: Comp}: ItemIconProps) { 531 + const t = useTheme() 532 + const {disabled} = useContextMenuItemContext() 533 + return ( 534 + <Comp 535 + size="md" 536 + fill={ 537 + disabled 538 + ? t.atoms.text_contrast_low.color 539 + : t.atoms.text_contrast_medium.color 540 + } 541 + /> 542 + ) 543 + } 544 + 545 + export function ItemRadio({selected}: {selected: boolean}) { 546 + const t = useTheme() 547 + return ( 548 + <View 549 + style={[ 550 + a.justify_center, 551 + a.align_center, 552 + a.rounded_full, 553 + t.atoms.border_contrast_high, 554 + { 555 + borderWidth: 1, 556 + height: 20, 557 + width: 20, 558 + }, 559 + ]}> 560 + {selected ? ( 561 + <View 562 + style={[ 563 + a.absolute, 564 + a.rounded_full, 565 + {height: 14, width: 14}, 566 + selected ? {backgroundColor: t.palette.primary_500} : {}, 567 + ]} 568 + /> 569 + ) : null} 570 + </View> 571 + ) 572 + } 573 + 574 + export function LabelText({children}: {children: React.ReactNode}) { 575 + const t = useTheme() 576 + return ( 577 + <Text 578 + style={[a.font_bold, t.atoms.text_contrast_medium, {marginBottom: -8}]}> 579 + {children} 580 + </Text> 581 + ) 582 + } 583 + 584 + export function Divider() { 585 + const t = useTheme() 586 + return ( 587 + <View 588 + style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]} 589 + /> 590 + ) 591 + }
+5
src/components/ContextMenu/index.web.tsx
··· 1 + export * from '#/components/Menu' 2 + 3 + export function Provider({children}: {children: React.ReactNode}) { 4 + return children 5 + }
+97
src/components/ContextMenu/types.ts
··· 1 + import React from 'react' 2 + import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native' 3 + import {SharedValue} from 'react-native-reanimated' 4 + 5 + import * as Dialog from '#/components/Dialog' 6 + import {RadixPassThroughTriggerProps} from '#/components/Menu/types' 7 + 8 + export type { 9 + GroupProps, 10 + ItemIconProps, 11 + ItemProps, 12 + ItemTextProps, 13 + } from '#/components/Menu/types' 14 + 15 + export type Measurement = { 16 + x: number 17 + y: number 18 + width: number 19 + height: number 20 + } 21 + 22 + export type ContextType = { 23 + isOpen: boolean 24 + measurement: Measurement | null 25 + /* Spring animation between 0 and 1 */ 26 + animationSV: SharedValue<number> 27 + /* Translation in Y axis to ensure everything's onscreen */ 28 + translationSV: SharedValue<number> 29 + open: (evt: Measurement) => void 30 + close: () => void 31 + } 32 + 33 + export type ItemContextType = { 34 + disabled: boolean 35 + } 36 + 37 + export type TriggerProps = { 38 + children(props: TriggerChildProps): React.ReactNode 39 + label: string 40 + /** 41 + * When activated, this is the accessibility label for the entire thing that has been triggered. 42 + * For example, if the trigger is a message bubble, use the message content. 43 + * 44 + * @platform ios, android 45 + */ 46 + contentLabel: string 47 + hint?: string 48 + role?: AccessibilityRole 49 + style?: StyleProp<ViewStyle> 50 + } 51 + export type TriggerChildProps = 52 + | { 53 + isNative: true 54 + control: {isOpen: boolean; open: () => void} 55 + state: { 56 + hovered: false 57 + focused: false 58 + pressed: false 59 + } 60 + /** 61 + * We don't necessarily know what these will be spread on to, so we 62 + * should add props one-by-one. 63 + * 64 + * On web, these properties are applied to a parent `Pressable`, so this 65 + * object is empty. 66 + */ 67 + props: { 68 + ref: null 69 + onPress: null 70 + onFocus: null 71 + onBlur: null 72 + onPressIn: null 73 + onPressOut: null 74 + accessibilityHint: null 75 + accessibilityLabel: string 76 + accessibilityRole: null 77 + } 78 + } 79 + | { 80 + isNative: false 81 + control: Dialog.DialogOuterProps['control'] 82 + state: { 83 + hovered: false 84 + focused: false 85 + pressed: false 86 + } 87 + props: RadixPassThroughTriggerProps & { 88 + onPress: () => void 89 + onFocus: () => void 90 + onBlur: () => void 91 + onMouseEnter: () => void 92 + onMouseLeave: () => void 93 + accessibilityHint?: string 94 + accessibilityLabel: string 95 + accessibilityRole: AccessibilityRole 96 + } 97 + }
+2 -7
src/components/Menu/context.tsx
··· 2 2 3 3 import type {ContextType, ItemContextType} from '#/components/Menu/types' 4 4 5 - export const Context = React.createContext<ContextType>({ 6 - // @ts-ignore 7 - control: null, 8 - }) 5 + export const Context = React.createContext<ContextType | null>(null) 9 6 10 - export const ItemContext = React.createContext<ItemContextType>({ 11 - disabled: false, 12 - }) 7 + export const ItemContext = React.createContext<ItemContextType | null>(null) 13 8 14 9 export function useMenuContext() { 15 10 const context = React.useContext(Context)
+1 -1
src/components/Menu/index.tsx
··· 34 34 children, 35 35 control, 36 36 }: React.PropsWithChildren<{ 37 - control?: Dialog.DialogOuterProps['control'] 37 + control?: Dialog.DialogControlProps 38 38 }>) { 39 39 const defaultControl = Dialog.useDialogControl() 40 40 const context = React.useMemo<ContextType>(
+1 -1
src/components/Menu/index.web.tsx
··· 50 50 children, 51 51 control, 52 52 }: React.PropsWithChildren<{ 53 - control?: Dialog.DialogOuterProps['control'] 53 + control?: Dialog.DialogControlProps 54 54 }>) { 55 55 const {_} = useLingui() 56 56 const defaultControl = useMenuControl()
+47 -78
src/components/dms/ActionsWrapper.tsx
··· 1 - import React from 'react' 2 - import {Keyboard} from 'react-native' 3 - import {Gesture, GestureDetector} from 'react-native-gesture-handler' 4 - import Animated, { 5 - cancelAnimation, 6 - runOnJS, 7 - useAnimatedStyle, 8 - useSharedValue, 9 - withTiming, 10 - } from 'react-native-reanimated' 1 + import {View} from 'react-native' 11 2 import {ChatBskyConvoDefs} from '@atproto/api' 12 3 import {msg} from '@lingui/macro' 13 4 import {useLingui} from '@lingui/react' 14 5 15 - import {HITSLOP_10} from '#/lib/constants' 16 - import {useHaptics} from '#/lib/haptics' 17 6 import {atoms as a} from '#/alf' 18 - import {MessageMenu} from '#/components/dms/MessageMenu' 19 - import {useMenuControl} from '#/components/Menu' 7 + import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 20 8 21 9 export function ActionsWrapper({ 22 10 message, ··· 28 16 children: React.ReactNode 29 17 }) { 30 18 const {_} = useLingui() 31 - const playHaptic = useHaptics() 32 - const menuControl = useMenuControl() 33 - 34 - const scale = useSharedValue(1) 35 - 36 - const animatedStyle = useAnimatedStyle(() => ({ 37 - transform: [{scale: scale.get()}], 38 - })) 39 - 40 - const open = React.useCallback(() => { 41 - playHaptic() 42 - Keyboard.dismiss() 43 - menuControl.open() 44 - }, [menuControl, playHaptic]) 45 - 46 - const shrink = React.useCallback(() => { 47 - 'worklet' 48 - cancelAnimation(scale) 49 - scale.set(() => withTiming(1, {duration: 200})) 50 - }, [scale]) 51 - 52 - const doubleTapGesture = Gesture.Tap() 53 - .numberOfTaps(2) 54 - .hitSlop(HITSLOP_10) 55 - .onEnd(open) 56 - .runOnJS(true) 57 - 58 - const pressAndHoldGesture = Gesture.LongPress() 59 - .onStart(() => { 60 - 'worklet' 61 - scale.set(() => 62 - withTiming(1.05, {duration: 200}, finished => { 63 - if (!finished) return 64 - runOnJS(open)() 65 - shrink() 66 - }), 67 - ) 68 - }) 69 - .onTouchesUp(shrink) 70 - .onTouchesMove(shrink) 71 - .cancelsTouchesInView(false) 72 - 73 - const composedGestures = Gesture.Exclusive( 74 - doubleTapGesture, 75 - pressAndHoldGesture, 76 - ) 77 19 78 20 return ( 79 - <GestureDetector gesture={composedGestures}> 80 - <Animated.View 81 - style={[ 82 - { 83 - maxWidth: '80%', 84 - }, 85 - isFromSelf ? a.self_end : a.self_start, 86 - animatedStyle, 87 - ]} 88 - accessible={true} 89 - accessibilityActions={[ 90 - {name: 'activate', label: _(msg`Open message options`)}, 91 - ]} 92 - onAccessibilityAction={open}> 93 - {children} 94 - <MessageMenu message={message} control={menuControl} /> 95 - </Animated.View> 96 - </GestureDetector> 21 + <MessageContextMenu message={message}> 22 + {trigger => 23 + // will always be true, since this file is platform split 24 + trigger.isNative && ( 25 + <View style={[a.flex_1, a.relative]}> 26 + {/* {isNative && ( 27 + <View 28 + style={[ 29 + a.rounded_full, 30 + a.absolute, 31 + {bottom: '100%'}, 32 + isFromSelf ? a.right_0 : a.left_0, 33 + t.atoms.bg, 34 + a.flex_row, 35 + a.shadow_lg, 36 + a.py_xs, 37 + a.px_md, 38 + a.gap_md, 39 + a.mb_xs, 40 + ]}> 41 + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( 42 + <Text key={emoji} style={[a.text_center, {fontSize: 32}]}> 43 + {emoji} 44 + </Text> 45 + ))} 46 + </View> 47 + )} */} 48 + <View 49 + style={[ 50 + {maxWidth: '80%'}, 51 + isFromSelf 52 + ? [a.self_end, a.align_end] 53 + : [a.self_start, a.align_start], 54 + ]} 55 + accessible={true} 56 + accessibilityActions={[ 57 + {name: 'activate', label: _(msg`Open message options`)}, 58 + ]} 59 + onAccessibilityAction={trigger.control.open}> 60 + {children} 61 + </View> 62 + </View> 63 + ) 64 + } 65 + </MessageContextMenu> 97 66 ) 98 67 }
+34 -34
src/components/dms/ActionsWrapper.web.tsx
··· 1 1 import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 2 + import {Pressable, View} from 'react-native' 3 3 import {ChatBskyConvoDefs} from '@atproto/api' 4 4 5 - import {atoms as a} from '#/alf' 6 - import {MessageMenu} from '#/components/dms/MessageMenu' 7 - import {useMenuControl} from '#/components/Menu' 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 + import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid' 8 8 9 9 export function ActionsWrapper({ 10 10 message, ··· 15 15 isFromSelf: boolean 16 16 children: React.ReactNode 17 17 }) { 18 - const menuControl = useMenuControl() 19 18 const viewRef = React.useRef(null) 19 + const t = useTheme() 20 20 21 21 const [showActions, setShowActions] = React.useState(false) 22 22 ··· 42 42 onMouseLeave={onMouseLeave} 43 43 onFocus={onFocus} 44 44 onBlur={onMouseLeave} 45 - style={StyleSheet.flatten([a.flex_1, a.flex_row])} 45 + style={[a.flex_1, isFromSelf ? a.flex_row : a.flex_row_reverse]} 46 46 ref={viewRef}> 47 - {isFromSelf && ( 48 - <View 49 - style={[ 50 - a.mr_xl, 51 - a.justify_center, 52 - { 53 - marginLeft: 'auto', 54 - }, 55 - ]}> 56 - <MessageMenu 57 - message={message} 58 - control={menuControl} 59 - triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} 60 - /> 61 - </View> 62 - )} 63 47 <View 64 - style={{ 65 - maxWidth: '80%', 66 - }}> 48 + style={[ 49 + a.justify_center, 50 + isFromSelf 51 + ? [a.mr_xl, {marginLeft: 'auto'}] 52 + : [a.ml_xl, {marginRight: 'auto'}], 53 + ]}> 54 + <MessageContextMenu message={message}> 55 + {({props, state, isNative, control}) => { 56 + // always false, file is platform split 57 + if (isNative) return null 58 + const showMenuTrigger = showActions || control.isOpen ? 1 : 0 59 + return ( 60 + <Pressable 61 + {...props} 62 + style={[ 63 + {opacity: showMenuTrigger}, 64 + a.p_sm, 65 + a.rounded_full, 66 + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 67 + ]}> 68 + <DotsHorizontalIcon size="md" style={t.atoms.text} /> 69 + </Pressable> 70 + ) 71 + }} 72 + </MessageContextMenu> 73 + </View> 74 + <View 75 + style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}> 67 76 {children} 68 77 </View> 69 - {!isFromSelf && ( 70 - <View style={[a.flex_row, a.align_center, a.ml_xl]}> 71 - <MessageMenu 72 - message={message} 73 - control={menuControl} 74 - triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} 75 - /> 76 - </View> 77 - )} 78 78 </View> 79 79 ) 80 80 }
+151
src/components/dms/MessageContextMenu.tsx
··· 1 + import React from 'react' 2 + import {LayoutAnimation} from 'react-native' 3 + import * as Clipboard from 'expo-clipboard' 4 + import {ChatBskyConvoDefs, RichText} from '@atproto/api' 5 + import {msg} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {useOpenLink} from '#/lib/hooks/useOpenLink' 9 + import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 + import {getTranslatorLink} from '#/locale/helpers' 11 + import {useConvoActive} from '#/state/messages/convo' 12 + import {useLanguagePrefs} from '#/state/preferences' 13 + import {useSession} from '#/state/session' 14 + import * as Toast from '#/view/com/util/Toast' 15 + import * as ContextMenu from '#/components/ContextMenu' 16 + import {TriggerProps} from '#/components/ContextMenu/types' 17 + import {ReportDialog} from '#/components/dms/ReportDialog' 18 + import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 19 + import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 20 + import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 21 + import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 22 + import * as Prompt from '#/components/Prompt' 23 + import {usePromptControl} from '#/components/Prompt' 24 + 25 + export let MessageContextMenu = ({ 26 + message, 27 + children, 28 + }: { 29 + message: ChatBskyConvoDefs.MessageView 30 + children: TriggerProps['children'] 31 + }): React.ReactNode => { 32 + const {_} = useLingui() 33 + const {currentAccount} = useSession() 34 + const convo = useConvoActive() 35 + const deleteControl = usePromptControl() 36 + const reportControl = usePromptControl() 37 + const langPrefs = useLanguagePrefs() 38 + const openLink = useOpenLink() 39 + 40 + const isFromSelf = message.sender?.did === currentAccount?.did 41 + 42 + const onCopyMessage = React.useCallback(() => { 43 + const str = richTextToString( 44 + new RichText({ 45 + text: message.text, 46 + facets: message.facets, 47 + }), 48 + true, 49 + ) 50 + 51 + Clipboard.setStringAsync(str) 52 + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 53 + }, [_, message.text, message.facets]) 54 + 55 + const onPressTranslateMessage = React.useCallback(() => { 56 + const translatorUrl = getTranslatorLink( 57 + message.text, 58 + langPrefs.primaryLanguage, 59 + ) 60 + openLink(translatorUrl, true) 61 + }, [langPrefs.primaryLanguage, message.text, openLink]) 62 + 63 + const onDelete = React.useCallback(() => { 64 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 65 + convo 66 + .deleteMessage(message.id) 67 + .then(() => 68 + Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), 69 + ) 70 + .catch(() => Toast.show(_(msg`Failed to delete message`))) 71 + }, [_, convo, message.id]) 72 + 73 + const sender = convo.convo.members.find( 74 + member => member.did === message.sender.did, 75 + ) 76 + 77 + return ( 78 + <> 79 + <ContextMenu.Root> 80 + <ContextMenu.Trigger 81 + label={_(msg`Message options`)} 82 + contentLabel={_( 83 + msg`Message from @${ 84 + sender?.handle ?? // should always be defined 85 + 'unknown' 86 + }: ${message.text}`, 87 + )}> 88 + {children} 89 + </ContextMenu.Trigger> 90 + 91 + <ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}> 92 + {message.text.length > 0 && ( 93 + <> 94 + <ContextMenu.Item 95 + testID="messageDropdownTranslateBtn" 96 + label={_(msg`Translate`)} 97 + onPress={onPressTranslateMessage}> 98 + <ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText> 99 + <ContextMenu.ItemIcon icon={Translate} position="right" /> 100 + </ContextMenu.Item> 101 + <ContextMenu.Item 102 + testID="messageDropdownCopyBtn" 103 + label={_(msg`Copy message text`)} 104 + onPress={onCopyMessage}> 105 + <ContextMenu.ItemText> 106 + {_(msg`Copy message text`)} 107 + </ContextMenu.ItemText> 108 + <ContextMenu.ItemIcon icon={ClipboardIcon} position="right" /> 109 + </ContextMenu.Item> 110 + <ContextMenu.Divider /> 111 + </> 112 + )} 113 + <ContextMenu.Item 114 + testID="messageDropdownDeleteBtn" 115 + label={_(msg`Delete message for me`)} 116 + onPress={() => deleteControl.open()}> 117 + <ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText> 118 + <ContextMenu.ItemIcon icon={Trash} position="right" /> 119 + </ContextMenu.Item> 120 + {!isFromSelf && ( 121 + <ContextMenu.Item 122 + testID="messageDropdownReportBtn" 123 + label={_(msg`Report message`)} 124 + onPress={() => reportControl.open()}> 125 + <ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText> 126 + <ContextMenu.ItemIcon icon={Warning} position="right" /> 127 + </ContextMenu.Item> 128 + )} 129 + </ContextMenu.Outer> 130 + </ContextMenu.Root> 131 + 132 + <ReportDialog 133 + currentScreen="conversation" 134 + params={{type: 'convoMessage', convoId: convo.convo.id, message}} 135 + control={reportControl} 136 + /> 137 + 138 + <Prompt.Basic 139 + control={deleteControl} 140 + title={_(msg`Delete message`)} 141 + description={_( 142 + msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`, 143 + )} 144 + confirmButtonCta={_(msg`Delete`)} 145 + confirmButtonColor="negative" 146 + onConfirm={onDelete} 147 + /> 148 + </> 149 + ) 150 + } 151 + MessageContextMenu = React.memo(MessageContextMenu)
+25 -8
src/components/dms/MessageItemEmbed.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {useWindowDimensions, View} from 'react-native' 3 3 import {AppBskyEmbedRecord} from '@atproto/api' 4 4 5 5 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 6 - import {atoms as a, native, useTheme} from '#/alf' 6 + import {atoms as a, native, tokens, useTheme, web} from '#/alf' 7 7 import {MessageContextProvider} from './MessageContext' 8 8 9 9 let MessageItemEmbed = ({ ··· 12 12 embed: AppBskyEmbedRecord.View 13 13 }): React.ReactNode => { 14 14 const t = useTheme() 15 + const screen = useWindowDimensions() 15 16 16 17 return ( 17 18 <MessageContextProvider> 18 - <View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}> 19 - <PostEmbeds 20 - embed={embed} 21 - allowNestedQuotes 22 - viewContext={PostEmbedViewContext.Feed} 23 - /> 19 + <View 20 + style={[ 21 + a.my_xs, 22 + t.atoms.bg, 23 + a.rounded_md, 24 + native({ 25 + flexBasis: 0, 26 + width: Math.min(screen.width, 600) / 1.4, 27 + }), 28 + web({ 29 + width: '100%', 30 + minWidth: 280, 31 + maxWidth: 360, 32 + }), 33 + ]}> 34 + <View style={{marginTop: tokens.space.sm * -1}}> 35 + <PostEmbeds 36 + embed={embed} 37 + allowNestedQuotes 38 + viewContext={PostEmbedViewContext.Feed} 39 + /> 40 + </View> 24 41 </View> 25 42 </MessageContextProvider> 26 43 )
-161
src/components/dms/MessageMenu.tsx
··· 1 - import React from 'react' 2 - import {LayoutAnimation, Pressable, View} from 'react-native' 3 - import * as Clipboard from 'expo-clipboard' 4 - import {ChatBskyConvoDefs, RichText} from '@atproto/api' 5 - import {msg} from '@lingui/macro' 6 - import {useLingui} from '@lingui/react' 7 - 8 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 9 - import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 - import {getTranslatorLink} from '#/locale/helpers' 11 - import {isWeb} from '#/platform/detection' 12 - import {useConvoActive} from '#/state/messages/convo' 13 - import {useLanguagePrefs} from '#/state/preferences' 14 - import {useSession} from '#/state/session' 15 - import * as Toast from '#/view/com/util/Toast' 16 - import {atoms as a, useTheme} from '#/alf' 17 - import {ReportDialog} from '#/components/dms/ReportDialog' 18 - import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 19 - import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 20 - import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 21 - import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 22 - import * as Menu from '#/components/Menu' 23 - import * as Prompt from '#/components/Prompt' 24 - import {usePromptControl} from '#/components/Prompt' 25 - import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard' 26 - 27 - export let MessageMenu = ({ 28 - message, 29 - control, 30 - triggerOpacity, 31 - }: { 32 - triggerOpacity?: number 33 - message: ChatBskyConvoDefs.MessageView 34 - control: Menu.MenuControlProps 35 - }): React.ReactNode => { 36 - const {_} = useLingui() 37 - const t = useTheme() 38 - const {currentAccount} = useSession() 39 - const convo = useConvoActive() 40 - const deleteControl = usePromptControl() 41 - const reportControl = usePromptControl() 42 - const langPrefs = useLanguagePrefs() 43 - const openLink = useOpenLink() 44 - 45 - const isFromSelf = message.sender?.did === currentAccount?.did 46 - 47 - const onCopyMessage = React.useCallback(() => { 48 - const str = richTextToString( 49 - new RichText({ 50 - text: message.text, 51 - facets: message.facets, 52 - }), 53 - true, 54 - ) 55 - 56 - Clipboard.setStringAsync(str) 57 - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 58 - }, [_, message.text, message.facets]) 59 - 60 - const onPressTranslateMessage = React.useCallback(() => { 61 - const translatorUrl = getTranslatorLink( 62 - message.text, 63 - langPrefs.primaryLanguage, 64 - ) 65 - openLink(translatorUrl, true) 66 - }, [langPrefs.primaryLanguage, message.text, openLink]) 67 - 68 - const onDelete = React.useCallback(() => { 69 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 70 - convo 71 - .deleteMessage(message.id) 72 - .then(() => 73 - Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), 74 - ) 75 - .catch(() => Toast.show(_(msg`Failed to delete message`))) 76 - }, [_, convo, message.id]) 77 - 78 - return ( 79 - <> 80 - <Menu.Root control={control}> 81 - {isWeb && ( 82 - <View style={{opacity: triggerOpacity}}> 83 - <Menu.Trigger label={_(msg`Chat settings`)}> 84 - {({props, state}) => ( 85 - <Pressable 86 - {...props} 87 - style={[ 88 - a.p_sm, 89 - a.rounded_full, 90 - (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 91 - ]}> 92 - <DotsHorizontal size="md" style={t.atoms.text} /> 93 - </Pressable> 94 - )} 95 - </Menu.Trigger> 96 - </View> 97 - )} 98 - 99 - <Menu.Outer> 100 - {message.text.length > 0 && ( 101 - <> 102 - <Menu.Group> 103 - <Menu.Item 104 - testID="messageDropdownTranslateBtn" 105 - label={_(msg`Translate`)} 106 - onPress={onPressTranslateMessage}> 107 - <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> 108 - <Menu.ItemIcon icon={Translate} position="right" /> 109 - </Menu.Item> 110 - <Menu.Item 111 - testID="messageDropdownCopyBtn" 112 - label={_(msg`Copy message text`)} 113 - onPress={onCopyMessage}> 114 - <Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText> 115 - <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 116 - </Menu.Item> 117 - </Menu.Group> 118 - <Menu.Divider /> 119 - </> 120 - )} 121 - <Menu.Group> 122 - <Menu.Item 123 - testID="messageDropdownDeleteBtn" 124 - label={_(msg`Delete message for me`)} 125 - onPress={() => deleteControl.open()}> 126 - <Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText> 127 - <Menu.ItemIcon icon={Trash} position="right" /> 128 - </Menu.Item> 129 - {!isFromSelf && ( 130 - <Menu.Item 131 - testID="messageDropdownReportBtn" 132 - label={_(msg`Report message`)} 133 - onPress={() => reportControl.open()}> 134 - <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText> 135 - <Menu.ItemIcon icon={Warning} position="right" /> 136 - </Menu.Item> 137 - )} 138 - </Menu.Group> 139 - </Menu.Outer> 140 - </Menu.Root> 141 - 142 - <ReportDialog 143 - currentScreen="conversation" 144 - params={{type: 'convoMessage', convoId: convo.convo.id, message}} 145 - control={reportControl} 146 - /> 147 - 148 - <Prompt.Basic 149 - control={deleteControl} 150 - title={_(msg`Delete message`)} 151 - description={_( 152 - msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`, 153 - )} 154 - confirmButtonCta={_(msg`Delete`)} 155 - confirmButtonColor="negative" 156 - onConfirm={onDelete} 157 - /> 158 - </> 159 - ) 160 - } 161 - MessageMenu = React.memo(MessageMenu)