Bluesky app fork with some witchin' additions 💫

[DMs] Reactions - link up API (attempt 2) (#8074)

* update package

* wire up APIs

* get reactions to display

* allow removing emoji

* handle limits better

* listen to reactions in log

* update convo list with reactions

* tweaks to reaction display

* Handle empty message fallback case

* update package

* shift reacts up by 2px

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Eric Bailey and committed by
GitHub
152bc3c1 55a40c24

+659 -123
+1 -1
package.json
··· 58 58 "icons:optimize": "svgo -f ./assets/icons" 59 59 }, 60 60 "dependencies": { 61 - "@atproto/api": "^0.14.7", 61 + "@atproto/api": "^0.14.14", 62 62 "@bitdrift/react-native": "^0.6.8", 63 63 "@braintree/sanitize-url": "^6.0.2", 64 64 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+12 -1
src/alf/atoms.ts
··· 1 - import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native' 1 + import { 2 + Platform, 3 + type StyleProp, 4 + StyleSheet, 5 + type ViewStyle, 6 + } from 'react-native' 2 7 3 8 import * as tokens from '#/alf/tokens' 4 9 import {ios, native, platform, web} from '#/alf/util/platform' ··· 125 130 }, 126 131 rounded_md: { 127 132 borderRadius: tokens.borderRadius.md, 133 + }, 134 + rounded_lg: { 135 + borderRadius: tokens.borderRadius.lg, 128 136 }, 129 137 rounded_full: { 130 138 borderRadius: tokens.borderRadius.full, ··· 357 365 }, 358 366 border_r: { 359 367 borderRightWidth: StyleSheet.hairlineWidth, 368 + }, 369 + border_transparent: { 370 + borderColor: 'transparent', 360 371 }, 361 372 curve_circular: ios({ 362 373 borderCurve: 'circular',
+1
src/alf/tokens.ts
··· 44 44 xs: 4, 45 45 sm: 8, 46 46 md: 12, 47 + lg: 16, 47 48 full: 999, 48 49 } as const 49 50
+4 -1
src/components/ContextMenu/index.tsx
··· 775 775 ]}> 776 776 <ItemContext.Provider value={itemContext}> 777 777 {typeof children === 'function' 778 - ? children(focused || pressed || context.hoveredMenuItem === id) 778 + ? children( 779 + (focused || pressed || context.hoveredMenuItem === id) && 780 + !rest.disabled, 781 + ) 779 782 : children} 780 783 </ItemContext.Provider> 781 784 </Pressable>
+42 -8
src/components/dms/ActionsWrapper.web.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useRef, useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 - import {ChatBskyConvoDefs} from '@atproto/api' 3 + import {type ChatBskyConvoDefs} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import type React from 'react' 4 7 8 + import {useConvoActive} from '#/state/messages/convo' 9 + import {useSession} from '#/state/session' 10 + import * as Toast from '#/view/com/util/Toast' 5 11 import {atoms as a, useTheme} from '#/alf' 6 12 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 13 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 8 14 import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 9 15 import {EmojiReactionPicker} from './EmojiReactionPicker' 16 + import {hasReachedReactionLimit} from './util' 10 17 11 18 export function ActionsWrapper({ 12 19 message, ··· 17 24 isFromSelf: boolean 18 25 children: React.ReactNode 19 26 }) { 20 - const viewRef = React.useRef(null) 27 + const viewRef = useRef(null) 21 28 const t = useTheme() 29 + const {_} = useLingui() 30 + const convo = useConvoActive() 31 + const {currentAccount} = useSession() 22 32 23 - const [showActions, setShowActions] = React.useState(false) 33 + const [showActions, setShowActions] = useState(false) 24 34 25 - const onMouseEnter = React.useCallback(() => { 35 + const onMouseEnter = useCallback(() => { 26 36 setShowActions(true) 27 37 }, []) 28 38 29 - const onMouseLeave = React.useCallback(() => { 39 + const onMouseLeave = useCallback(() => { 30 40 setShowActions(false) 31 41 }, []) 32 42 33 43 // We need to handle the `onFocus` separately because we want to know if there is a related target (the element 34 44 // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed. 35 - const onFocus = React.useCallback<React.FocusEventHandler>(e => { 45 + const onFocus = useCallback<React.FocusEventHandler>(e => { 36 46 if (e.nativeEvent.relatedTarget == null) return 37 47 setShowActions(true) 38 48 }, []) 39 49 50 + const onEmojiSelect = useCallback( 51 + (emoji: string) => { 52 + if ( 53 + message.reactions?.find( 54 + reaction => 55 + reaction.value === emoji && 56 + reaction.sender.did === currentAccount?.did, 57 + ) 58 + ) { 59 + convo 60 + .removeReaction(message.id, emoji) 61 + .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) 62 + } else { 63 + if (hasReachedReactionLimit(message, currentAccount?.did)) return 64 + convo 65 + .addReaction(message.id, emoji) 66 + .catch(() => 67 + Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'), 68 + ) 69 + } 70 + }, 71 + [_, convo, message, currentAccount?.did], 72 + ) 73 + 40 74 return ( 41 75 <View 42 76 // @ts-expect-error web only ··· 56 90 ? [a.mr_md, {marginLeft: 'auto'}] 57 91 : [a.ml_md, {marginRight: 'auto'}], 58 92 ]}> 59 - <EmojiReactionPicker message={message}> 93 + <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}> 60 94 {({props, state, isNative, control}) => { 61 95 // always false, file is platform split 62 96 if (isNative) return null
+46 -29
src/components/dms/EmojiReactionPicker.tsx
··· 1 1 import {useMemo, useState} from 'react' 2 - import {Alert, useWindowDimensions, View} from 'react-native' 2 + import {useWindowDimensions, View} from 'react-native' 3 3 import {type ChatBskyConvoDefs} from '@atproto/api' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' ··· 18 18 import {type TriggerProps} from '#/components/Menu/types' 19 19 import {Text} from '#/components/Typography' 20 20 import {EmojiPopup} from './EmojiPopup' 21 + import {hasAlreadyReacted, hasReachedReactionLimit} from './util' 21 22 22 23 export function EmojiReactionPicker({ 23 24 message, 25 + onEmojiSelect, 24 26 }: { 25 27 message: ChatBskyConvoDefs.MessageView 26 28 children?: TriggerProps['children'] 29 + onEmojiSelect: (emoji: string) => void 27 30 }) { 28 31 const {_} = useLingui() 29 32 const {currentAccount} = useSession() ··· 39 42 return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon 40 43 }, []) 41 44 42 - const handleEmojiSelect = (emoji: string) => { 43 - Alert.alert(emoji) 44 - } 45 - 46 45 const position = useMemo(() => { 47 46 return { 48 47 x: align === 'left' ? 12 : screenWidth - layout.width - 12, ··· 52 51 } 53 52 }, [measurement, align, screenWidth, layout]) 54 53 54 + const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) 55 + 55 56 return ( 56 57 <View 57 58 onLayout={evt => setLayout(evt.nativeEvent.layout)} ··· 70 71 t.atoms.border_contrast_low, 71 72 a.shadow_md, 72 73 ]}> 73 - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( 74 - <ContextMenu.Item 75 - position={position} 76 - label={_(msg`React with ${emoji}`)} 77 - key={emoji} 78 - onPress={() => handleEmojiSelect(emoji)} 79 - unstyled> 80 - {hovered => ( 81 - <View 82 - style={[ 83 - a.rounded_full, 84 - hovered && {backgroundColor: t.palette.primary_500}, 85 - {height: 40, width: 40}, 86 - a.justify_center, 87 - a.align_center, 88 - ]}> 89 - <Text style={[a.text_center, {fontSize: 30}]} emoji> 90 - {emoji} 91 - </Text> 92 - </View> 93 - )} 94 - </ContextMenu.Item> 95 - ))} 74 + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => { 75 + const alreadyReacted = hasAlreadyReacted( 76 + message, 77 + currentAccount?.did, 78 + emoji, 79 + ) 80 + return ( 81 + <ContextMenu.Item 82 + position={position} 83 + label={_(msg`React with ${emoji}`)} 84 + key={emoji} 85 + onPress={() => onEmojiSelect(emoji)} 86 + unstyled 87 + disabled={limitReacted ? !alreadyReacted : false}> 88 + {hovered => ( 89 + <View 90 + style={[ 91 + a.rounded_full, 92 + hovered 93 + ? { 94 + backgroundColor: alreadyReacted 95 + ? t.palette.negative_100 96 + : t.palette.primary_500, 97 + } 98 + : alreadyReacted && { 99 + backgroundColor: t.palette.primary_200, 100 + }, 101 + {height: 40, width: 40}, 102 + a.justify_center, 103 + a.align_center, 104 + ]}> 105 + <Text style={[a.text_center, {fontSize: 30}]} emoji> 106 + {emoji} 107 + </Text> 108 + </View> 109 + )} 110 + </ContextMenu.Item> 111 + ) 112 + })} 96 113 <EmojiPopup 97 114 onEmojiSelected={emoji => { 98 115 close() 99 - handleEmojiSelect(emoji) 116 + onEmojiSelect(emoji) 100 117 }}> 101 118 <View 102 119 style={[
+50 -22
src/components/dms/EmojiReactionPicker.web.tsx
··· 1 1 import {useState} from 'react' 2 2 import {View} from 'react-native' 3 - import {ChatBskyConvoDefs} from '@atproto/api' 3 + import {type ChatBskyConvoDefs} from '@atproto/api' 4 4 import EmojiPicker from '@emoji-mart/react' 5 5 import {msg} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web' 8 + import {useSession} from '#/state/session' 9 + import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web' 9 10 import {PressableWithHover} from '#/view/com/util/PressableWithHover' 10 11 import {atoms as a} from '#/alf' 11 12 import {useTheme} from '#/alf' 12 13 import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 13 14 import * as Menu from '#/components/Menu' 14 - import {TriggerProps} from '#/components/Menu/types' 15 + import {type TriggerProps} from '#/components/Menu/types' 15 16 import {Text} from '#/components/Typography' 17 + import {hasAlreadyReacted, hasReachedReactionLimit} from './util' 16 18 17 19 export function EmojiReactionPicker({ 20 + message, 18 21 children, 22 + onEmojiSelect, 19 23 }: { 20 24 message: ChatBskyConvoDefs.MessageView 21 25 children?: TriggerProps['children'] 26 + onEmojiSelect: (emoji: string) => void 22 27 }) { 23 28 if (!children) 24 29 throw new Error('EmojiReactionPicker requires the children prop on web') ··· 29 34 <Menu.Root> 30 35 <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger> 31 36 <Menu.Outer> 32 - <MenuInner /> 37 + <MenuInner message={message} onEmojiSelect={onEmojiSelect} /> 33 38 </Menu.Outer> 34 39 </Menu.Root> 35 40 ) 36 41 } 37 42 38 - function MenuInner() { 43 + function MenuInner({ 44 + message, 45 + onEmojiSelect, 46 + }: { 47 + message: ChatBskyConvoDefs.MessageView 48 + onEmojiSelect: (emoji: string) => void 49 + }) { 39 50 const t = useTheme() 40 51 const {control} = Menu.useMenuContext() 52 + const {currentAccount} = useSession() 41 53 42 54 const [expanded, setExpanded] = useState(false) 43 55 ··· 47 59 48 60 const handleEmojiSelect = (emoji: string) => { 49 61 control.close() 50 - window.alert(emoji) 62 + onEmojiSelect(emoji) 51 63 } 64 + 65 + const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) 52 66 53 67 return expanded ? ( 54 68 <EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} /> 55 69 ) : ( 56 70 <View style={[a.flex_row, a.gap_xs]}> 57 - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( 58 - <PressableWithHover 59 - key={emoji} 60 - onPress={() => handleEmojiSelect(emoji)} 61 - hoverStyle={{backgroundColor: t.palette.primary_100}} 62 - style={[ 63 - a.rounded_xs, 64 - {height: 40, width: 40}, 65 - a.justify_center, 66 - a.align_center, 67 - ]}> 68 - <Text style={[a.text_center, {fontSize: 30}]} emoji> 69 - {emoji} 70 - </Text> 71 - </PressableWithHover> 72 - ))} 71 + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => { 72 + const alreadyReacted = hasAlreadyReacted( 73 + message, 74 + currentAccount?.did, 75 + emoji, 76 + ) 77 + return ( 78 + <PressableWithHover 79 + key={emoji} 80 + onPress={() => handleEmojiSelect(emoji)} 81 + hoverStyle={{ 82 + backgroundColor: alreadyReacted 83 + ? t.palette.negative_200 84 + : !limitReacted 85 + ? t.palette.primary_300 86 + : undefined, 87 + }} 88 + style={[ 89 + a.rounded_xs, 90 + {height: 40, width: 40}, 91 + a.justify_center, 92 + a.align_center, 93 + alreadyReacted && {backgroundColor: t.palette.primary_100}, 94 + ]}> 95 + <Text style={[a.text_center, {fontSize: 30}]} emoji> 96 + {emoji} 97 + </Text> 98 + </PressableWithHover> 99 + ) 100 + })} 73 101 <PressableWithHover 74 102 onPress={() => setExpanded(true)} 75 103 hoverStyle={{backgroundColor: t.palette.primary_100}}
+34 -6
src/components/dms/MessageContextMenu.tsx
··· 1 - import React from 'react' 1 + import {memo, useCallback} from 'react' 2 2 import {LayoutAnimation} from 'react-native' 3 3 import * as Clipboard from 'expo-clipboard' 4 4 import {type ChatBskyConvoDefs, RichText} from '@atproto/api' ··· 23 23 import * as Prompt from '#/components/Prompt' 24 24 import {usePromptControl} from '#/components/Prompt' 25 25 import {EmojiReactionPicker} from './EmojiReactionPicker' 26 + import {hasReachedReactionLimit} from './util' 26 27 27 28 export let MessageContextMenu = ({ 28 29 message, ··· 41 42 42 43 const isFromSelf = message.sender?.did === currentAccount?.did 43 44 44 - const onCopyMessage = React.useCallback(() => { 45 + const onCopyMessage = useCallback(() => { 45 46 const str = richTextToString( 46 47 new RichText({ 47 48 text: message.text, ··· 54 55 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 55 56 }, [_, message.text, message.facets]) 56 57 57 - const onPressTranslateMessage = React.useCallback(() => { 58 + const onPressTranslateMessage = useCallback(() => { 58 59 const translatorUrl = getTranslatorLink( 59 60 message.text, 60 61 langPrefs.primaryLanguage, ··· 62 63 openLink(translatorUrl, true) 63 64 }, [langPrefs.primaryLanguage, message.text, openLink]) 64 65 65 - const onDelete = React.useCallback(() => { 66 + const onDelete = useCallback(() => { 66 67 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 67 68 convo 68 69 .deleteMessage(message.id) ··· 72 73 .catch(() => Toast.show(_(msg`Failed to delete message`))) 73 74 }, [_, convo, message.id]) 74 75 76 + const onEmojiSelect = useCallback( 77 + (emoji: string) => { 78 + if ( 79 + message.reactions?.find( 80 + reaction => 81 + reaction.value === emoji && 82 + reaction.sender.did === currentAccount?.did, 83 + ) 84 + ) { 85 + convo 86 + .removeReaction(message.id, emoji) 87 + .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) 88 + } else { 89 + if (hasReachedReactionLimit(message, currentAccount?.did)) return 90 + convo 91 + .addReaction(message.id, emoji) 92 + .catch(() => 93 + Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'), 94 + ) 95 + } 96 + }, 97 + [_, convo, message, currentAccount?.did], 98 + ) 99 + 75 100 const sender = convo.convo.members.find( 76 101 member => member.did === message.sender.did, 77 102 ) ··· 81 106 <ContextMenu.Root> 82 107 {isNative && ( 83 108 <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> 84 - <EmojiReactionPicker message={message} /> 109 + <EmojiReactionPicker 110 + message={message} 111 + onEmojiSelect={onEmojiSelect} 112 + /> 85 113 </ContextMenu.AuxiliaryView> 86 114 )} 87 115 ··· 156 184 </> 157 185 ) 158 186 } 159 - MessageContextMenu = React.memo(MessageContextMenu) 187 + MessageContextMenu = memo(MessageContextMenu)
+88 -5
src/components/dms/MessageItem.tsx
··· 1 1 import React, {useCallback, useMemo} from 'react' 2 - import {GestureResponderEvent, StyleProp, TextStyle, View} from 'react-native' 2 + import { 3 + type GestureResponderEvent, 4 + type StyleProp, 5 + type TextStyle, 6 + View, 7 + } from 'react-native' 8 + import Animated, { 9 + LayoutAnimationConfig, 10 + LinearTransition, 11 + ZoomIn, 12 + ZoomOut, 13 + } from 'react-native-reanimated' 3 14 import { 4 15 AppBskyEmbedRecord, 5 16 ChatBskyConvoDefs, 6 17 RichText as RichTextAPI, 7 18 } from '@atproto/api' 8 - import {I18n} from '@lingui/core' 19 + import {type I18n} from '@lingui/core' 9 20 import {msg} from '@lingui/macro' 10 21 import {useLingui} from '@lingui/react' 11 22 12 - import {ConvoItem} from '#/state/messages/convo/types' 23 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 24 + import {useConvoActive} from '#/state/messages/convo' 25 + import {type ConvoItem} from '#/state/messages/convo/types' 13 26 import {useSession} from '#/state/session' 14 27 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 15 - import {atoms as a, useTheme} from '#/alf' 28 + import {atoms as a, native, useTheme} from '#/alf' 16 29 import {isOnlyEmoji} from '#/alf/typography' 17 30 import {ActionsWrapper} from '#/components/dms/ActionsWrapper' 18 31 import {InlineLinkText} from '#/components/Link' 32 + import {RichText} from '#/components/RichText' 19 33 import {Text} from '#/components/Typography' 20 - import {RichText} from '../RichText' 21 34 import {DateDivider} from './DateDivider' 22 35 import {MessageItemEmbed} from './MessageItemEmbed' 23 36 import {localDateString} from './util' ··· 29 42 }): React.ReactNode => { 30 43 const t = useTheme() 31 44 const {currentAccount} = useSession() 45 + const {_} = useLingui() 46 + const {convo} = useConvoActive() 32 47 33 48 const {message, nextMessage, prevMessage} = item 34 49 const isPending = item.type === 'pending-message' ··· 133 148 </View> 134 149 )} 135 150 </ActionsWrapper> 151 + 152 + <LayoutAnimationConfig skipEntering skipExiting> 153 + {message.reactions && message.reactions.length > 0 && ( 154 + <View 155 + style={[ 156 + isFromSelf ? a.align_end : a.align_start, 157 + a.px_xs, 158 + a.pb_2xs, 159 + ]}> 160 + <View 161 + style={[ 162 + a.flex_row, 163 + a.gap_2xs, 164 + a.py_xs, 165 + a.px_xs, 166 + a.justify_center, 167 + isFromSelf ? a.justify_end : a.justify_start, 168 + a.flex_wrap, 169 + a.pb_xs, 170 + t.atoms.bg, 171 + a.rounded_lg, 172 + a.border, 173 + t.atoms.border_contrast_low, 174 + {marginTop: -6}, 175 + ]}> 176 + {message.reactions.map((reaction, _i, reactions) => { 177 + let label 178 + if (reaction.sender.did === currentAccount?.did) { 179 + label = _(msg`You reacted ${reaction.value}`) 180 + } else { 181 + const senderDid = reaction.sender.did 182 + const sender = convo.members.find( 183 + member => member.did === senderDid, 184 + ) 185 + if (sender) { 186 + label = _( 187 + msg`${sanitizeDisplayName( 188 + sender.displayName || sender.handle, 189 + )} reacted ${reaction.value}`, 190 + ) 191 + } else { 192 + label = _(msg`Someone reacted ${reaction.value}`) 193 + } 194 + } 195 + return ( 196 + <Animated.View 197 + entering={native(ZoomIn.springify(200).delay(400))} 198 + exiting={ 199 + reactions.length > 1 && native(ZoomOut.delay(200)) 200 + } 201 + layout={native(LinearTransition.delay(300))} 202 + key={reaction.sender.did + reaction.value} 203 + style={[a.p_2xs]} 204 + accessible={true} 205 + accessibilityLabel={label} 206 + accessibilityHint={_( 207 + msg`Double tap or long press the message to add a reaction`, 208 + )}> 209 + <Text emoji style={[a.text_sm]}> 210 + {reaction.value} 211 + </Text> 212 + </Animated.View> 213 + ) 214 + })} 215 + </View> 216 + </View> 217 + )} 218 + </LayoutAnimationConfig> 136 219 137 220 {isLastInGroup && ( 138 221 <MessageItemMetadata
+30 -1
src/components/dms/util.ts
··· 1 - import * as bsky from '#/types/bsky' 1 + import {type ChatBskyConvoDefs} from '@atproto/api' 2 + 3 + import {EMOJI_REACTION_LIMIT} from '#/lib/constants' 4 + import type * as bsky from '#/types/bsky' 2 5 3 6 export function canBeMessaged(profile: bsky.profile.AnyProfileView) { 4 7 switch (profile.associated?.chat?.allowIncoming) { ··· 25 28 // not padding with 0s because it's not necessary, it's just used for comparison 26 29 return `${yyyy}-${mm}-${dd}` 27 30 } 31 + 32 + export function hasAlreadyReacted( 33 + message: ChatBskyConvoDefs.MessageView, 34 + myDid: string | undefined, 35 + emoji: string, 36 + ): boolean { 37 + if (!message.reactions) { 38 + return false 39 + } 40 + return !!message.reactions.find( 41 + reaction => reaction.value === emoji && reaction.sender.did === myDid, 42 + ) 43 + } 44 + 45 + export function hasReachedReactionLimit( 46 + message: ChatBskyConvoDefs.MessageView, 47 + myDid: string | undefined, 48 + ): boolean { 49 + if (!message.reactions) { 50 + return false 51 + } 52 + const myReactions = message.reactions.filter( 53 + reaction => reaction.sender.did === myDid, 54 + ) 55 + return myReactions.length >= EMOJI_REACTION_LIMIT 56 + }
+4 -2
src/lib/constants.ts
··· 1 - import {Insets, Platform} from 'react-native' 2 - import {AppBskyActorDefs} from '@atproto/api' 1 + import {type Insets, Platform} from 'react-native' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 3 4 4 export const LOCAL_DEV_SERVICE = 5 5 Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' ··· 190 190 ] as const 191 191 192 192 export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number] 193 + 194 + export const EMOJI_REACTION_LIMIT = 5
+53 -4
src/screens/Messages/components/ChatListItem.tsx
··· 1 1 import React, {useCallback, useMemo, useState} from 'react' 2 - import {GestureResponderEvent, View} from 'react-native' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 3 import { 4 4 AppBskyEmbedRecord, 5 5 ChatBskyConvoDefs, 6 6 moderateProfile, 7 - ModerationOpts, 7 + type ModerationOpts, 8 8 } from '@atproto/api' 9 9 import {msg} from '@lingui/macro' 10 10 import {useLingui} from '@lingui/react' ··· 43 43 import {useMenuControl} from '#/components/Menu' 44 44 import {PostAlerts} from '#/components/moderation/PostAlerts' 45 45 import {Text} from '#/components/Typography' 46 - import * as bsky from '#/types/bsky' 46 + import type * as bsky from '#/types/bsky' 47 47 48 48 export let ChatListItem = ({ 49 49 convo, ··· 189 189 ? _(msg`Conversation deleted`) 190 190 : _(msg`Message deleted`) 191 191 } 192 + if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastMessage)) { 193 + const isFromMe = 194 + convo.lastMessage.reaction.sender.did === currentAccount?.did 195 + const lastMessageText = convo.lastMessage.message.text 196 + const fallbackMessage = _( 197 + msg({ 198 + message: 'a message', 199 + comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, 200 + }), 201 + ) 202 + 203 + if (isFromMe) { 204 + lastMessage = _( 205 + msg`You reacted ${convo.lastMessage.reaction.value} to ${ 206 + lastMessageText 207 + ? `"${convo.lastMessage.message.text}"` 208 + : fallbackMessage 209 + }`, 210 + ) 211 + } else { 212 + const senderDid = convo.lastMessage.reaction.sender.did 213 + const sender = convo.members.find(member => member.did === senderDid) 214 + if (sender) { 215 + lastMessage = _( 216 + msg`${sanitizeDisplayName( 217 + sender.displayName || sender.handle, 218 + )} reacted ${convo.lastMessage.reaction.value} to ${ 219 + lastMessageText 220 + ? `"${convo.lastMessage.message.text}"` 221 + : fallbackMessage 222 + }`, 223 + ) 224 + } else { 225 + lastMessage = _( 226 + msg`Someone reacted ${convo.lastMessage.reaction.value} to ${ 227 + lastMessageText 228 + ? `"${convo.lastMessage.message.text}"` 229 + : fallbackMessage 230 + }`, 231 + ) 232 + } 233 + } 234 + } 192 235 193 236 return { 194 237 lastMessage, 195 238 lastMessageSentAt, 196 239 latestReportableMessage, 197 240 } 198 - }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) 241 + }, [ 242 + _, 243 + convo.lastMessage, 244 + currentAccount?.did, 245 + isDeletedAccount, 246 + convo.members, 247 + ]) 199 248 200 249 const [showActions, setShowActions] = useState(false) 201 250
+179 -13
src/state/messages/convo/agent.ts
··· 1 1 import { 2 - BskyAgent, 3 - ChatBskyActorDefs, 2 + type AtpAgent, 3 + type ChatBskyActorDefs, 4 4 ChatBskyConvoDefs, 5 - ChatBskyConvoGetLog, 6 - ChatBskyConvoSendMessage, 5 + type ChatBskyConvoGetLog, 6 + type ChatBskyConvoSendMessage, 7 7 } from '@atproto/api' 8 8 import {XRPCError} from '@atproto/xrpc' 9 9 import EventEmitter from 'eventemitter3' ··· 19 19 NETWORK_FAILURE_STATUSES, 20 20 } from '#/state/messages/convo/const' 21 21 import { 22 - ConvoDispatch, 22 + type ConvoDispatch, 23 23 ConvoDispatchEvent, 24 - ConvoError, 24 + type ConvoError, 25 25 ConvoErrorCode, 26 - ConvoEvent, 27 - ConvoItem, 26 + type ConvoEvent, 27 + type ConvoItem, 28 28 ConvoItemError, 29 - ConvoParams, 30 - ConvoState, 29 + type ConvoParams, 30 + type ConvoState, 31 31 ConvoStatus, 32 32 } from '#/state/messages/convo/types' 33 - import {MessagesEventBus} from '#/state/messages/events/agent' 34 - import {MessagesEventBusError} from '#/state/messages/events/types' 33 + import {type MessagesEventBus} from '#/state/messages/events/agent' 34 + import {type MessagesEventBusError} from '#/state/messages/events/types' 35 35 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 36 36 37 37 const logger = Logger.create(Logger.Context.ConversationAgent) ··· 50 50 export class Convo { 51 51 private id: string 52 52 53 - private agent: BskyAgent 53 + private agent: AtpAgent 54 54 private events: MessagesEventBus 55 55 private senderUserDid: string 56 56 ··· 106 106 this.onFirehoseConnect = this.onFirehoseConnect.bind(this) 107 107 this.onFirehoseError = this.onFirehoseError.bind(this) 108 108 this.markConvoAccepted = this.markConvoAccepted.bind(this) 109 + this.addReaction = this.addReaction.bind(this) 110 + this.removeReaction = this.removeReaction.bind(this) 109 111 } 110 112 111 113 private commit() { ··· 147 149 sendMessage: undefined, 148 150 fetchMessageHistory: undefined, 149 151 markConvoAccepted: undefined, 152 + addReaction: undefined, 153 + removeReaction: undefined, 150 154 } 151 155 } 152 156 case ConvoStatus.Disabled: ··· 165 169 sendMessage: this.sendMessage, 166 170 fetchMessageHistory: this.fetchMessageHistory, 167 171 markConvoAccepted: this.markConvoAccepted, 172 + addReaction: this.addReaction, 173 + removeReaction: this.removeReaction, 168 174 } 169 175 } 170 176 case ConvoStatus.Error: { ··· 180 186 sendMessage: undefined, 181 187 fetchMessageHistory: undefined, 182 188 markConvoAccepted: undefined, 189 + addReaction: undefined, 190 + removeReaction: undefined, 183 191 } 184 192 } 185 193 default: { ··· 195 203 sendMessage: undefined, 196 204 fetchMessageHistory: undefined, 197 205 markConvoAccepted: undefined, 206 + addReaction: undefined, 207 + removeReaction: undefined, 198 208 } 199 209 } 200 210 } ··· 760 770 this.deletedMessages.delete(ev.message.id) 761 771 needsCommit = true 762 772 } 773 + } else if ( 774 + (ChatBskyConvoDefs.isLogAddReaction(ev) || 775 + ChatBskyConvoDefs.isLogRemoveReaction(ev)) && 776 + ChatBskyConvoDefs.isMessageView(ev.message) 777 + ) { 778 + /* 779 + * Update if we have this in state - replace message wholesale. If we don't, don't worry about it. 780 + */ 781 + if (this.pastMessages.has(ev.message.id)) { 782 + this.pastMessages.set(ev.message.id, ev.message) 783 + needsCommit = true 784 + } 785 + if (this.newMessages.has(ev.message.id)) { 786 + this.newMessages.set(ev.message.id, ev.message) 787 + needsCommit = true 788 + } 763 789 } 764 790 } 765 791 } ··· 1140 1166 1141 1167 return item 1142 1168 }) 1169 + } 1170 + 1171 + /** 1172 + * Add an emoji reaction to a message 1173 + * 1174 + * @param messageId - the id of the message to add the reaction to 1175 + * @param emoji - must be one grapheme 1176 + */ 1177 + async addReaction(messageId: string, emoji: string) { 1178 + const optimisticReaction = { 1179 + value: emoji, 1180 + sender: {did: this.senderUserDid}, 1181 + createdAt: new Date().toISOString(), 1182 + } 1183 + let restore: null | (() => void) = null 1184 + if (this.pastMessages.has(messageId)) { 1185 + const prevMessage = this.pastMessages.get(messageId) 1186 + if ( 1187 + ChatBskyConvoDefs.isMessageView(prevMessage) && 1188 + // skip optimistic update if reaction already exists 1189 + !prevMessage.reactions?.find( 1190 + reaction => 1191 + reaction.sender.did === this.senderUserDid && 1192 + reaction.value === emoji, 1193 + ) 1194 + ) { 1195 + if (prevMessage.reactions) { 1196 + if ( 1197 + prevMessage.reactions.filter( 1198 + reaction => reaction.sender.did === this.senderUserDid, 1199 + ).length >= 5 1200 + ) { 1201 + throw new Error('Maximum reactions reached') 1202 + } 1203 + } 1204 + this.pastMessages.set(messageId, { 1205 + ...prevMessage, 1206 + reactions: [...(prevMessage.reactions ?? []), optimisticReaction], 1207 + }) 1208 + this.commit() 1209 + restore = () => { 1210 + this.pastMessages.set(messageId, prevMessage) 1211 + this.commit() 1212 + } 1213 + } 1214 + } else if (this.newMessages.has(messageId)) { 1215 + const prevMessage = this.newMessages.get(messageId) 1216 + if ( 1217 + ChatBskyConvoDefs.isMessageView(prevMessage) && 1218 + !prevMessage.reactions?.find(reaction => reaction.value === emoji) 1219 + ) { 1220 + if (prevMessage.reactions && prevMessage.reactions.length >= 5) 1221 + throw new Error('Maximum reactions reached') 1222 + this.newMessages.set(messageId, { 1223 + ...prevMessage, 1224 + reactions: [...(prevMessage.reactions ?? []), optimisticReaction], 1225 + }) 1226 + this.commit() 1227 + restore = () => { 1228 + this.newMessages.set(messageId, prevMessage) 1229 + this.commit() 1230 + } 1231 + } 1232 + } 1233 + 1234 + try { 1235 + logger.info(`Adding reaction ${emoji} to message ${messageId}`) 1236 + const {data} = await this.agent.chat.bsky.convo.addReaction( 1237 + {messageId, value: emoji, convoId: this.convoId}, 1238 + {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, 1239 + ) 1240 + if (ChatBskyConvoDefs.isMessageView(data.message)) { 1241 + if (this.pastMessages.has(messageId)) { 1242 + this.pastMessages.set(messageId, data.message) 1243 + this.commit() 1244 + } else if (this.newMessages.has(messageId)) { 1245 + this.newMessages.set(messageId, data.message) 1246 + this.commit() 1247 + } 1248 + } 1249 + } catch (error) { 1250 + if (restore) restore() 1251 + throw error 1252 + } 1253 + } 1254 + 1255 + /* 1256 + * Remove a reaction from a message. 1257 + * 1258 + * @param messageId - The ID of the message to remove the reaction from. 1259 + * @param emoji - The emoji to remove. 1260 + */ 1261 + async removeReaction(messageId: string, emoji: string) { 1262 + let restore: null | (() => void) = null 1263 + if (this.pastMessages.has(messageId)) { 1264 + const prevMessage = this.pastMessages.get(messageId) 1265 + if (ChatBskyConvoDefs.isMessageView(prevMessage)) { 1266 + this.pastMessages.set(messageId, { 1267 + ...prevMessage, 1268 + reactions: prevMessage.reactions?.filter( 1269 + reaction => 1270 + reaction.value !== emoji || 1271 + reaction.sender.did !== this.senderUserDid, 1272 + ), 1273 + }) 1274 + this.commit() 1275 + restore = () => { 1276 + this.pastMessages.set(messageId, prevMessage) 1277 + this.commit() 1278 + } 1279 + } 1280 + } else if (this.newMessages.has(messageId)) { 1281 + const prevMessage = this.newMessages.get(messageId) 1282 + if (ChatBskyConvoDefs.isMessageView(prevMessage)) { 1283 + this.newMessages.set(messageId, { 1284 + ...prevMessage, 1285 + reactions: prevMessage.reactions?.filter( 1286 + reaction => 1287 + reaction.value !== emoji || 1288 + reaction.sender.did !== this.senderUserDid, 1289 + ), 1290 + }) 1291 + this.commit() 1292 + restore = () => { 1293 + this.newMessages.set(messageId, prevMessage) 1294 + this.commit() 1295 + } 1296 + } 1297 + } 1298 + 1299 + try { 1300 + logger.info(`Removing reaction ${emoji} from message ${messageId}`) 1301 + await this.agent.chat.bsky.convo.removeReaction( 1302 + {messageId, value: emoji, convoId: this.convoId}, 1303 + {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, 1304 + ) 1305 + } catch (error) { 1306 + if (restore) restore() 1307 + throw error 1308 + } 1143 1309 } 1144 1310 }
+7 -7
src/state/messages/convo/index.tsx
··· 1 1 import React, {useContext, useState, useSyncExternalStore} from 'react' 2 - import {ChatBskyConvoDefs} from '@atproto/api' 2 + import {type ChatBskyConvoDefs} from '@atproto/api' 3 3 import {useFocusEffect} from '@react-navigation/native' 4 4 import {useQueryClient} from '@tanstack/react-query' 5 5 6 6 import {useAppState} from '#/lib/hooks/useAppState' 7 7 import {Convo} from '#/state/messages/convo/agent' 8 8 import { 9 - ConvoParams, 10 - ConvoState, 11 - ConvoStateBackgrounded, 12 - ConvoStateDisabled, 13 - ConvoStateReady, 14 - ConvoStateSuspended, 9 + type ConvoParams, 10 + type ConvoState, 11 + type ConvoStateBackgrounded, 12 + type ConvoStateDisabled, 13 + type ConvoStateReady, 14 + type ConvoStateSuspended, 15 15 } from '#/state/messages/convo/types' 16 16 import {isConvoActive} from '#/state/messages/convo/util' 17 17 import {useMessagesEventBus} from '#/state/messages/events'
+21 -5
src/state/messages/convo/types.ts
··· 1 1 import { 2 - BskyAgent, 3 - ChatBskyActorDefs, 4 - ChatBskyConvoDefs, 5 - ChatBskyConvoSendMessage, 2 + type BskyAgent, 3 + type ChatBskyActorDefs, 4 + type ChatBskyConvoDefs, 5 + type ChatBskyConvoSendMessage, 6 6 } from '@atproto/api' 7 7 8 - import {MessagesEventBus} from '#/state/messages/events/agent' 8 + import {type MessagesEventBus} from '#/state/messages/events/agent' 9 9 10 10 export type ConvoParams = { 11 11 convoId: string ··· 142 142 ) => void 143 143 type FetchMessageHistory = () => Promise<void> 144 144 type MarkConvoAccepted = () => void 145 + type AddReaction = (messageId: string, reaction: string) => Promise<void> 146 + type RemoveReaction = (messageId: string, reaction: string) => Promise<void> 145 147 146 148 export type ConvoStateUninitialized = { 147 149 status: ConvoStatus.Uninitialized ··· 155 157 sendMessage: undefined 156 158 fetchMessageHistory: undefined 157 159 markConvoAccepted: undefined 160 + addReaction: undefined 161 + removeReaction: undefined 158 162 } 159 163 export type ConvoStateInitializing = { 160 164 status: ConvoStatus.Initializing ··· 168 172 sendMessage: undefined 169 173 fetchMessageHistory: undefined 170 174 markConvoAccepted: undefined 175 + addReaction: undefined 176 + removeReaction: undefined 171 177 } 172 178 export type ConvoStateReady = { 173 179 status: ConvoStatus.Ready ··· 181 187 sendMessage: SendMessage 182 188 fetchMessageHistory: FetchMessageHistory 183 189 markConvoAccepted: MarkConvoAccepted 190 + addReaction: AddReaction 191 + removeReaction: RemoveReaction 184 192 } 185 193 export type ConvoStateBackgrounded = { 186 194 status: ConvoStatus.Backgrounded ··· 194 202 sendMessage: SendMessage 195 203 fetchMessageHistory: FetchMessageHistory 196 204 markConvoAccepted: MarkConvoAccepted 205 + addReaction: AddReaction 206 + removeReaction: RemoveReaction 197 207 } 198 208 export type ConvoStateSuspended = { 199 209 status: ConvoStatus.Suspended ··· 207 217 sendMessage: SendMessage 208 218 fetchMessageHistory: FetchMessageHistory 209 219 markConvoAccepted: MarkConvoAccepted 220 + addReaction: AddReaction 221 + removeReaction: RemoveReaction 210 222 } 211 223 export type ConvoStateError = { 212 224 status: ConvoStatus.Error ··· 220 232 sendMessage: undefined 221 233 fetchMessageHistory: undefined 222 234 markConvoAccepted: undefined 235 + addReaction: undefined 236 + removeReaction: undefined 223 237 } 224 238 export type ConvoStateDisabled = { 225 239 status: ConvoStatus.Disabled ··· 233 247 sendMessage: SendMessage 234 248 fetchMessageHistory: FetchMessageHistory 235 249 markConvoAccepted: MarkConvoAccepted 250 + addReaction: AddReaction 251 + removeReaction: RemoveReaction 236 252 } 237 253 export type ConvoState = 238 254 | ConvoStateUninitialized
+56 -11
src/state/queries/messages/list-conversations.tsx
··· 1 - import React, { 2 - createContext, 3 - useCallback, 4 - useContext, 5 - useEffect, 6 - useMemo, 7 - } from 'react' 1 + import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' 8 2 import { 9 3 ChatBskyConvoDefs, 10 - ChatBskyConvoListConvos, 4 + type ChatBskyConvoListConvos, 11 5 moderateProfile, 12 - ModerationOpts, 6 + type ModerationOpts, 13 7 } from '@atproto/api' 14 8 import { 15 - InfiniteData, 16 - QueryClient, 9 + type InfiniteData, 10 + type QueryClient, 17 11 useInfiniteQuery, 18 12 useQueryClient, 19 13 } from '@tanstack/react-query' ··· 316 310 rev: logRef.rev, 317 311 })), 318 312 ) 313 + } else if (ChatBskyConvoDefs.isLogAddReaction(log)) { 314 + const logRef: ChatBskyConvoDefs.LogAddReaction = log 315 + queryClient.setQueriesData( 316 + {queryKey: [RQKEY_ROOT]}, 317 + (old?: ConvoListQueryData) => 318 + optimisticUpdate(logRef.convoId, old, convo => ({ 319 + ...convo, 320 + lastMessage: { 321 + $type: 'chat.bsky.convo.defs#messageAndReactionView', 322 + reaction: logRef.reaction, 323 + message: logRef.message, 324 + }, 325 + rev: logRef.rev, 326 + })), 327 + ) 328 + } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) { 329 + if (ChatBskyConvoDefs.isMessageView(log.message)) { 330 + for (const [_queryKey, queryData] of queryClient.getQueriesData< 331 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 332 + >({ 333 + queryKey: [RQKEY_ROOT], 334 + })) { 335 + if (!queryData?.pages) { 336 + continue 337 + } 338 + 339 + for (const page of queryData.pages) { 340 + for (const convo of page.convos) { 341 + if ( 342 + // if the convo is the same 343 + log.convoId === convo.id && 344 + ChatBskyConvoDefs.isMessageAndReactionView( 345 + convo.lastMessage, 346 + ) && 347 + ChatBskyConvoDefs.isMessageView( 348 + convo.lastMessage.message, 349 + ) && 350 + // ...and the message is the same 351 + convo.lastMessage.message.id === log.message.id && 352 + // ...and the reaction is the same 353 + convo.lastMessage.reaction.sender.did === 354 + log.reaction.sender.did && 355 + convo.lastMessage.reaction.value === log.reaction.value 356 + ) { 357 + // refetch, because we don't know what the last message is now 358 + debouncedRefetch() 359 + } 360 + } 361 + } 362 + } 363 + } 319 364 } 320 365 } 321 366 },
+31 -7
yarn.lock
··· 80 80 tlds "^1.234.0" 81 81 zod "^3.23.8" 82 82 83 - "@atproto/api@^0.14.7": 84 - version "0.14.7" 85 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.7.tgz#3ffa02d6b3baf9e265dab170367ffade08023567" 86 - integrity sha512-YG2kvAtsgtajLlLrorYuHcxGgepG0c/RUB2/iJyBnwKjGqDLG8joOETf38JSNiGzs6NJbNKa9NHG6BQKourxBA== 83 + "@atproto/api@^0.14.14": 84 + version "0.14.14" 85 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.14.tgz#5d2d2e6156eab6ca0d463c114b4a3865275e9aac" 86 + integrity sha512-ryawcnmazVSWYfq11ujPHauY77GfkM3mF0rZOkqENN2Ptnl6BZXJvpA0zLA/sQ5YBLcHXSEWg5Xdq+8i1l+8gA== 87 87 dependencies: 88 88 "@atproto/common-web" "^0.4.0" 89 - "@atproto/lexicon" "^0.4.7" 90 - "@atproto/syntax" "^0.3.3" 91 - "@atproto/xrpc" "^0.6.9" 89 + "@atproto/lexicon" "^0.4.9" 90 + "@atproto/syntax" "^0.4.0" 91 + "@atproto/xrpc" "^0.6.11" 92 92 await-lock "^2.2.2" 93 93 multiformats "^9.9.0" 94 94 tlds "^1.234.0" ··· 302 302 multiformats "^9.9.0" 303 303 zod "^3.23.8" 304 304 305 + "@atproto/lexicon@^0.4.9": 306 + version "0.4.9" 307 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.9.tgz#612951a85ecc1398366bd837cda6be89440f179d" 308 + integrity sha512-/tmEuHQFr51V2V7EAVJzaA40sqJ7ylAZpR962VbOsPtmcdOHvezbjVHYEMXgfb927hS+xqbVyzBTbu5w9v8prA== 309 + dependencies: 310 + "@atproto/common-web" "^0.4.0" 311 + "@atproto/syntax" "^0.4.0" 312 + iso-datestring-validator "^2.2.2" 313 + multiformats "^9.9.0" 314 + zod "^3.23.8" 315 + 305 316 "@atproto/oauth-provider@^0.2.17": 306 317 version "0.2.17" 307 318 resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.17.tgz#4644d391eedbbbbe5825ecc0e8cc03f1c6433b95" ··· 446 457 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.3.tgz#6debe8983985378104822172a128e429931bf3f7" 447 458 integrity sha512-F1LZweesNYdBbZBXVa72N/cSvchG8Q1tG4/209ZXbIuM3FwQtkgn+zgmmV4P4ORmhOeXPBNXvMBpcqiwx/gEQQ== 448 459 460 + "@atproto/syntax@^0.4.0": 461 + version "0.4.0" 462 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" 463 + integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== 464 + 449 465 "@atproto/xrpc-server@^0.7.11": 450 466 version "0.7.11" 451 467 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.11.tgz#efadcfdaaaa0ff5576d1ee97e46dcbc6dafcb0b6" ··· 462 478 rate-limiter-flexible "^2.4.1" 463 479 uint8arrays "3.0.0" 464 480 ws "^8.12.0" 481 + zod "^3.23.8" 482 + 483 + "@atproto/xrpc@^0.6.11": 484 + version "0.6.11" 485 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.11.tgz#54c527e39a2f5ddd2655b11f7cb99b8f303d8364" 486 + integrity sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA== 487 + dependencies: 488 + "@atproto/lexicon" "^0.4.9" 465 489 zod "^3.23.8" 466 490 467 491 "@atproto/xrpc@^0.6.9":