From c4785ef96e13d02b217dce4e777269c0e895507d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 21 Mar 2025 18:29:14 +0200 Subject: 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 --- src/components/dms/ActionsWrapper.tsx | 125 +++++++++-------------- src/components/dms/ActionsWrapper.web.tsx | 68 ++++++------- src/components/dms/MessageContextMenu.tsx | 151 ++++++++++++++++++++++++++++ src/components/dms/MessageItemEmbed.tsx | 33 ++++-- src/components/dms/MessageMenu.tsx | 161 ------------------------------ 5 files changed, 257 insertions(+), 281 deletions(-) create mode 100644 src/components/dms/MessageContextMenu.tsx delete mode 100644 src/components/dms/MessageMenu.tsx (limited to 'src/components/dms') 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 ( - - - {children} - - - + + {trigger => + // will always be true, since this file is platform split + trigger.isNative && ( + + {/* {isNative && ( + + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( + + {emoji} + + ))} + + )} */} + + {children} + + + ) + } + ) } diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 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 && ( - - - - )} + style={[ + a.justify_center, + isFromSelf + ? [a.mr_xl, {marginLeft: 'auto'}] + : [a.ml_xl, {marginRight: 'auto'}], + ]}> + + {({props, state, isNative, control}) => { + // always false, file is platform split + if (isNative) return null + const showMenuTrigger = showActions || control.isOpen ? 1 : 0 + return ( + + + + ) + }} + + + {children} - {!isFromSelf && ( - - - - )} ) } diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx new file mode 100644 index 000000000..b5542690f --- /dev/null +++ b/src/components/dms/MessageContextMenu.tsx @@ -0,0 +1,151 @@ +import React from 'react' +import {LayoutAnimation} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import {ChatBskyConvoDefs, RichText} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {getTranslatorLink} from '#/locale/helpers' +import {useConvoActive} from '#/state/messages/convo' +import {useLanguagePrefs} from '#/state/preferences' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import * as ContextMenu from '#/components/ContextMenu' +import {TriggerProps} from '#/components/ContextMenu/types' +import {ReportDialog} from '#/components/dms/ReportDialog' +import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import * as Prompt from '#/components/Prompt' +import {usePromptControl} from '#/components/Prompt' + +export let MessageContextMenu = ({ + message, + children, +}: { + message: ChatBskyConvoDefs.MessageView + children: TriggerProps['children'] +}): React.ReactNode => { + const {_} = useLingui() + const {currentAccount} = useSession() + const convo = useConvoActive() + const deleteControl = usePromptControl() + const reportControl = usePromptControl() + const langPrefs = useLanguagePrefs() + const openLink = useOpenLink() + + const isFromSelf = message.sender?.did === currentAccount?.did + + const onCopyMessage = React.useCallback(() => { + const str = richTextToString( + new RichText({ + text: message.text, + facets: message.facets, + }), + true, + ) + + Clipboard.setStringAsync(str) + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') + }, [_, message.text, message.facets]) + + const onPressTranslateMessage = React.useCallback(() => { + const translatorUrl = getTranslatorLink( + message.text, + langPrefs.primaryLanguage, + ) + openLink(translatorUrl, true) + }, [langPrefs.primaryLanguage, message.text, openLink]) + + const onDelete = React.useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + convo + .deleteMessage(message.id) + .then(() => + Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), + ) + .catch(() => Toast.show(_(msg`Failed to delete message`))) + }, [_, convo, message.id]) + + const sender = convo.convo.members.find( + member => member.did === message.sender.did, + ) + + return ( + <> + + + {children} + + + + {message.text.length > 0 && ( + <> + + {_(msg`Translate`)} + + + + + {_(msg`Copy message text`)} + + + + + + )} + deleteControl.open()}> + {_(msg`Delete for me`)} + + + {!isFromSelf && ( + reportControl.open()}> + {_(msg`Report`)} + + + )} + + + + + + + + ) +} +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 ( - - + + + + ) diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx deleted file mode 100644 index cff5f9dd4..000000000 --- a/src/components/dms/MessageMenu.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React from 'react' -import {LayoutAnimation, Pressable, View} from 'react-native' -import * as Clipboard from 'expo-clipboard' -import {ChatBskyConvoDefs, RichText} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {richTextToString} from '#/lib/strings/rich-text-helpers' -import {getTranslatorLink} from '#/locale/helpers' -import {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 {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 {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 = ({ - message, - control, - triggerOpacity, -}: { - triggerOpacity?: number - message: ChatBskyConvoDefs.MessageView - control: Menu.MenuControlProps -}): React.ReactNode => { - const {_} = useLingui() - const t = useTheme() - const {currentAccount} = useSession() - const convo = useConvoActive() - const deleteControl = usePromptControl() - const reportControl = usePromptControl() - const langPrefs = useLanguagePrefs() - const openLink = useOpenLink() - - const isFromSelf = message.sender?.did === currentAccount?.did - - const onCopyMessage = React.useCallback(() => { - const str = richTextToString( - new RichText({ - text: message.text, - facets: message.facets, - }), - true, - ) - - Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') - }, [_, message.text, message.facets]) - - const onPressTranslateMessage = React.useCallback(() => { - const translatorUrl = getTranslatorLink( - message.text, - langPrefs.primaryLanguage, - ) - openLink(translatorUrl, true) - }, [langPrefs.primaryLanguage, message.text, openLink]) - - const onDelete = React.useCallback(() => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - convo - .deleteMessage(message.id) - .then(() => - Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), - ) - .catch(() => Toast.show(_(msg`Failed to delete message`))) - }, [_, convo, message.id]) - - return ( - <> - - {isWeb && ( - - - {({props, state}) => ( - - - - )} - - - )} - - - {message.text.length > 0 && ( - <> - - - {_(msg`Translate`)} - - - - {_(msg`Copy message text`)} - - - - - - )} - - deleteControl.open()}> - {_(msg`Delete for me`)} - - - {!isFromSelf && ( - reportControl.open()}> - {_(msg`Report`)} - - - )} - - - - - - - - - ) -} -MessageMenu = React.memo(MessageMenu) -- cgit 1.4.1