diff options
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/alf/atoms.ts | 13 | ||||
-rw-r--r-- | src/alf/tokens.ts | 1 | ||||
-rw-r--r-- | src/components/ContextMenu/index.tsx | 5 | ||||
-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 | ||||
-rw-r--r-- | src/lib/constants.ts | 6 | ||||
-rw-r--r-- | src/screens/Messages/components/ChatListItem.tsx | 57 | ||||
-rw-r--r-- | src/state/messages/convo/agent.ts | 192 | ||||
-rw-r--r-- | src/state/messages/convo/index.tsx | 14 | ||||
-rw-r--r-- | src/state/messages/convo/types.ts | 26 | ||||
-rw-r--r-- | src/state/queries/messages/list-conversations.tsx | 67 | ||||
-rw-r--r-- | yarn.lock | 38 |
17 files changed, 659 insertions, 123 deletions
diff --git a/package.json b/package.json index 191005c58..b903b1410 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.14.7", + "@atproto/api": "^0.14.14", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 69ac0c057..5a3e6d675 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1,4 +1,9 @@ -import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native' +import { + Platform, + type StyleProp, + StyleSheet, + type ViewStyle, +} from 'react-native' import * as tokens from '#/alf/tokens' import {ios, native, platform, web} from '#/alf/util/platform' @@ -126,6 +131,9 @@ export const atoms = { rounded_md: { borderRadius: tokens.borderRadius.md, }, + rounded_lg: { + borderRadius: tokens.borderRadius.lg, + }, rounded_full: { borderRadius: tokens.borderRadius.full, }, @@ -358,6 +366,9 @@ export const atoms = { border_r: { borderRightWidth: StyleSheet.hairlineWidth, }, + border_transparent: { + borderColor: 'transparent', + }, curve_circular: ios({ borderCurve: 'circular', }), diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 1d99e5e57..576aafe95 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -44,6 +44,7 @@ export const borderRadius = { xs: 4, sm: 8, md: 12, + lg: 16, full: 999, } as const diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 90c448782..aebed6419 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -775,7 +775,10 @@ export function Item({ ]}> <ItemContext.Provider value={itemContext}> {typeof children === 'function' - ? children(focused || pressed || context.hoveredMenuItem === id) + ? children( + (focused || pressed || context.hoveredMenuItem === id) && + !rest.disabled, + ) : children} </ItemContext.Provider> </Pressable> 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 +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a105de050..fe84f41b2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,5 @@ -import {Insets, Platform} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {type Insets, Platform} from 'react-native' +import {type AppBskyActorDefs} from '@atproto/api' export const LOCAL_DEV_SERVICE = Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' @@ -190,3 +190,5 @@ export const SUPPORTED_MIME_TYPES = [ ] as const export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number] + +export const EMOJI_REACTION_LIMIT = 5 diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 96e010b8f..d8e4b975c 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -1,10 +1,10 @@ import React, {useCallback, useMemo, useState} from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import { AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, - ModerationOpts, + type ModerationOpts, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -43,7 +43,7 @@ import {Link} from '#/components/Link' import {useMenuControl} from '#/components/Menu' import {PostAlerts} from '#/components/moderation/PostAlerts' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' export let ChatListItem = ({ convo, @@ -189,13 +189,62 @@ function ChatListItemReady({ ? _(msg`Conversation deleted`) : _(msg`Message deleted`) } + if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastMessage)) { + const isFromMe = + convo.lastMessage.reaction.sender.did === currentAccount?.did + const lastMessageText = convo.lastMessage.message.text + const fallbackMessage = _( + msg({ + message: 'a message', + comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, + }), + ) + + if (isFromMe) { + lastMessage = _( + msg`You reacted ${convo.lastMessage.reaction.value} to ${ + lastMessageText + ? `"${convo.lastMessage.message.text}"` + : fallbackMessage + }`, + ) + } else { + const senderDid = convo.lastMessage.reaction.sender.did + const sender = convo.members.find(member => member.did === senderDid) + if (sender) { + lastMessage = _( + msg`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${convo.lastMessage.reaction.value} to ${ + lastMessageText + ? `"${convo.lastMessage.message.text}"` + : fallbackMessage + }`, + ) + } else { + lastMessage = _( + msg`Someone reacted ${convo.lastMessage.reaction.value} to ${ + lastMessageText + ? `"${convo.lastMessage.message.text}"` + : fallbackMessage + }`, + ) + } + } + } return { lastMessage, lastMessageSentAt, latestReportableMessage, } - }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) + }, [ + _, + convo.lastMessage, + currentAccount?.did, + isDeletedAccount, + convo.members, + ]) const [showActions, setShowActions] = useState(false) diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index f6a8d6dc4..909213975 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -1,9 +1,9 @@ import { - BskyAgent, - ChatBskyActorDefs, + type AtpAgent, + type ChatBskyActorDefs, ChatBskyConvoDefs, - ChatBskyConvoGetLog, - ChatBskyConvoSendMessage, + type ChatBskyConvoGetLog, + type ChatBskyConvoSendMessage, } from '@atproto/api' import {XRPCError} from '@atproto/xrpc' import EventEmitter from 'eventemitter3' @@ -19,19 +19,19 @@ import { NETWORK_FAILURE_STATUSES, } from '#/state/messages/convo/const' import { - ConvoDispatch, + type ConvoDispatch, ConvoDispatchEvent, - ConvoError, + type ConvoError, ConvoErrorCode, - ConvoEvent, - ConvoItem, + type ConvoEvent, + type ConvoItem, ConvoItemError, - ConvoParams, - ConvoState, + type ConvoParams, + type ConvoState, ConvoStatus, } from '#/state/messages/convo/types' -import {MessagesEventBus} from '#/state/messages/events/agent' -import {MessagesEventBusError} from '#/state/messages/events/types' +import {type MessagesEventBus} from '#/state/messages/events/agent' +import {type MessagesEventBusError} from '#/state/messages/events/types' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' const logger = Logger.create(Logger.Context.ConversationAgent) @@ -50,7 +50,7 @@ export function isConvoItemMessage( export class Convo { private id: string - private agent: BskyAgent + private agent: AtpAgent private events: MessagesEventBus private senderUserDid: string @@ -106,6 +106,8 @@ export class Convo { this.onFirehoseConnect = this.onFirehoseConnect.bind(this) this.onFirehoseError = this.onFirehoseError.bind(this) this.markConvoAccepted = this.markConvoAccepted.bind(this) + this.addReaction = this.addReaction.bind(this) + this.removeReaction = this.removeReaction.bind(this) } private commit() { @@ -147,6 +149,8 @@ export class Convo { sendMessage: undefined, fetchMessageHistory: undefined, markConvoAccepted: undefined, + addReaction: undefined, + removeReaction: undefined, } } case ConvoStatus.Disabled: @@ -165,6 +169,8 @@ export class Convo { sendMessage: this.sendMessage, fetchMessageHistory: this.fetchMessageHistory, markConvoAccepted: this.markConvoAccepted, + addReaction: this.addReaction, + removeReaction: this.removeReaction, } } case ConvoStatus.Error: { @@ -180,6 +186,8 @@ export class Convo { sendMessage: undefined, fetchMessageHistory: undefined, markConvoAccepted: undefined, + addReaction: undefined, + removeReaction: undefined, } } default: { @@ -195,6 +203,8 @@ export class Convo { sendMessage: undefined, fetchMessageHistory: undefined, markConvoAccepted: undefined, + addReaction: undefined, + removeReaction: undefined, } } } @@ -760,6 +770,22 @@ export class Convo { this.deletedMessages.delete(ev.message.id) needsCommit = true } + } else if ( + (ChatBskyConvoDefs.isLogAddReaction(ev) || + ChatBskyConvoDefs.isLogRemoveReaction(ev)) && + ChatBskyConvoDefs.isMessageView(ev.message) + ) { + /* + * Update if we have this in state - replace message wholesale. If we don't, don't worry about it. + */ + if (this.pastMessages.has(ev.message.id)) { + this.pastMessages.set(ev.message.id, ev.message) + needsCommit = true + } + if (this.newMessages.has(ev.message.id)) { + this.newMessages.set(ev.message.id, ev.message) + needsCommit = true + } } } } @@ -1141,4 +1167,144 @@ export class Convo { return item }) } + + /** + * Add an emoji reaction to a message + * + * @param messageId - the id of the message to add the reaction to + * @param emoji - must be one grapheme + */ + async addReaction(messageId: string, emoji: string) { + const optimisticReaction = { + value: emoji, + sender: {did: this.senderUserDid}, + createdAt: new Date().toISOString(), + } + let restore: null | (() => void) = null + if (this.pastMessages.has(messageId)) { + const prevMessage = this.pastMessages.get(messageId) + if ( + ChatBskyConvoDefs.isMessageView(prevMessage) && + // skip optimistic update if reaction already exists + !prevMessage.reactions?.find( + reaction => + reaction.sender.did === this.senderUserDid && + reaction.value === emoji, + ) + ) { + if (prevMessage.reactions) { + if ( + prevMessage.reactions.filter( + reaction => reaction.sender.did === this.senderUserDid, + ).length >= 5 + ) { + throw new Error('Maximum reactions reached') + } + } + this.pastMessages.set(messageId, { + ...prevMessage, + reactions: [...(prevMessage.reactions ?? []), optimisticReaction], + }) + this.commit() + restore = () => { + this.pastMessages.set(messageId, prevMessage) + this.commit() + } + } + } else if (this.newMessages.has(messageId)) { + const prevMessage = this.newMessages.get(messageId) + if ( + ChatBskyConvoDefs.isMessageView(prevMessage) && + !prevMessage.reactions?.find(reaction => reaction.value === emoji) + ) { + if (prevMessage.reactions && prevMessage.reactions.length >= 5) + throw new Error('Maximum reactions reached') + this.newMessages.set(messageId, { + ...prevMessage, + reactions: [...(prevMessage.reactions ?? []), optimisticReaction], + }) + this.commit() + restore = () => { + this.newMessages.set(messageId, prevMessage) + this.commit() + } + } + } + + try { + logger.info(`Adding reaction ${emoji} to message ${messageId}`) + const {data} = await this.agent.chat.bsky.convo.addReaction( + {messageId, value: emoji, convoId: this.convoId}, + {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, + ) + if (ChatBskyConvoDefs.isMessageView(data.message)) { + if (this.pastMessages.has(messageId)) { + this.pastMessages.set(messageId, data.message) + this.commit() + } else if (this.newMessages.has(messageId)) { + this.newMessages.set(messageId, data.message) + this.commit() + } + } + } catch (error) { + if (restore) restore() + throw error + } + } + + /* + * Remove a reaction from a message. + * + * @param messageId - The ID of the message to remove the reaction from. + * @param emoji - The emoji to remove. + */ + async removeReaction(messageId: string, emoji: string) { + let restore: null | (() => void) = null + if (this.pastMessages.has(messageId)) { + const prevMessage = this.pastMessages.get(messageId) + if (ChatBskyConvoDefs.isMessageView(prevMessage)) { + this.pastMessages.set(messageId, { + ...prevMessage, + reactions: prevMessage.reactions?.filter( + reaction => + reaction.value !== emoji || + reaction.sender.did !== this.senderUserDid, + ), + }) + this.commit() + restore = () => { + this.pastMessages.set(messageId, prevMessage) + this.commit() + } + } + } else if (this.newMessages.has(messageId)) { + const prevMessage = this.newMessages.get(messageId) + if (ChatBskyConvoDefs.isMessageView(prevMessage)) { + this.newMessages.set(messageId, { + ...prevMessage, + reactions: prevMessage.reactions?.filter( + reaction => + reaction.value !== emoji || + reaction.sender.did !== this.senderUserDid, + ), + }) + this.commit() + restore = () => { + this.newMessages.set(messageId, prevMessage) + this.commit() + } + } + } + + try { + logger.info(`Removing reaction ${emoji} from message ${messageId}`) + await this.agent.chat.bsky.convo.removeReaction( + {messageId, value: emoji, convoId: this.convoId}, + {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, + ) + } catch (error) { + if (restore) restore() + throw error + } + } } diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index f004566e8..a53f08900 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -1,17 +1,17 @@ import React, {useContext, useState, useSyncExternalStore} from 'react' -import {ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyConvoDefs} from '@atproto/api' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useAppState} from '#/lib/hooks/useAppState' import {Convo} from '#/state/messages/convo/agent' import { - ConvoParams, - ConvoState, - ConvoStateBackgrounded, - ConvoStateDisabled, - ConvoStateReady, - ConvoStateSuspended, + type ConvoParams, + type ConvoState, + type ConvoStateBackgrounded, + type ConvoStateDisabled, + type ConvoStateReady, + type ConvoStateSuspended, } from '#/state/messages/convo/types' import {isConvoActive} from '#/state/messages/convo/util' import {useMessagesEventBus} from '#/state/messages/events' diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 83499de2e..705387793 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -1,11 +1,11 @@ import { - BskyAgent, - ChatBskyActorDefs, - ChatBskyConvoDefs, - ChatBskyConvoSendMessage, + type BskyAgent, + type ChatBskyActorDefs, + type ChatBskyConvoDefs, + type ChatBskyConvoSendMessage, } from '@atproto/api' -import {MessagesEventBus} from '#/state/messages/events/agent' +import {type MessagesEventBus} from '#/state/messages/events/agent' export type ConvoParams = { convoId: string @@ -142,6 +142,8 @@ type SendMessage = ( ) => void type FetchMessageHistory = () => Promise<void> type MarkConvoAccepted = () => void +type AddReaction = (messageId: string, reaction: string) => Promise<void> +type RemoveReaction = (messageId: string, reaction: string) => Promise<void> export type ConvoStateUninitialized = { status: ConvoStatus.Uninitialized @@ -155,6 +157,8 @@ export type ConvoStateUninitialized = { sendMessage: undefined fetchMessageHistory: undefined markConvoAccepted: undefined + addReaction: undefined + removeReaction: undefined } export type ConvoStateInitializing = { status: ConvoStatus.Initializing @@ -168,6 +172,8 @@ export type ConvoStateInitializing = { sendMessage: undefined fetchMessageHistory: undefined markConvoAccepted: undefined + addReaction: undefined + removeReaction: undefined } export type ConvoStateReady = { status: ConvoStatus.Ready @@ -181,6 +187,8 @@ export type ConvoStateReady = { sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted + addReaction: AddReaction + removeReaction: RemoveReaction } export type ConvoStateBackgrounded = { status: ConvoStatus.Backgrounded @@ -194,6 +202,8 @@ export type ConvoStateBackgrounded = { sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted + addReaction: AddReaction + removeReaction: RemoveReaction } export type ConvoStateSuspended = { status: ConvoStatus.Suspended @@ -207,6 +217,8 @@ export type ConvoStateSuspended = { sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted + addReaction: AddReaction + removeReaction: RemoveReaction } export type ConvoStateError = { status: ConvoStatus.Error @@ -220,6 +232,8 @@ export type ConvoStateError = { sendMessage: undefined fetchMessageHistory: undefined markConvoAccepted: undefined + addReaction: undefined + removeReaction: undefined } export type ConvoStateDisabled = { status: ConvoStatus.Disabled @@ -233,6 +247,8 @@ export type ConvoStateDisabled = { sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted + addReaction: AddReaction + removeReaction: RemoveReaction } export type ConvoState = | ConvoStateUninitialized diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index f5fce6347..066c25e21 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -1,19 +1,13 @@ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, -} from 'react' +import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import { ChatBskyConvoDefs, - ChatBskyConvoListConvos, + type ChatBskyConvoListConvos, moderateProfile, - ModerationOpts, + type ModerationOpts, } from '@atproto/api' import { - InfiniteData, - QueryClient, + type InfiniteData, + type QueryClient, useInfiniteQuery, useQueryClient, } from '@tanstack/react-query' @@ -316,6 +310,57 @@ export function ListConvosProviderInner({ rev: logRef.rev, })), ) + } else if (ChatBskyConvoDefs.isLogAddReaction(log)) { + const logRef: ChatBskyConvoDefs.LogAddReaction = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ + ...convo, + lastMessage: { + $type: 'chat.bsky.convo.defs#messageAndReactionView', + reaction: logRef.reaction, + message: logRef.message, + }, + rev: logRef.rev, + })), + ) + } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) { + if (ChatBskyConvoDefs.isMessageView(log.message)) { + for (const [_queryKey, queryData] of queryClient.getQueriesData< + InfiniteData<ChatBskyConvoListConvos.OutputSchema> + >({ + queryKey: [RQKEY_ROOT], + })) { + if (!queryData?.pages) { + continue + } + + for (const page of queryData.pages) { + for (const convo of page.convos) { + if ( + // if the convo is the same + log.convoId === convo.id && + ChatBskyConvoDefs.isMessageAndReactionView( + convo.lastMessage, + ) && + ChatBskyConvoDefs.isMessageView( + convo.lastMessage.message, + ) && + // ...and the message is the same + convo.lastMessage.message.id === log.message.id && + // ...and the reaction is the same + convo.lastMessage.reaction.sender.did === + log.reaction.sender.did && + convo.lastMessage.reaction.value === log.reaction.value + ) { + // refetch, because we don't know what the last message is now + debouncedRefetch() + } + } + } + } + } } } }, diff --git a/yarn.lock b/yarn.lock index f27a845a1..1e0a57607 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,15 +80,15 @@ tlds "^1.234.0" zod "^3.23.8" -"@atproto/api@^0.14.7": - version "0.14.7" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.7.tgz#3ffa02d6b3baf9e265dab170367ffade08023567" - integrity sha512-YG2kvAtsgtajLlLrorYuHcxGgepG0c/RUB2/iJyBnwKjGqDLG8joOETf38JSNiGzs6NJbNKa9NHG6BQKourxBA== +"@atproto/api@^0.14.14": + version "0.14.14" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.14.tgz#5d2d2e6156eab6ca0d463c114b4a3865275e9aac" + integrity sha512-ryawcnmazVSWYfq11ujPHauY77GfkM3mF0rZOkqENN2Ptnl6BZXJvpA0zLA/sQ5YBLcHXSEWg5Xdq+8i1l+8gA== dependencies: "@atproto/common-web" "^0.4.0" - "@atproto/lexicon" "^0.4.7" - "@atproto/syntax" "^0.3.3" - "@atproto/xrpc" "^0.6.9" + "@atproto/lexicon" "^0.4.9" + "@atproto/syntax" "^0.4.0" + "@atproto/xrpc" "^0.6.11" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -302,6 +302,17 @@ multiformats "^9.9.0" zod "^3.23.8" +"@atproto/lexicon@^0.4.9": + version "0.4.9" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.9.tgz#612951a85ecc1398366bd837cda6be89440f179d" + integrity sha512-/tmEuHQFr51V2V7EAVJzaA40sqJ7ylAZpR962VbOsPtmcdOHvezbjVHYEMXgfb927hS+xqbVyzBTbu5w9v8prA== + dependencies: + "@atproto/common-web" "^0.4.0" + "@atproto/syntax" "^0.4.0" + iso-datestring-validator "^2.2.2" + multiformats "^9.9.0" + zod "^3.23.8" + "@atproto/oauth-provider@^0.2.17": version "0.2.17" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.17.tgz#4644d391eedbbbbe5825ecc0e8cc03f1c6433b95" @@ -446,6 +457,11 @@ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.3.tgz#6debe8983985378104822172a128e429931bf3f7" integrity sha512-F1LZweesNYdBbZBXVa72N/cSvchG8Q1tG4/209ZXbIuM3FwQtkgn+zgmmV4P4ORmhOeXPBNXvMBpcqiwx/gEQQ== +"@atproto/syntax@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" + integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== + "@atproto/xrpc-server@^0.7.11": version "0.7.11" resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.11.tgz#efadcfdaaaa0ff5576d1ee97e46dcbc6dafcb0b6" @@ -464,6 +480,14 @@ ws "^8.12.0" zod "^3.23.8" +"@atproto/xrpc@^0.6.11": + version "0.6.11" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.11.tgz#54c527e39a2f5ddd2655b11f7cb99b8f303d8364" + integrity sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA== + dependencies: + "@atproto/lexicon" "^0.4.9" + zod "^3.23.8" + "@atproto/xrpc@^0.6.9": version "0.6.9" resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.9.tgz#6e1effc42cdab40741a73ead5c276183284887d2" |