From 8ba1b10ce0d278a88e37d6b6c277a41673392877 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 2 May 2024 13:54:17 -0700 Subject: [Clipclops] Message actions for native and web (#3807) * haptic on long press * add animation to press and hold * eslint disable for now * adjust styles * dont trigger if animation is cancelled * organize * add a delete menu * reset scale automatically * message actions dialog cleanup center the trigger handle focus/unfocus better make triggers accessible weg dropdown menu add a wep specific wrapper decrease press delay add report button improve shrink logic use `self_end` instead of `margin: auto` rm extra `?` move `MessageItem` to `components` add delete button * rm some padding * update after merge * fix merge * web only types * fix crash * add an explanation * fix web types --------- Co-authored-by: Samuel Newman --- src/components/dms/ActionsWrapper.tsx | 82 ++++++++++ src/components/dms/ActionsWrapper.web.tsx | 86 +++++++++++ src/components/dms/MessageItem.tsx | 166 +++++++++++++++++++++ src/components/dms/MessageMenu.tsx | 99 ++++++++++++ src/screens/Messages/Conversation/MessageItem.tsx | 165 -------------------- src/screens/Messages/Conversation/MessagesList.tsx | 2 +- 6 files changed, 434 insertions(+), 166 deletions(-) create mode 100644 src/components/dms/ActionsWrapper.tsx create mode 100644 src/components/dms/ActionsWrapper.web.tsx create mode 100644 src/components/dms/MessageItem.tsx create mode 100644 src/components/dms/MessageMenu.tsx delete mode 100644 src/screens/Messages/Conversation/MessageItem.tsx (limited to 'src') 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 ( + + + {children} + + + + ) +} 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(e => { + if (e.nativeEvent.relatedTarget == null) return + setShowActions(true) + }, []) + + return ( + + {isFromSelf && ( + + + + )} + + {children} + + {!isFromSelf && ( + + + + )} + + ) +} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx new file mode 100644 index 000000000..3a1d8eab7 --- /dev/null +++ b/src/components/dms/MessageItem.tsx @@ -0,0 +1,166 @@ +import React, {useCallback, useMemo} from 'react' +import {StyleProp, TextStyle, 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 {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({ + item, + next, +}: { + item: ChatBskyConvoDefs.MessageView + next: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null +}) { + const t = useTheme() + const {currentAccount} = useSession() + + const isFromSelf = item.sender?.did === currentAccount?.did + + const isNextFromSelf = + ChatBskyConvoDefs.isMessageView(next) && + next.sender?.did === currentAccount?.did + + const isLastInGroup = useMemo(() => { + // if the next message is from a different sender, then it's the last in the group + if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { + return true + } + + // or, if there's a 10 minute gap between this message and the next + if (ChatBskyConvoDefs.isMessageView(next)) { + const thisDate = new Date(item.sentAt) + const nextDate = new Date(next.sentAt) + + const diff = nextDate.getTime() - thisDate.getTime() + + // 10 minutes + return diff > 10 * 60 * 1000 + } + + return true + }, [item, next, isFromSelf, isNextFromSelf]) + + return ( + + + + + {item.text} + + + + + + ) +} + +export function MessageItemMetadata({ + message, + isLastInGroup, + style, +}: { + message: ChatBskyConvoDefs.MessageView + isLastInGroup: boolean + style: StyleProp +}) { + const t = useTheme() + const {_} = useLingui() + + const relativeTimestamp = useCallback( + (timestamp: string) => { + const date = new Date(timestamp) + const now = new Date() + + const time = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }).format(date) + + const diff = now.getTime() - date.getTime() + + // if under 1 minute + if (diff < 1000 * 60) { + return _(msg`Now`) + } + + // if in the last day + if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) { + return time + } + + // if yesterday + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + if ( + yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10) + ) { + return _(msg`Yesterday, ${time}`) + } + + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hour12: true, + day: 'numeric', + month: 'numeric', + year: 'numeric', + }).format(date) + }, + [_], + ) + + if (!isLastInGroup) { + return null + } + + return ( + + {({timeElapsed}) => ( + + {timeElapsed} + + )} + + ) +} 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 ( + <> + + {!hideTrigger && ( + + + {({props, state}) => ( + + + + )} + + + )} + + + + + {_(msg`Delete`)} + + + {!isFromSelf && ( + + {_(msg`Report`)} + + + )} + + + + + + + ) +} +MessageMenu = React.memo(MessageMenu) diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx deleted file mode 100644 index ba1bcfd39..000000000 --- a/src/screens/Messages/Conversation/MessageItem.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React, {useCallback, useMemo} from 'react' -import {StyleProp, TextStyle, 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 {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' - -export function MessageItem({ - item, - next, -}: { - item: ChatBskyConvoDefs.MessageView - next: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null -}) { - const t = useTheme() - const {currentAccount} = useSession() - - const isFromSelf = item.sender?.did === currentAccount?.did - - const isNextFromSelf = - ChatBskyConvoDefs.isMessageView(next) && - next.sender?.did === currentAccount?.did - - const isLastInGroup = useMemo(() => { - // if the next message is from a different sender, then it's the last in the group - if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { - return true - } - - // or, if there's a 10 minute gap between this message and the next - if (ChatBskyConvoDefs.isMessageView(next)) { - const thisDate = new Date(item.sentAt) - const nextDate = new Date(next.sentAt) - - const diff = nextDate.getTime() - thisDate.getTime() - - // 10 minutes - return diff > 10 * 60 * 1000 - } - - return true - }, [item, next, isFromSelf, isNextFromSelf]) - - return ( - - - - {item.text} - - - - - ) -} - -function Metadata({ - message, - isLastInGroup, - style, -}: { - message: ChatBskyConvoDefs.MessageView - isLastInGroup: boolean - style: StyleProp -}) { - const t = useTheme() - const {_} = useLingui() - - const relativeTimestamp = useCallback( - (timestamp: string) => { - const date = new Date(timestamp) - const now = new Date() - - const time = new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric', - hour12: true, - }).format(date) - - const diff = now.getTime() - date.getTime() - - // if under 1 minute - if (diff < 1000 * 60) { - return _(msg`Now`) - } - - // if in the last day - if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) { - return time - } - - // if yesterday - const yesterday = new Date(now) - yesterday.setDate(yesterday.getDate() - 1) - if ( - yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10) - ) { - return _(msg`Yesterday, ${time}`) - } - - return new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric', - hour12: true, - day: 'numeric', - month: 'numeric', - year: 'numeric', - }).format(date) - }, - [_], - ) - - if (!isLastInGroup) { - return null - } - - return ( - - {({timeElapsed}) => ( - - {timeElapsed} - - )} - - ) -} 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' -- cgit 1.4.1