diff options
Diffstat (limited to 'src/components/dms')
-rw-r--r-- | src/components/dms/ActionsWrapper.web.tsx | 50 | ||||
-rw-r--r-- | src/components/dms/EmojiReactionPicker.tsx | 75 | ||||
-rw-r--r-- | src/components/dms/EmojiReactionPicker.web.tsx | 72 | ||||
-rw-r--r-- | src/components/dms/MessageContextMenu.tsx | 40 | ||||
-rw-r--r-- | src/components/dms/MessageItem.tsx | 93 | ||||
-rw-r--r-- | src/components/dms/util.ts | 31 |
6 files changed, 290 insertions, 71 deletions
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 82113eba8..aaffc0cfb 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -1,12 +1,19 @@ -import React from 'react' +import {useCallback, useRef, useState} from 'react' import {Pressable, View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type React from 'react' +import {useConvoActive} from '#/state/messages/convo' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiReactionPicker} from './EmojiReactionPicker' +import {hasReachedReactionLimit} from './util' export function ActionsWrapper({ message, @@ -17,26 +24,53 @@ export function ActionsWrapper({ isFromSelf: boolean children: React.ReactNode }) { - const viewRef = React.useRef(null) + const viewRef = useRef(null) const t = useTheme() + const {_} = useLingui() + const convo = useConvoActive() + const {currentAccount} = useSession() - const [showActions, setShowActions] = React.useState(false) + const [showActions, setShowActions] = useState(false) - const onMouseEnter = React.useCallback(() => { + const onMouseEnter = useCallback(() => { setShowActions(true) }, []) - const onMouseLeave = React.useCallback(() => { + const onMouseLeave = 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 => { + const onFocus = useCallback<React.FocusEventHandler>(e => { if (e.nativeEvent.relatedTarget == null) return setShowActions(true) }, []) + const onEmojiSelect = useCallback( + (emoji: string) => { + if ( + message.reactions?.find( + reaction => + reaction.value === emoji && + reaction.sender.did === currentAccount?.did, + ) + ) { + convo + .removeReaction(message.id, emoji) + .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) + } else { + if (hasReachedReactionLimit(message, currentAccount?.did)) return + convo + .addReaction(message.id, emoji) + .catch(() => + Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'), + ) + } + }, + [_, convo, message, currentAccount?.did], + ) + return ( <View // @ts-expect-error web only @@ -56,7 +90,7 @@ export function ActionsWrapper({ ? [a.mr_md, {marginLeft: 'auto'}] : [a.ml_md, {marginRight: 'auto'}], ]}> - <EmojiReactionPicker message={message}> + <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}> {({props, state, isNative, control}) => { // always false, file is platform split if (isNative) return null diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx index a98cebf9a..477f45743 100644 --- a/src/components/dms/EmojiReactionPicker.tsx +++ b/src/components/dms/EmojiReactionPicker.tsx @@ -1,5 +1,5 @@ import {useMemo, useState} from 'react' -import {Alert, useWindowDimensions, View} from 'react-native' +import {useWindowDimensions, View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -18,12 +18,15 @@ import { import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' import {EmojiPopup} from './EmojiPopup' +import {hasAlreadyReacted, hasReachedReactionLimit} from './util' export function EmojiReactionPicker({ message, + onEmojiSelect, }: { message: ChatBskyConvoDefs.MessageView children?: TriggerProps['children'] + onEmojiSelect: (emoji: string) => void }) { const {_} = useLingui() const {currentAccount} = useSession() @@ -39,10 +42,6 @@ export function EmojiReactionPicker({ return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon }, []) - const handleEmojiSelect = (emoji: string) => { - Alert.alert(emoji) - } - const position = useMemo(() => { return { x: align === 'left' ? 12 : screenWidth - layout.width - 12, @@ -52,6 +51,8 @@ export function EmojiReactionPicker({ } }, [measurement, align, screenWidth, layout]) + const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) + return ( <View onLayout={evt => setLayout(evt.nativeEvent.layout)} @@ -70,33 +71,49 @@ export function EmojiReactionPicker({ t.atoms.border_contrast_low, a.shadow_md, ]}> - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( - <ContextMenu.Item - position={position} - label={_(msg`React with ${emoji}`)} - key={emoji} - onPress={() => handleEmojiSelect(emoji)} - unstyled> - {hovered => ( - <View - style={[ - a.rounded_full, - hovered && {backgroundColor: t.palette.primary_500}, - {height: 40, width: 40}, - a.justify_center, - a.align_center, - ]}> - <Text style={[a.text_center, {fontSize: 30}]} emoji> - {emoji} - </Text> - </View> - )} - </ContextMenu.Item> - ))} + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => { + const alreadyReacted = hasAlreadyReacted( + message, + currentAccount?.did, + emoji, + ) + return ( + <ContextMenu.Item + position={position} + label={_(msg`React with ${emoji}`)} + key={emoji} + onPress={() => onEmojiSelect(emoji)} + unstyled + disabled={limitReacted ? !alreadyReacted : false}> + {hovered => ( + <View + style={[ + a.rounded_full, + hovered + ? { + backgroundColor: alreadyReacted + ? t.palette.negative_100 + : t.palette.primary_500, + } + : alreadyReacted && { + backgroundColor: t.palette.primary_200, + }, + {height: 40, width: 40}, + a.justify_center, + a.align_center, + ]}> + <Text style={[a.text_center, {fontSize: 30}]} emoji> + {emoji} + </Text> + </View> + )} + </ContextMenu.Item> + ) + })} <EmojiPopup onEmojiSelected={emoji => { close() - handleEmojiSelect(emoji) + onEmojiSelect(emoji) }}> <View style={[ diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx index bd51b4fd2..0425a879a 100644 --- a/src/components/dms/EmojiReactionPicker.web.tsx +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -1,24 +1,29 @@ import {useState} from 'react' import {View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyConvoDefs} from '@atproto/api' import EmojiPicker from '@emoji-mart/react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import {useSession} from '#/state/session' +import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web' import {PressableWithHover} from '#/view/com/util/PressableWithHover' import {atoms as a} from '#/alf' import {useTheme} from '#/alf' import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import * as Menu from '#/components/Menu' -import {TriggerProps} from '#/components/Menu/types' +import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' +import {hasAlreadyReacted, hasReachedReactionLimit} from './util' export function EmojiReactionPicker({ + message, children, + onEmojiSelect, }: { message: ChatBskyConvoDefs.MessageView children?: TriggerProps['children'] + onEmojiSelect: (emoji: string) => void }) { if (!children) throw new Error('EmojiReactionPicker requires the children prop on web') @@ -29,15 +34,22 @@ export function EmojiReactionPicker({ <Menu.Root> <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger> <Menu.Outer> - <MenuInner /> + <MenuInner message={message} onEmojiSelect={onEmojiSelect} /> </Menu.Outer> </Menu.Root> ) } -function MenuInner() { +function MenuInner({ + message, + onEmojiSelect, +}: { + message: ChatBskyConvoDefs.MessageView + onEmojiSelect: (emoji: string) => void +}) { const t = useTheme() const {control} = Menu.useMenuContext() + const {currentAccount} = useSession() const [expanded, setExpanded] = useState(false) @@ -47,29 +59,45 @@ function MenuInner() { const handleEmojiSelect = (emoji: string) => { control.close() - window.alert(emoji) + onEmojiSelect(emoji) } + const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) + return expanded ? ( <EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} /> ) : ( <View style={[a.flex_row, a.gap_xs]}> - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( - <PressableWithHover - key={emoji} - onPress={() => handleEmojiSelect(emoji)} - hoverStyle={{backgroundColor: t.palette.primary_100}} - style={[ - a.rounded_xs, - {height: 40, width: 40}, - a.justify_center, - a.align_center, - ]}> - <Text style={[a.text_center, {fontSize: 30}]} emoji> - {emoji} - </Text> - </PressableWithHover> - ))} + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => { + const alreadyReacted = hasAlreadyReacted( + message, + currentAccount?.did, + emoji, + ) + return ( + <PressableWithHover + key={emoji} + onPress={() => handleEmojiSelect(emoji)} + hoverStyle={{ + backgroundColor: alreadyReacted + ? t.palette.negative_200 + : !limitReacted + ? t.palette.primary_300 + : undefined, + }} + style={[ + a.rounded_xs, + {height: 40, width: 40}, + a.justify_center, + a.align_center, + alreadyReacted && {backgroundColor: t.palette.primary_100}, + ]}> + <Text style={[a.text_center, {fontSize: 30}]} emoji> + {emoji} + </Text> + </PressableWithHover> + ) + })} <PressableWithHover onPress={() => setExpanded(true)} hoverStyle={{backgroundColor: t.palette.primary_100}} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 5591bec69..c978f1556 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {memo, useCallback} from 'react' import {LayoutAnimation} from 'react-native' import * as Clipboard from 'expo-clipboard' import {type ChatBskyConvoDefs, RichText} from '@atproto/api' @@ -23,6 +23,7 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' import {EmojiReactionPicker} from './EmojiReactionPicker' +import {hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, @@ -41,7 +42,7 @@ export let MessageContextMenu = ({ const isFromSelf = message.sender?.did === currentAccount?.did - const onCopyMessage = React.useCallback(() => { + const onCopyMessage = useCallback(() => { const str = richTextToString( new RichText({ text: message.text, @@ -54,7 +55,7 @@ export let MessageContextMenu = ({ Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') }, [_, message.text, message.facets]) - const onPressTranslateMessage = React.useCallback(() => { + const onPressTranslateMessage = useCallback(() => { const translatorUrl = getTranslatorLink( message.text, langPrefs.primaryLanguage, @@ -62,7 +63,7 @@ export let MessageContextMenu = ({ openLink(translatorUrl, true) }, [langPrefs.primaryLanguage, message.text, openLink]) - const onDelete = React.useCallback(() => { + const onDelete = useCallback(() => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) convo .deleteMessage(message.id) @@ -72,6 +73,30 @@ export let MessageContextMenu = ({ .catch(() => Toast.show(_(msg`Failed to delete message`))) }, [_, convo, message.id]) + const onEmojiSelect = useCallback( + (emoji: string) => { + if ( + message.reactions?.find( + reaction => + reaction.value === emoji && + reaction.sender.did === currentAccount?.did, + ) + ) { + convo + .removeReaction(message.id, emoji) + .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) + } else { + if (hasReachedReactionLimit(message, currentAccount?.did)) return + convo + .addReaction(message.id, emoji) + .catch(() => + Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'), + ) + } + }, + [_, convo, message, currentAccount?.did], + ) + const sender = convo.convo.members.find( member => member.did === message.sender.did, ) @@ -81,7 +106,10 @@ export let MessageContextMenu = ({ <ContextMenu.Root> {isNative && ( <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> - <EmojiReactionPicker message={message} /> + <EmojiReactionPicker + message={message} + onEmojiSelect={onEmojiSelect} + /> </ContextMenu.AuxiliaryView> )} @@ -156,4 +184,4 @@ export let MessageContextMenu = ({ </> ) } -MessageContextMenu = React.memo(MessageContextMenu) +MessageContextMenu = memo(MessageContextMenu) diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 675b6a5ee..60b0b5ed6 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,23 +1,36 @@ import React, {useCallback, useMemo} from 'react' -import {GestureResponderEvent, StyleProp, TextStyle, View} from 'react-native' +import { + type GestureResponderEvent, + type StyleProp, + type TextStyle, + View, +} from 'react-native' +import Animated, { + LayoutAnimationConfig, + LinearTransition, + ZoomIn, + ZoomOut, +} from 'react-native-reanimated' import { AppBskyEmbedRecord, ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' -import {I18n} from '@lingui/core' +import {type I18n} from '@lingui/core' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {ConvoItem} from '#/state/messages/convo/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {useConvoActive} from '#/state/messages/convo' +import {type ConvoItem} from '#/state/messages/convo/types' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, native, useTheme} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' +import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {RichText} from '../RichText' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' import {localDateString} from './util' @@ -29,6 +42,8 @@ let MessageItem = ({ }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() + const {_} = useLingui() + const {convo} = useConvoActive() const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' @@ -134,6 +149,74 @@ let MessageItem = ({ )} </ActionsWrapper> + <LayoutAnimationConfig skipEntering skipExiting> + {message.reactions && message.reactions.length > 0 && ( + <View + style={[ + isFromSelf ? a.align_end : a.align_start, + a.px_xs, + a.pb_2xs, + ]}> + <View + style={[ + a.flex_row, + a.gap_2xs, + a.py_xs, + a.px_xs, + a.justify_center, + isFromSelf ? a.justify_end : a.justify_start, + a.flex_wrap, + a.pb_xs, + t.atoms.bg, + a.rounded_lg, + a.border, + t.atoms.border_contrast_low, + {marginTop: -6}, + ]}> + {message.reactions.map((reaction, _i, reactions) => { + let label + if (reaction.sender.did === currentAccount?.did) { + label = _(msg`You reacted ${reaction.value}`) + } else { + const senderDid = reaction.sender.did + const sender = convo.members.find( + member => member.did === senderDid, + ) + if (sender) { + label = _( + msg`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${reaction.value}`, + ) + } else { + label = _(msg`Someone reacted ${reaction.value}`) + } + } + return ( + <Animated.View + entering={native(ZoomIn.springify(200).delay(400))} + exiting={ + reactions.length > 1 && native(ZoomOut.delay(200)) + } + layout={native(LinearTransition.delay(300))} + key={reaction.sender.did + reaction.value} + style={[a.p_2xs]} + accessible={true} + accessibilityLabel={label} + accessibilityHint={_( + msg`Double tap or long press the message to add a reaction`, + )}> + <Text emoji style={[a.text_sm]}> + {reaction.value} + </Text> + </Animated.View> + ) + })} + </View> + </View> + )} + </LayoutAnimationConfig> + {isLastInGroup && ( <MessageItemMetadata item={item} diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 7315f5fc9..2bcc9c3bd 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -1,4 +1,7 @@ -import * as bsky from '#/types/bsky' +import {type ChatBskyConvoDefs} from '@atproto/api' + +import {EMOJI_REACTION_LIMIT} from '#/lib/constants' +import type * as bsky from '#/types/bsky' export function canBeMessaged(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowIncoming) { @@ -25,3 +28,29 @@ export function localDateString(date: Date) { // not padding with 0s because it's not necessary, it's just used for comparison return `${yyyy}-${mm}-${dd}` } + +export function hasAlreadyReacted( + message: ChatBskyConvoDefs.MessageView, + myDid: string | undefined, + emoji: string, +): boolean { + if (!message.reactions) { + return false + } + return !!message.reactions.find( + reaction => reaction.value === emoji && reaction.sender.did === myDid, + ) +} + +export function hasReachedReactionLimit( + message: ChatBskyConvoDefs.MessageView, + myDid: string | undefined, +): boolean { + if (!message.reactions) { + return false + } + const myReactions = message.reactions.filter( + reaction => reaction.sender.did === myDid, + ) + return myReactions.length >= EMOJI_REACTION_LIMIT +} |