diff options
-rw-r--r-- | src/components/dms/ActionsWrapper.tsx | 82 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.web.tsx | 86 | ||||
-rw-r--r-- | src/components/dms/MessageItem.tsx (renamed from src/screens/Messages/Conversation/MessageItem.tsx) | 57 | ||||
-rw-r--r-- | src/components/dms/MessageMenu.tsx | 99 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 2 |
5 files changed, 297 insertions, 29 deletions
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx new file mode 100644 index 000000000..107e5eb8e --- /dev/null +++ b/src/components/dms/ActionsWrapper.tsx @@ -0,0 +1,82 @@ +import React, {useCallback} from 'react' +import {Pressable, View} from 'react-native' +import Animated, { + cancelAnimation, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import {ChatBskyConvoDefs} from '@atproto-labs/api' + +import {useHaptics} from 'lib/haptics' +import {atoms as a} from '#/alf' +import {MessageMenu} from '#/components/dms/MessageMenu' +import {useMenuControl} from '#/components/Menu' + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable) + +export function ActionsWrapper({ + message, + isFromSelf, + children, +}: { + message: ChatBskyConvoDefs.MessageView + isFromSelf: boolean + children: React.ReactNode +}) { + const playHaptic = useHaptics() + const menuControl = useMenuControl() + + const scale = useSharedValue(1) + const animationDidComplete = useSharedValue(false) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })) + + // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this + // function + const open = useCallback(() => { + menuControl.open() + }, [menuControl]) + + const shrink = useCallback(() => { + 'worklet' + cancelAnimation(scale) + scale.value = withTiming(1, {duration: 200}, () => { + animationDidComplete.value = false + }) + }, [animationDidComplete, scale]) + + const grow = React.useCallback(() => { + 'worklet' + scale.value = withTiming(1.05, {duration: 750}, finished => { + if (!finished) return + animationDidComplete.value = true + runOnJS(playHaptic)() + runOnJS(open)() + + shrink() + }) + }, [scale, animationDidComplete, playHaptic, shrink, open]) + + return ( + <View + style={[ + { + maxWidth: '65%', + }, + isFromSelf ? a.self_end : a.self_start, + ]}> + <AnimatedPressable + style={animatedStyle} + unstable_pressDelay={200} + onPressIn={grow} + onTouchEnd={shrink}> + {children} + </AnimatedPressable> + <MessageMenu message={message} control={menuControl} hideTrigger={true} /> + </View> + ) +} diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx new file mode 100644 index 000000000..f4c85ab94 --- /dev/null +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto-labs/api' + +import {atoms as a} from '#/alf' +import {MessageMenu} from '#/components/dms/MessageMenu' +import {useMenuControl} from '#/components/Menu' + +export function ActionsWrapper({ + message, + isFromSelf, + children, +}: { + message: ChatBskyConvoDefs.MessageView + isFromSelf: boolean + children: React.ReactNode +}) { + const menuControl = useMenuControl() + const viewRef = React.useRef(null) + + const [showActions, setShowActions] = React.useState(false) + + const onMouseEnter = React.useCallback(() => { + setShowActions(true) + }, []) + + const onMouseLeave = React.useCallback(() => { + setShowActions(false) + }, []) + + // We need to handle the `onFocus` separately because we want to know if there is a related target (the element + // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed. + const onFocus = React.useCallback<React.FocusEventHandler>(e => { + if (e.nativeEvent.relatedTarget == null) return + setShowActions(true) + }, []) + + return ( + <View + // @ts-expect-error web only + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onFocus={onFocus} + onBlur={onMouseLeave} + style={StyleSheet.flatten([a.flex_1, a.flex_row])} + ref={viewRef}> + {isFromSelf && ( + <View + style={[ + a.mr_xl, + a.justify_center, + { + marginLeft: 'auto', + }, + ]}> + <MessageMenu + message={message} + control={menuControl} + triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} + onTriggerPress={onMouseEnter} + // @ts-expect-error web only + onMouseLeave={onMouseLeave} + /> + </View> + )} + <View + style={{ + maxWidth: '65%', + }}> + {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} + onTriggerPress={onMouseEnter} + // @ts-expect-error web only + onMouseLeave={onMouseLeave} + /> + </View> + )} + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/components/dms/MessageItem.tsx index ba1bcfd39..3a1d8eab7 100644 --- a/src/screens/Messages/Conversation/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -5,8 +5,9 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import {TimeElapsed} from 'view/com/util/TimeElapsed' import {atoms as a, useTheme} from '#/alf' +import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {Text} from '#/components/Typography' export function MessageItem({ @@ -50,34 +51,34 @@ export function MessageItem({ return ( <View> - <View - style={[ - a.py_sm, - a.px_lg, - a.my_2xs, - a.rounded_md, - isFromSelf ? a.self_end : a.self_start, - { - maxWidth: '65%', - backgroundColor: isFromSelf - ? t.palette.primary_500 - : t.palette.contrast_50, - borderRadius: 17, - }, - isFromSelf - ? {borderBottomRightRadius: isLastInGroup ? 2 : 17} - : {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, - ]}> - <Text + <ActionsWrapper isFromSelf={isFromSelf} message={item}> + <View style={[ - a.text_md, - a.leading_snug, - isFromSelf && {color: t.palette.white}, + a.py_sm, + a.px_lg, + a.my_2xs, + a.rounded_md, + { + backgroundColor: isFromSelf + ? t.palette.primary_500 + : t.palette.contrast_50, + borderRadius: 17, + }, + isFromSelf + ? {borderBottomRightRadius: isLastInGroup ? 2 : 17} + : {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, ]}> - {item.text} - </Text> - </View> - <Metadata + <Text + style={[ + a.text_md, + a.leading_snug, + isFromSelf && {color: t.palette.white}, + ]}> + {item.text} + </Text> + </View> + </ActionsWrapper> + <MessageItemMetadata message={item} isLastInGroup={isLastInGroup} style={isFromSelf ? a.text_right : a.text_left} @@ -86,7 +87,7 @@ export function MessageItem({ ) } -function Metadata({ +export function MessageItemMetadata({ message, isLastInGroup, style, diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx new file mode 100644 index 000000000..a21324204 --- /dev/null +++ b/src/components/dms/MessageMenu.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import {Pressable, View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useSession} from 'state/session' +import {atoms as a, useTheme} from '#/alf' +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' + +export let MessageMenu = ({ + message, + control, + hideTrigger, + triggerOpacity, +}: { + hideTrigger?: boolean + triggerOpacity?: number + onTriggerPress?: () => void + message: ChatBskyConvoDefs.MessageView + control: Menu.MenuControlProps +}): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const deleteControl = usePromptControl() + + const isFromSelf = message.sender?.did === currentAccount?.did + + const onDelete = React.useCallback(() => { + // TODO delete the message + }, []) + + const onReport = React.useCallback(() => { + // TODO report the message + }, []) + + return ( + <> + <Menu.Root control={control}> + {!hideTrigger && ( + <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="sm" style={t.atoms.text} /> + </Pressable> + )} + </Menu.Trigger> + </View> + )} + + <Menu.Outer> + <Menu.Group> + <Menu.Item + testID="messageDropdownDeleteBtn" + label={_(msg`Delete message`)} + onPress={deleteControl.open}> + <Menu.ItemText>{_(msg`Delete`)}</Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + {!isFromSelf && ( + <Menu.Item + testID="messageDropdownReportBtn" + label={_(msg`Report message`)} + onPress={onReport}> + <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText> + <Menu.ItemIcon icon={Warning} position="right" /> + </Menu.Item> + )} + </Menu.Group> + </Menu.Outer> + </Menu.Root> + + <Prompt.Basic + control={deleteControl} + title={_(msg`Delete message`)} + description={_( + msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`, + )} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + onConfirm={onDelete} + /> + </> + ) +} +MessageMenu = React.memo(MessageMenu) diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 1a6145da5..435c40326 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -17,9 +17,9 @@ import {useChat} from '#/state/messages' import {ConvoItem, ConvoStatus} from '#/state/messages/convo' import {useSetMinimalShellMode} from '#/state/shell' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' -import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' +import {MessageItem} from '#/components/dms/MessageItem' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' |