diff options
Diffstat (limited to 'src/components/dms')
-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 | 166 | ||||
-rw-r--r-- | src/components/dms/MessageMenu.tsx | 99 |
4 files changed, 433 insertions, 0 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/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 ( + <View> + <ActionsWrapper isFromSelf={isFromSelf} message={item}> + <View + style={[ + 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}, + ]}> + <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} + /> + </View> + ) +} + +export function MessageItemMetadata({ + message, + isLastInGroup, + style, +}: { + message: ChatBskyConvoDefs.MessageView + isLastInGroup: boolean + style: StyleProp<TextStyle> +}) { + 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 timestamp={message.sentAt} timeToString={relativeTimestamp}> + {({timeElapsed}) => ( + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_xs, + a.mt_2xs, + a.mb_lg, + style, + ]}> + {timeElapsed} + </Text> + )} + </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 ( + <> + <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) |