Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Groupchats feature branch (#10181)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

Samuel Newman
DS Boyce
Claude Opus 4.6 (1M context)
and committed by
GitHub
d3f50938 75c9e2c1

+2818 -627
+1
assets/icons/messagePlus_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>
+1 -1
package.json
··· 81 81 "icons:optimize": "svgo -f ./assets/icons" 82 82 }, 83 83 "dependencies": { 84 - "@atproto/api": "^0.19.8", 84 + "@atproto/api": "^0.19.9", 85 85 "@bitdrift/react-native": "^0.6.8", 86 86 "@braintree/sanitize-url": "^6.0.2", 87 87 "@bsky.app/alf": "^0.1.7",
+48
patches/react-native-keyboard-controller+1.21.5.patch
··· 1 + diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts 2 + index 24a25ae..2c5ff6d 100644 3 + --- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts 4 + +++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts 5 + @@ -1,8 +1,6 @@ 6 + import { useCallback } from "react"; 7 + -import { Platform } from "react-native"; 8 + import { scrollTo, useAnimatedReaction } from "react-native-reanimated"; 9 + 10 + -import { IS_FABRIC } from "../../../architecture"; 11 + import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers"; 12 + 13 + import type { KeyboardLiftBehavior } from "../useChatKeyboard/types"; 14 + @@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { 15 + scroll, 16 + layout, 17 + size, 18 + - contentOffsetY, 19 + inverted, 20 + keyboardLiftBehavior, 21 + freeze, 22 + @@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { 23 + (target: number) => { 24 + "worklet"; 25 + 26 + - if (contentOffsetY && IS_FABRIC) { 27 + - // eslint-disable-next-line react-compiler/react-compiler 28 + - contentOffsetY.value = target; 29 + - } else if (Platform.OS === "android") { 30 + - // Defer scrollTo so the animatedProps inset commit lands first; 31 + - // otherwise the native ScrollView clamps to the old range. 32 + - requestAnimationFrame(() => { 33 + - scrollTo(scrollViewRef, 0, target, false); 34 + - }); 35 + - } else { 36 + + // Always defer scrollTo so the animatedProps inset commit lands first; 37 + + // otherwise the native ScrollView clamps contentOffset to the old 38 + + // contentInset range (iOS Fabric) or the old contentInsetBottom (Android). 39 + + requestAnimationFrame(() => { 40 + scrollTo(scrollViewRef, 0, target, false); 41 + - } 42 + + }); 43 + }, 44 + - [scrollViewRef, contentOffsetY], 45 + + [scrollViewRef], 46 + ); 47 + 48 + useAnimatedReaction(
+6
src/Navigation.tsx
··· 78 78 import {LogScreen} from '#/screens/Log' 79 79 import {MessagesScreen} from '#/screens/Messages/ChatList' 80 80 import {MessagesConversationScreen} from '#/screens/Messages/Conversation' 81 + import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings' 81 82 import {MessagesInboxScreen} from '#/screens/Messages/Inbox' 82 83 import {MessagesSettingsScreen} from '#/screens/Messages/Settings' 83 84 import {ModerationScreen} from '#/screens/Moderation' ··· 567 568 name="MessagesConversation" 568 569 getComponent={() => MessagesConversationScreen} 569 570 options={{title: title(msg`Chat`), requireAuth: true}} 571 + /> 572 + <Stack.Screen 573 + name="MessagesConversationSettings" 574 + getComponent={() => MessagesConversationSettingsScreen} 575 + options={{title: title(msg`Group chat settings`), requireAuth: true}} 570 576 /> 571 577 <Stack.Screen 572 578 name="MessagesSettings"
+3
src/analytics/metrics/types.ts
··· 563 563 | 'ChatsList' 564 564 | 'SendViaChatDialog' 565 565 } 566 + 'groupchat:create': { 567 + logContext: 'NewChatDialog' 568 + } 566 569 'starterPack:addUser': { 567 570 starterPack?: string 568 571 }
+260
src/components/AvatarBubbles.tsx
··· 1 + import {useCallback, useEffect} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 + import Animated, { 4 + Easing, 5 + interpolate, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withDelay, 9 + withTiming, 10 + } from 'react-native-reanimated' 11 + 12 + import {useSession} from '#/state/session' 13 + import {UserAvatar} from '#/view/com/util/UserAvatar' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 16 + import type * as bsky from '#/types/bsky' 17 + 18 + type Props = { 19 + animate?: boolean 20 + profiles: bsky.profile.AnyProfileView[] 21 + size?: 'small' | 'medium' | 'large' 22 + } 23 + 24 + export function AvatarBubbles({ 25 + animate = false, 26 + profiles: allProfiles, 27 + size = 'large', 28 + }: Props) { 29 + const {currentAccount} = useSession() 30 + const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) 31 + const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120 32 + const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1 33 + const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 34 + 35 + const initialValue = animate ? 0 : 1 36 + const p0 = useSharedValue(initialValue) 37 + const p1 = useSharedValue(initialValue) 38 + const p2 = useSharedValue(initialValue) 39 + const p3 = useSharedValue(initialValue) 40 + 41 + const animateScale = (p: Animated.SharedValue<number>, index: number) => { 42 + p.set(0) 43 + p.set(() => 44 + withDelay( 45 + 500 + index * 100, 46 + withTiming(1, { 47 + duration: 250, 48 + easing: Easing.out(Easing.back(1.75)), 49 + }), 50 + ), 51 + ) 52 + } 53 + 54 + const playScaleAnimation = useCallback(() => { 55 + animateScale(p0, 0) 56 + animateScale(p1, 1) 57 + animateScale(p2, 2) 58 + animateScale(p3, 3) 59 + }, [p0, p1, p2, p3]) 60 + 61 + useEffect(() => { 62 + if (!animate) return 63 + playScaleAnimation() 64 + }, [animate, playScaleAnimation]) 65 + 66 + let avatars = ( 67 + <> 68 + <AvatarBubble 69 + profile={profiles[0] ?? allProfiles[0]} 70 + scale={p0} 71 + size={76} 72 + x={-2} 73 + y={-2} 74 + style={[a.z_20]} 75 + includeProfileBorder 76 + /> 77 + <AvatarBubble 78 + profile={profiles[1]} 79 + scale={p1} 80 + size={76} 81 + x={42} 82 + y={42} 83 + style={[a.z_10]} 84 + includeProfileBorder 85 + /> 86 + </> 87 + ) 88 + 89 + if (profiles.length === 3) { 90 + avatars = ( 91 + <> 92 + <AvatarBubble 93 + profile={profiles[0]} 94 + scale={p0} 95 + size={68} 96 + x={-2} 97 + y={-2} 98 + /> 99 + <AvatarBubble 100 + profile={profiles[1]} 101 + scale={p1} 102 + size={56} 103 + x={38} 104 + y={62} 105 + /> 106 + <AvatarBubble 107 + profile={profiles[2]} 108 + scale={p2} 109 + size={46} 110 + x={71} 111 + y={18} 112 + /> 113 + </> 114 + ) 115 + } 116 + 117 + if (profiles.length >= 4) { 118 + avatars = ( 119 + <> 120 + <AvatarBubble 121 + profile={profiles[0]} 122 + scale={p0} 123 + size={68} 124 + x={-2} 125 + y={-2} 126 + /> 127 + <AvatarBubble 128 + profile={profiles[1]} 129 + scale={p1} 130 + size={56} 131 + x={60} 132 + y={49} 133 + /> 134 + <AvatarBubble 135 + profile={profiles[2]} 136 + scale={p2} 137 + size={42} 138 + x={14} 139 + y={74} 140 + /> 141 + <AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} /> 142 + </> 143 + ) 144 + } 145 + 146 + return ( 147 + <Animated.View 148 + style={[ 149 + a.p_2xs, 150 + { 151 + height: containerSize, 152 + width: containerSize, 153 + }, 154 + ]}> 155 + <View 156 + style={[ 157 + { 158 + marginTop: marginOffset, 159 + marginLeft: marginOffset, 160 + transform: [{scale}], 161 + transformOrigin: 'top left', 162 + }, 163 + ]}> 164 + {avatars} 165 + </View> 166 + </Animated.View> 167 + ) 168 + } 169 + 170 + function AvatarBubble({ 171 + profile, 172 + scale, 173 + size, 174 + style, 175 + x, 176 + y, 177 + includeProfileBorder, 178 + }: { 179 + profile?: bsky.profile.AnyProfileView 180 + scale: Animated.SharedValue<number> 181 + size: number 182 + style?: StyleProp<ViewStyle> 183 + x: number 184 + y: number 185 + includeProfileBorder?: boolean 186 + }) { 187 + const t = useTheme() 188 + 189 + const animatedStyle = useAnimatedStyle(() => ({ 190 + transform: [ 191 + {translateX: x}, 192 + {translateY: y}, 193 + {scale: interpolate(scale.get(), [0, 1], [0, 1])}, 194 + ], 195 + })) 196 + 197 + return ( 198 + <Animated.View 199 + style={[ 200 + a.absolute, 201 + a.rounded_full, 202 + a.flex_grow_0, 203 + {transform: [{translateX: x}, {translateY: y}]}, 204 + includeProfileBorder && { 205 + borderColor: t.atoms.text_inverted.color, 206 + borderWidth: 2, 207 + }, 208 + style, 209 + animatedStyle, 210 + ]}> 211 + {profile ? ( 212 + <Avatar profile={profile} size={size} /> 213 + ) : ( 214 + <AvatarPlaceholder size={size} /> 215 + )} 216 + </Animated.View> 217 + ) 218 + } 219 + 220 + function Avatar({ 221 + profile, 222 + size = 76, 223 + }: { 224 + profile: bsky.profile.AnyProfileView 225 + size?: number 226 + }) { 227 + return ( 228 + <UserAvatar 229 + avatar={profile.avatar} 230 + size={size} 231 + type="user" 232 + hideLiveBadge 233 + noBorder 234 + /> 235 + ) 236 + } 237 + 238 + function AvatarPlaceholder({size = 76}: {size?: number}) { 239 + const t = useTheme() 240 + 241 + return ( 242 + <View 243 + style={[ 244 + a.align_center, 245 + a.justify_center, 246 + a.rounded_full, 247 + t.atoms.bg_contrast_200, 248 + { 249 + width: size, 250 + height: size, 251 + }, 252 + ]}> 253 + <PersonIcon 254 + width={size * 0.5} 255 + height={size * 0.5} 256 + fill={t.atoms.text_inverted.color} 257 + /> 258 + </View> 259 + ) 260 + }
+6 -1
src/components/ContextMenu/index.tsx
··· 482 482 ) 483 483 } 484 484 485 - export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { 485 + export function AuxiliaryView({ 486 + children, 487 + align = 'left', 488 + style, 489 + }: AuxiliaryViewProps) { 486 490 const context = useContextMenuContext() 487 491 const {width: screenWidth} = useWindowDimensions() 488 492 const {top: topInset} = useSafeAreaInsets() ··· 556 560 : {right: screenWidth - measurement.x - measurement.width}, 557 561 animatedStyle, 558 562 a.z_20, 563 + style, 559 564 ]}> 560 565 {children} 561 566 </Animated.View>
+1
src/components/ContextMenu/types.ts
··· 21 21 export type AuxiliaryViewProps = { 22 22 children?: React.ReactNode 23 23 align?: 'left' | 'right' 24 + style?: StyleProp<ViewStyle> 24 25 } 25 26 26 27 export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
+3 -4
src/components/dms/ActionsWrapper.tsx
··· 1 1 import {View} from 'react-native' 2 2 import {type ChatBskyConvoDefs} from '@atproto/api' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 3 + import {useLingui} from '@lingui/react/macro' 5 4 6 5 import {atoms as a} from '#/alf' 7 6 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' ··· 15 14 isFromSelf: boolean 16 15 children: React.ReactNode 17 16 }) { 18 - const {_} = useLingui() 17 + const {t: l} = useLingui() 19 18 20 19 return ( 21 20 <MessageContextMenu message={message}> ··· 32 31 ]} 33 32 accessible={true} 34 33 accessibilityActions={[ 35 - {name: 'activate', label: _(msg`Open message options`)}, 34 + {name: 'activate', label: l`Open message options`}, 36 35 ]} 37 36 onAccessibilityAction={() => trigger.control.open('full')}> 38 37 {children}
+9 -9
src/components/dms/ConvoMenu.tsx
··· 25 25 import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' 26 26 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 27 27 import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' 28 - import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' 29 - import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 30 - import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 28 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 29 + import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' 30 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 31 31 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 32 32 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 33 33 import { ··· 95 95 shape="round" 96 96 variant="ghost" 97 97 style={[a.bg_transparent]}> 98 - <ButtonIcon icon={DotsHorizontal} size="md" /> 98 + <ButtonIcon icon={DotsHorizontalIcon} size="md" /> 99 99 </Button> 100 100 )} 101 101 </Menu.Trigger> ··· 220 220 } 221 221 222 222 if (userBlock) { 223 - queueUnblock() 223 + void queueUnblock() 224 224 } else { 225 - queueBlock() 225 + void queueBlock() 226 226 } 227 227 }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock]) 228 228 ··· 233 233 <Menu.ItemText> 234 234 <Trans>Leave conversation</Trans> 235 235 </Menu.ItemText> 236 - <Menu.ItemIcon icon={ArrowBoxLeft} /> 236 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 237 237 </Menu.Item> 238 238 ) : ( 239 239 <> ··· 245 245 <Menu.ItemText> 246 246 <Trans>Mark as read</Trans> 247 247 </Menu.ItemText> 248 - <Menu.ItemIcon icon={Bubble} /> 248 + <Menu.ItemIcon icon={BubbleIcon} /> 249 249 </Menu.Item> 250 250 )} 251 251 <Menu.Item ··· 296 296 <Menu.ItemText> 297 297 <Trans>Leave conversation</Trans> 298 298 </Menu.ItemText> 299 - <Menu.ItemIcon icon={ArrowBoxLeft} /> 299 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 300 300 </Menu.Item> 301 301 </Menu.Group> 302 302 </>
+6 -12
src/components/dms/DateDivider.tsx
··· 1 1 import {memo} from 'react' 2 2 import {View} from 'react-native' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 5 - import {Trans} from '@lingui/react/macro' 3 + import {Trans, useLingui} from '@lingui/react/macro' 6 4 import {subDays} from 'date-fns' 7 5 8 6 import {atoms as a, useTheme} from '#/alf' ··· 29 27 }) 30 28 31 29 let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { 32 - const {_} = useLingui() 30 + const {t: l} = useLingui() 33 31 const t = useTheme() 34 32 35 33 let date: string ··· 42 40 const oneWeekAgo = subDays(today, 7) 43 41 44 42 if (localDateString(today) === localDateString(timestamp)) { 45 - date = _(msg`Today`) 43 + date = l`Today` 46 44 } else if (localDateString(yesterday) === localDateString(timestamp)) { 47 - date = _(msg`Yesterday`) 45 + date = l`Yesterday` 48 46 } else { 49 47 if (timestamp < oneWeekAgo) { 50 48 if (timestamp.getFullYear() === today.getFullYear()) { ··· 58 56 } 59 57 60 58 return ( 61 - <View style={[a.w_full, a.my_lg]}> 59 + <View style={[a.w_full, a.my_sm]}> 62 60 <Text 63 61 style={[ 64 62 a.text_xs, ··· 68 66 a.px_md, 69 67 ]}> 70 68 <Trans> 71 - <Text 72 - style={[a.text_xs, t.atoms.text_contrast_medium, a.font_semi_bold]}> 73 - {date} 74 - </Text>{' '} 75 - at {time} 69 + {date} at {time} 76 70 </Trans> 77 71 </Text> 78 72 </View>
+39 -42
src/components/dms/MessageContextMenu.tsx
··· 2 2 import {LayoutAnimation, Platform} from 'react-native' 3 3 import * as Clipboard from 'expo-clipboard' 4 4 import {type ChatBskyConvoDefs, RichText} from '@atproto/api' 5 - import {msg} from '@lingui/core/macro' 6 - import {useLingui} from '@lingui/react' 5 + import {useLingui} from '@lingui/react/macro' 7 6 import {useQueryClient} from '@tanstack/react-query' 8 7 9 8 import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' ··· 12 11 import {useLanguagePrefs} from '#/state/preferences' 13 12 import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 14 13 import {useSession} from '#/state/session' 14 + import {atoms as a} from '#/alf' 15 15 import * as ContextMenu from '#/components/ContextMenu' 16 16 import {type TriggerProps} from '#/components/ContextMenu/types' 17 17 import {AfterReportDialog} from '#/components/dms/AfterReportDialog' 18 - import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 18 + import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble' 19 19 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 20 - import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 21 - import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 20 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 21 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 22 22 import {ReportDialog} from '#/components/moderation/ReportDialog' 23 23 import * as Prompt from '#/components/Prompt' 24 24 import {usePromptControl} from '#/components/Prompt' ··· 35 35 message: ChatBskyConvoDefs.MessageView 36 36 children: TriggerProps['children'] 37 37 }): React.ReactNode => { 38 - const {_} = useLingui() 38 + const {t: l} = useLingui() 39 39 const ax = useAnalytics() 40 40 const {currentAccount} = useSession() 41 41 const queryClient = useQueryClient() ··· 47 47 const translate = useGoogleTranslate() 48 48 49 49 const isFromSelf = message.sender?.did === currentAccount?.did 50 + const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) 50 51 51 52 const onCopyMessage = useCallback(() => { 52 53 const str = richTextToString( ··· 58 59 ) 59 60 60 61 void Clipboard.setStringAsync(str) 61 - Toast.show(_(msg`Copied to clipboard`), { 62 + Toast.show(l`Copied to clipboard`, { 62 63 type: 'success', 63 64 }) 64 - }, [_, message.text, message.facets]) 65 + }, [l, message.text, message.facets]) 65 66 66 67 const onPressTranslateMessage = useCallback(() => { 67 68 void translate(message.text, langPrefs.primaryLanguage) ··· 79 80 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 80 81 convo 81 82 .deleteMessage(message.id) 82 - .then(() => 83 - Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), 84 - ) 85 - .catch(() => Toast.show(_(msg`Failed to delete message`))) 86 - }, [_, convo, message.id]) 83 + .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'}))) 84 + .catch(() => Toast.show(l`Failed to delete message`)) 85 + }, [l, convo, message.id]) 87 86 88 87 const onEmojiSelect = useCallback( 89 88 (emoji: string) => { ··· 96 95 ) { 97 96 convo 98 97 .removeReaction(message.id, emoji) 99 - .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) 98 + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) 100 99 } else { 101 100 if (hasReachedReactionLimit(message, currentAccount?.did)) return 102 101 convo.addReaction(message.id, emoji).catch(() => 103 - Toast.show(_(msg`Failed to add emoji reaction`), { 102 + Toast.show(l`Failed to add emoji reaction`, { 104 103 type: 'error', 105 104 }), 106 105 ) 107 106 } 108 107 }, 109 - [_, convo, message, currentAccount?.did], 108 + [l, convo, message, currentAccount?.did], 110 109 ) 111 110 112 111 const sender = convo.convo.members.find( ··· 117 116 <> 118 117 <ContextMenu.Root> 119 118 {IS_NATIVE && ( 120 - <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> 119 + <ContextMenu.AuxiliaryView 120 + align={isFromSelf ? 'right' : 'left'} 121 + style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> 121 122 <EmojiReactionPicker 122 123 message={message} 123 124 onEmojiSelect={onEmojiSelect} ··· 126 127 )} 127 128 128 129 <ContextMenu.Trigger 129 - label={_(msg`Message options`)} 130 - contentLabel={_( 131 - msg`Message from @${ 132 - sender?.handle ?? 'unknown' // should always be defined 133 - }: ${message.text}`, 134 - )}> 130 + label={l`Message options`} 131 + contentLabel={l`Message from @${ 132 + sender?.handle ?? 'unknown' // should always be defined 133 + }: ${message.text}`}> 135 134 {children} 136 135 </ContextMenu.Trigger> 137 136 138 - <ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}> 137 + <ContextMenu.Outer 138 + align={isFromSelf ? 'right' : 'left'} 139 + style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> 139 140 {message.text.length > 0 && ( 140 141 <> 141 142 <ContextMenu.Item 142 143 testID="messageDropdownTranslateBtn" 143 - label={_(msg`Translate`)} 144 + label={l`Translate`} 144 145 onPress={onPressTranslateMessage}> 145 - <ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText> 146 - <ContextMenu.ItemIcon icon={Translate} position="right" /> 146 + <ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText> 147 + <ContextMenu.ItemIcon icon={TranslateIcon} position="right" /> 147 148 </ContextMenu.Item> 148 149 <ContextMenu.Item 149 150 testID="messageDropdownCopyBtn" 150 - label={_(msg`Copy message text`)} 151 + label={l`Copy message text`} 151 152 onPress={onCopyMessage}> 152 153 <ContextMenu.ItemText> 153 - {_(msg`Copy message text`)} 154 + {l`Copy message text`} 154 155 </ContextMenu.ItemText> 155 156 <ContextMenu.ItemIcon icon={ClipboardIcon} position="right" /> 156 157 </ContextMenu.Item> ··· 159 160 )} 160 161 <ContextMenu.Item 161 162 testID="messageDropdownDeleteBtn" 162 - label={_(msg`Delete message for me`)} 163 + label={l`Delete message for me`} 163 164 onPress={() => deleteControl.open()}> 164 - <ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText> 165 - <ContextMenu.ItemIcon icon={Trash} position="right" /> 165 + <ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText> 166 + <ContextMenu.ItemIcon icon={TrashIcon} position="right" /> 166 167 </ContextMenu.Item> 167 168 {!isFromSelf && ( 168 169 <ContextMenu.Item 169 170 testID="messageDropdownReportBtn" 170 - label={_(msg`Report message`)} 171 + label={l`Report message`} 171 172 onPress={() => reportControl.open()}> 172 - <ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText> 173 - <ContextMenu.ItemIcon icon={Warning} position="right" /> 173 + <ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText> 174 + <ContextMenu.ItemIcon icon={WarningIcon} position="right" /> 174 175 </ContextMenu.Item> 175 176 )} 176 177 </ContextMenu.Outer> 177 178 </ContextMenu.Root> 178 - 179 179 <ReportDialog 180 180 control={reportControl} 181 181 subject={{ ··· 198 198 message, 199 199 }} 200 200 /> 201 - 202 201 <Prompt.Basic 203 202 control={deleteControl} 204 - title={_(msg`Delete message`)} 205 - description={_( 206 - msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`, 207 - )} 208 - confirmButtonCta={_(msg`Delete`)} 203 + title={l`Delete message`} 204 + description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`} 205 + confirmButtonCta={l`Delete`} 209 206 confirmButtonColor="negative" 210 207 onConfirm={onDelete} 211 208 />
+585 -203
src/components/dms/MessageItem.tsx
··· 1 - import {memo, useCallback, useMemo} from 'react' 1 + import {memo, useCallback, useMemo, useState} from 'react' 2 2 import { 3 3 type GestureResponderEvent, 4 + Pressable, 4 5 type StyleProp, 5 6 type TextStyle, 6 7 View, 7 8 } from 'react-native' 8 9 import Animated, { 10 + FadeIn, 11 + FadeOut, 9 12 LayoutAnimationConfig, 10 13 LinearTransition, 14 + useSharedValue, 11 15 ZoomIn, 12 16 ZoomOut, 13 17 } from 'react-native-reanimated' ··· 16 20 ChatBskyConvoDefs, 17 21 RichText as RichTextAPI, 18 22 } from '@atproto/api' 19 - import {type I18n} from '@lingui/core' 20 - import {msg} from '@lingui/core/macro' 21 - import {useLingui} from '@lingui/react' 23 + import {plural} from '@lingui/core/macro' 24 + import {Trans, useLingui} from '@lingui/react/macro' 22 25 26 + import {HITSLOP_10} from '#/lib/constants' 23 27 import {sanitizeDisplayName} from '#/lib/strings/display-names' 28 + import {sanitizeHandle} from '#/lib/strings/handles' 24 29 import {useConvoActive} from '#/state/messages/convo' 25 30 import {type ConvoItem} from '#/state/messages/convo/types' 31 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 26 32 import {useSession} from '#/state/session' 27 - import {TimeElapsed} from '#/view/com/util/TimeElapsed' 28 - import {atoms as a, native, useTheme} from '#/alf' 33 + import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' 34 + import {UserAvatar} from '#/view/com/util/UserAvatar' 35 + import {atoms as a, native, useTheme, web} from '#/alf' 29 36 import {isOnlyEmoji} from '#/alf/typography' 37 + import * as Dialog from '#/components/Dialog' 38 + import {useDialogControl} from '#/components/Dialog' 30 39 import {ActionsWrapper} from '#/components/dms/ActionsWrapper' 31 40 import {InlineLinkText} from '#/components/Link' 41 + import * as ProfileCard from '#/components/ProfileCard' 32 42 import {RichText} from '#/components/RichText' 33 43 import {Text} from '#/components/Typography' 34 - import {IS_NATIVE} from '#/env' 44 + import type * as bsky from '#/types/bsky' 35 45 import {DateDivider} from './DateDivider' 36 46 import {MessageItemEmbed} from './MessageItemEmbed' 37 - import {localDateString} from './util' 47 + 48 + const AVATAR_SIZE = 28 49 + const CLUSTERED_MESSAGE_GAP = 2 50 + const BORDER_RADIUS = 18 51 + const SQUARED_BORDER_RADIUS = 4 52 + const DISPLAY_NAME_INSET = 22 53 + 54 + // 42px avatar + 2 * 8px my_sm margins 55 + const ROW_HEIGHT = 58 56 + 57 + const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 58 + const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 59 + 60 + type Reaction = { 61 + key: string 62 + value: string 63 + senders: ChatBskyConvoDefs.ReactionViewSender[] 64 + count: number 65 + } 66 + 67 + function isWithinCluster({ 68 + isPending, 69 + adjacentMessage, 70 + isFromSameSender, 71 + currentSentAt, 72 + direction, 73 + }: { 74 + isPending: boolean 75 + adjacentMessage: 76 + | ChatBskyConvoDefs.MessageView 77 + | ChatBskyConvoDefs.DeletedMessageView 78 + | null 79 + isFromSameSender: boolean 80 + currentSentAt: string 81 + direction: 'prev' | 'next' 82 + }): boolean { 83 + if (!isFromSameSender) return true 84 + if (isPending && adjacentMessage) return false 85 + if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { 86 + const thisDate = new Date(currentSentAt) 87 + const adjDate = new Date(adjacentMessage.sentAt) 88 + const diff = 89 + direction === 'next' 90 + ? adjDate.getTime() - thisDate.getTime() 91 + : thisDate.getTime() - adjDate.getTime() 92 + return diff > CLUSTERED_MESSAGE_THRESHOLD_MS 93 + } 94 + return true 95 + } 38 96 39 97 let MessageItem = ({ 40 98 item, 99 + isGroupChat = false, 100 + profile, 41 101 }: { 42 102 item: ConvoItem & {type: 'message' | 'pending-message'} 103 + isGroupChat?: boolean 104 + profile?: bsky.profile.AnyProfileView 43 105 }): React.ReactNode => { 44 106 const t = useTheme() 45 107 const {currentAccount} = useSession() 46 - const {_} = useLingui() 108 + const {t: l} = useLingui() 47 109 const {convo} = useConvoActive() 110 + const moderationOpts = useModerationOpts() 111 + 112 + const reactionsControl = useDialogControl() 48 113 49 114 const {message, nextMessage, prevMessage} = item 50 115 const isPending = item.type === 'pending-message' 116 + 117 + const displayName = sanitizeDisplayName( 118 + profile?.displayName || sanitizeHandle(profile?.handle ?? ''), 119 + ) 51 120 52 121 const isFromSelf = message.sender?.did === currentAccount?.did 53 122 123 + const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) 54 124 const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) 55 125 56 - const isNextFromSelf = 57 - nextIsMessage && nextMessage.sender?.did === currentAccount?.did 126 + const isPrevFromSameSender = 127 + prevIsMessage && prevMessage.sender?.did === message.sender?.did 128 + const isNextFromSameSender = 129 + nextIsMessage && nextMessage.sender?.did === message.sender?.did 58 130 59 - const isNextFromSameSender = isNextFromSelf === isFromSelf 131 + const isFirstInCluster = useMemo( 132 + () => 133 + isWithinCluster({ 134 + isPending, 135 + adjacentMessage: prevMessage, 136 + isFromSameSender: isPrevFromSameSender, 137 + currentSentAt: message.sentAt, 138 + direction: 'prev', 139 + }), 140 + [isPending, prevMessage, isPrevFromSameSender, message.sentAt], 141 + ) 60 142 61 - const isNewDay = useMemo(() => { 62 - if (!prevMessage) return true 143 + const isLastInCluster = useMemo( 144 + () => 145 + isWithinCluster({ 146 + isPending, 147 + adjacentMessage: nextMessage, 148 + isFromSameSender: isNextFromSameSender, 149 + currentSentAt: message.sentAt, 150 + direction: 'next', 151 + }), 152 + [isPending, nextMessage, isNextFromSameSender, message.sentAt], 153 + ) 63 154 64 - const thisDate = new Date(message.sentAt) 65 - const prevDate = new Date(prevMessage.sentAt) 155 + const hasLargeGapFromPrev = 156 + !ChatBskyConvoDefs.isMessageView(prevMessage) || 157 + new Date(message.sentAt).getTime() - 158 + new Date(prevMessage.sentAt).getTime() > 159 + MESSAGE_GAP_THRESHOLD_MS 66 160 67 - return localDateString(thisDate) !== localDateString(prevDate) 68 - }, [message, prevMessage]) 69 - 70 - const isLastMessageOfDay = useMemo(() => { 71 - if (!nextMessage || !nextIsMessage) return true 161 + const showDateDivider = hasLargeGapFromPrev 72 162 73 - const thisDate = new Date(message.sentAt) 74 - const prevDate = new Date(nextMessage.sentAt) 163 + const isInCluster = !(isFirstInCluster && isLastInCluster) 164 + const isInMiddleOfCluster = 165 + isInCluster && !isFirstInCluster && !isLastInCluster 75 166 76 - return localDateString(thisDate) !== localDateString(prevDate) 77 - }, [message.sentAt, nextIsMessage, nextMessage]) 167 + const hasReactions = message.reactions && message.reactions.length > 0 168 + const squaredBottomCorner = 169 + !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster) 170 + const squaredTopCorner = 171 + isInCluster && (isInMiddleOfCluster || isLastInCluster) 78 172 79 - const needsTail = isLastMessageOfDay || !isNextFromSameSender 173 + const pendingColor = t.palette.primary_300 80 174 81 - const isLastInGroup = useMemo(() => { 82 - // if this message is pending, it means the next message is pending too 83 - if (isPending && nextMessage) { 84 - return false 85 - } 175 + const rt = useMemo(() => { 176 + return new RichTextAPI({text: message.text, facets: message.facets}) 177 + }, [message.text, message.facets]) 86 178 87 - // or, if there's a 5 minute gap between this message and the next 88 - if (ChatBskyConvoDefs.isMessageView(nextMessage)) { 89 - const thisDate = new Date(message.sentAt) 90 - const nextDate = new Date(nextMessage.sentAt) 179 + const hasEmbedAndText = 180 + AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 91 181 92 - const diff = nextDate.getTime() - thisDate.getTime() 182 + const avatar = profile ? ( 183 + <ProfileCard.Avatar 184 + profile={profile} 185 + size={AVATAR_SIZE} 186 + moderationOpts={moderationOpts!} 187 + disabledPreview 188 + /> 189 + ) : ( 190 + <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> 191 + ) 93 192 94 - // 5 minutes 95 - return diff > 5 * 60 * 1000 193 + const groupedReactions = useMemo(() => { 194 + const reactions = message.reactions ?? [] 195 + const grouped = new Map< 196 + string, 197 + { 198 + key: string 199 + value: string 200 + senders: ChatBskyConvoDefs.ReactionViewSender[] 201 + count: number 202 + } 203 + >() 204 + for (const reaction of reactions) { 205 + if (!reaction) continue 206 + const existing = grouped.get(reaction.value) 207 + if (existing) { 208 + existing.senders.push(reaction.sender) 209 + existing.count++ 210 + } else { 211 + grouped.set(reaction.value, { 212 + key: reaction.value, 213 + value: reaction.value, 214 + senders: [reaction.sender], 215 + count: 1, 216 + }) 217 + } 96 218 } 219 + return Array.from(grouped.values()) 220 + }, [message.reactions]) 97 221 98 - return true 99 - }, [message, nextMessage, isPending]) 222 + const reactions = useMemo(() => message.reactions ?? [], [message.reactions]) 100 223 101 - const pendingColor = t.palette.primary_200 102 - 103 - const rt = useMemo(() => { 104 - return new RichTextAPI({text: message.text, facets: message.facets}) 105 - }, [message.text, message.facets]) 224 + const reactionsLabel = useMemo(() => { 225 + if (reactions.length === 0) return '' 226 + if (reactions.length === 1) { 227 + const reaction = reactions[0] 228 + const sender = reaction.sender 229 + if (sender.did === currentAccount?.did) { 230 + return l`You reacted ${reaction.value}` 231 + } else { 232 + const senderDid = reaction.sender.did 233 + const sender = convo.members.find(member => member.did === senderDid) 234 + if (sender) { 235 + return l`${sanitizeDisplayName( 236 + sender.displayName || sender.handle, 237 + )} reacted ${reaction.value}` 238 + } 239 + return l`Someone reacted ${reaction.value}` 240 + } 241 + } 242 + return l`${plural(reactions.length, { 243 + one: '# person', 244 + other: '# people', 245 + })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` 246 + }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) 106 247 107 248 const appliedReactions = ( 108 249 <LayoutAnimationConfig skipEntering skipExiting> 109 - {message.reactions && message.reactions.length > 0 && ( 110 - <View 111 - style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}> 250 + {hasReactions ? ( 251 + <> 112 252 <View 113 253 style={[ 114 - a.flex_row, 115 - a.gap_2xs, 116 - a.py_xs, 117 - a.px_xs, 118 - a.justify_center, 119 - isFromSelf ? a.justify_end : a.justify_start, 120 - a.flex_wrap, 121 - a.pb_xs, 122 - t.atoms.bg_contrast_25, 123 - a.border, 124 - t.atoms.border_contrast_low, 125 - a.rounded_lg, 126 - t.atoms.shadow_sm, 127 - { 128 - // vibe coded number 129 - transform: [{translateY: -11}], 130 - }, 254 + isFromSelf ? a.align_end : a.align_start, 255 + a.px_sm, 256 + a.pb_2xs, 131 257 ]}> 132 - {message.reactions.map((reaction, _i, reactions) => { 133 - let label 134 - if (reaction.sender.did === currentAccount?.did) { 135 - label = _(msg`You reacted ${reaction.value}`) 136 - } else { 137 - const senderDid = reaction.sender.did 138 - const sender = convo.members.find( 139 - member => member.did === senderDid, 140 - ) 141 - if (sender) { 142 - label = _( 143 - msg`${sanitizeDisplayName( 144 - sender.displayName || sender.handle, 145 - )} reacted ${reaction.value}`, 146 - ) 147 - } else { 148 - label = _(msg`Someone reacted ${reaction.value}`) 149 - } 258 + <Pressable 259 + accessible={true} 260 + accessibilityLabel={reactionsLabel} 261 + accessibilityHint={ 262 + isGroupChat ? l`Tap to view reactions` : undefined 150 263 } 151 - return ( 264 + style={[ 265 + a.flex_row, 266 + a.gap_2xs, 267 + a.py_xs, 268 + a.px_xs, 269 + isFromSelf ? a.justify_end : a.justify_start, 270 + a.flex_wrap, 271 + a.rounded_lg, 272 + a.border, 273 + t.atoms.border_contrast_low, 274 + t.atoms.bg_contrast_25, 275 + t.atoms.shadow_sm, 276 + { 277 + transform: [{translateY: -8}], 278 + }, 279 + ]} 280 + onPress={() => 281 + isGroupChat ? reactionsControl.open() : undefined 282 + }> 283 + {groupedReactions.map(group => ( 152 284 <Animated.View 153 285 entering={native(ZoomIn.springify(200).delay(400))} 154 - exiting={reactions.length > 1 && native(ZoomOut.delay(200))} 286 + exiting={ 287 + groupedReactions.length > 1 && native(ZoomOut.delay(200)) 288 + } 155 289 layout={native(LinearTransition.delay(300))} 156 - key={reaction.sender.did + reaction.value} 157 - style={[a.p_2xs]} 158 - accessible={true} 159 - accessibilityLabel={label} 160 - accessibilityHint={_( 161 - msg`Double tap or long press the message to add a reaction`, 162 - )}> 290 + key={group.value} 291 + style={[a.p_2xs]}> 163 292 <Text emoji style={[a.text_sm]}> 164 - {reaction.value} 293 + {group.value} 165 294 </Text> 166 295 </Animated.View> 167 - ) 168 - })} 296 + ))} 297 + {groupedReactions.length !== reactions.length && 298 + reactions.length > 1 ? ( 299 + <View style={[a.p_2xs, a.justify_center]}> 300 + <Text 301 + style={[ 302 + a.text_xs, 303 + t.atoms.text_contrast_medium, 304 + {includeFontPadding: false}, 305 + ]}> 306 + {reactions.length} 307 + </Text> 308 + </View> 309 + ) : null} 310 + </Pressable> 169 311 </View> 170 - </View> 171 - )} 312 + <ReactionsDialog 313 + control={reactionsControl} 314 + members={convo.members} 315 + reactions={message.reactions} 316 + groupedReactions={groupedReactions} 317 + /> 318 + </> 319 + ) : null} 172 320 </LayoutAnimationConfig> 173 321 ) 174 322 175 323 return ( 176 324 <> 177 - {isNewDay && <DateDivider date={message.sentAt} />} 325 + {showDateDivider && ( 326 + <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> 327 + <DateDivider date={message.sentAt} /> 328 + </Animated.View> 329 + )} 178 330 <View 179 331 style={[ 180 - isFromSelf ? a.mr_md : a.ml_md, 181 - nextIsMessage && !isNextFromSameSender && a.mb_md, 332 + isFromSelf ? a.mr_sm : a.ml_sm, 333 + isFirstInCluster && !showDateDivider && a.mt_sm, 182 334 ]}> 183 - <ActionsWrapper isFromSelf={isFromSelf} message={message}> 184 - {AppBskyEmbedRecord.isView(message.embed) && ( 185 - <MessageItemEmbed embed={message.embed} /> 186 - )} 187 - {rt.text.length > 0 && ( 188 - <View 189 - style={ 190 - !isOnlyEmoji(message.text) && [ 191 - a.py_sm, 192 - a.my_2xs, 193 - a.rounded_md, 335 + <View style={[a.relative]}> 336 + {isGroupChat && !isFromSelf && isLastInCluster ? ( 337 + <View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}> 338 + {avatar} 339 + </View> 340 + ) : null} 341 + <View 342 + style={[ 343 + a.flex_grow, 344 + !isFromSelf && 345 + isGroupChat && { 346 + paddingLeft: AVATAR_SIZE, 347 + }, 348 + ]}> 349 + {isGroupChat && 350 + !isFromSelf && 351 + isFirstInCluster && 352 + !isOnlyEmoji(message.text) ? ( 353 + <Text 354 + style={[ 355 + a.text_xs, 356 + t.atoms.text_contrast_medium, 357 + a.pt_xs, 358 + a.pb_2xs, 194 359 { 195 - paddingLeft: 14, 196 - paddingRight: 14, 197 - backgroundColor: isFromSelf 198 - ? isPending 199 - ? pendingColor 200 - : t.palette.primary_500 201 - : t.palette.contrast_50, 202 - borderRadius: 17, 360 + paddingLeft: DISPLAY_NAME_INSET, 203 361 }, 204 - isFromSelf ? a.self_end : a.self_start, 205 - isFromSelf 206 - ? {borderBottomRightRadius: needsTail ? 2 : 17} 207 - : {borderBottomLeftRadius: needsTail ? 2 : 17}, 208 - ] 209 - }> 210 - <RichText 211 - value={rt} 212 - style={[a.text_md, isFromSelf && {color: t.palette.white}]} 213 - interactiveStyle={a.underline} 214 - enableTags 215 - emojiMultiplier={3} 216 - shouldProxyLinks={true} 217 - /> 218 - </View> 219 - )} 220 - 221 - {IS_NATIVE && appliedReactions} 222 - </ActionsWrapper> 223 - 224 - {!IS_NATIVE && appliedReactions} 225 - 226 - {isLastInGroup && ( 362 + ]}> 363 + {displayName} 364 + </Text> 365 + ) : null} 366 + <ActionsWrapper isFromSelf={isFromSelf} message={message}> 367 + {rt.text.length > 0 && ( 368 + <View 369 + accessibilityHint={l`Double tap or long press the message to add a reaction`} 370 + style={[ 371 + !isFromSelf && a.ml_sm, 372 + ...(isOnlyEmoji(message.text) 373 + ? [] 374 + : [ 375 + a.rounded_md, 376 + a.rounded_xl, 377 + a.py_sm, 378 + a.px_md, 379 + { 380 + marginTop: isFirstInCluster 381 + ? 0 382 + : CLUSTERED_MESSAGE_GAP, 383 + backgroundColor: isFromSelf 384 + ? isPending 385 + ? pendingColor 386 + : t.palette.primary_500 387 + : t.palette.contrast_50, 388 + }, 389 + isFromSelf ? a.self_end : a.self_start, 390 + isFromSelf 391 + ? { 392 + borderBottomRightRadius: 393 + squaredBottomCorner || hasEmbedAndText 394 + ? SQUARED_BORDER_RADIUS 395 + : BORDER_RADIUS, 396 + borderTopRightRadius: squaredTopCorner 397 + ? SQUARED_BORDER_RADIUS 398 + : BORDER_RADIUS, 399 + } 400 + : { 401 + borderBottomLeftRadius: 402 + squaredBottomCorner || hasEmbedAndText 403 + ? SQUARED_BORDER_RADIUS 404 + : BORDER_RADIUS, 405 + borderTopLeftRadius: squaredTopCorner 406 + ? SQUARED_BORDER_RADIUS 407 + : BORDER_RADIUS, 408 + }, 409 + ]), 410 + ]}> 411 + <RichText 412 + value={rt} 413 + style={[a.text_md, isFromSelf && {color: t.palette.white}]} 414 + interactiveStyle={a.underline} 415 + enableTags 416 + emojiMultiplier={3} 417 + shouldProxyLinks={true} 418 + /> 419 + </View> 420 + )} 421 + {AppBskyEmbedRecord.isView(message.embed) && ( 422 + <MessageItemEmbed 423 + embed={message.embed} 424 + isFromSelf={isFromSelf} 425 + squaredBottomCorner={squaredBottomCorner} 426 + squaredTopCorner={squaredTopCorner || hasEmbedAndText} 427 + /> 428 + )} 429 + {appliedReactions} 430 + </ActionsWrapper> 431 + </View> 432 + </View> 433 + {isLastInCluster && ( 227 434 <MessageItemMetadata 228 435 item={item} 229 - style={isFromSelf ? a.text_right : a.text_left} 436 + style={[isFromSelf ? a.text_right : a.text_left]} 230 437 /> 231 438 )} 232 439 </View> ··· 244 451 style: StyleProp<TextStyle> 245 452 }): React.ReactNode => { 246 453 const t = useTheme() 247 - const {_} = useLingui() 248 - const {message} = item 454 + const {t: l} = useLingui() 249 455 250 456 const handleRetry = useCallback( 251 457 (e: GestureResponderEvent) => { ··· 258 464 [item], 259 465 ) 260 466 261 - const relativeTimestamp = useCallback( 262 - (i18n: I18n, timestamp: string) => { 263 - const date = new Date(timestamp) 264 - const now = new Date() 265 - 266 - const time = i18n.date(date, { 267 - hour: 'numeric', 268 - minute: 'numeric', 269 - }) 270 - 271 - const diff = now.getTime() - date.getTime() 272 - 273 - // if under 30 seconds 274 - if (diff < 1000 * 30) { 275 - return _(msg`Now`) 276 - } 277 - 278 - return time 279 - }, 280 - [_], 281 - ) 467 + const errorColor = t.palette.negative_400 282 468 283 - return ( 284 - <Text 285 - style={[ 286 - a.text_xs, 287 - a.mt_2xs, 288 - a.mb_lg, 289 - t.atoms.text_contrast_medium, 290 - style, 291 - ]}> 292 - <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}> 293 - {({timeElapsed}) => ( 294 - <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 295 - {timeElapsed} 296 - </Text> 297 - )} 298 - </TimeElapsed> 299 - 300 - {item.type === 'pending-message' && item.failed && ( 301 - <> 302 - {' '} 303 - &middot;{' '} 304 - <Text 305 - style={[ 306 - a.text_xs, 307 - { 308 - color: t.palette.negative_400, 309 - }, 310 - ]}> 311 - {_(msg`Failed to send`)} 469 + switch (item.type) { 470 + case 'pending-message': 471 + return item.failed ? ( 472 + <Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}> 473 + <Text style={[a.text_xs, {color: errorColor}]}> 474 + <Trans>Message failed to send.</Trans> 312 475 </Text> 313 476 {item.retry && ( 314 477 <> 315 478 {' '} 316 - &middot;{' '} 317 479 <InlineLinkText 318 - label={_(msg`Click to retry failed message`)} 480 + label={l`Click to retry failed message`} 319 481 to="#" 320 482 onPress={handleRetry} 321 - style={[a.text_xs]}> 322 - {_(msg`Retry`)} 483 + style={[a.text_xs, {color: errorColor}]}> 484 + <Trans>Tap to retry</Trans> 323 485 </InlineLinkText> 486 + . 324 487 </> 325 488 )} 326 - </> 327 - )} 328 - </Text> 329 - ) 489 + </Text> 490 + ) : null 491 + default: 492 + return null 493 + } 330 494 } 331 495 MessageItemMetadata = memo(MessageItemMetadata) 332 496 export {MessageItemMetadata} 497 + 498 + function ReactionsDialog({ 499 + control, 500 + members, 501 + reactions, 502 + groupedReactions, 503 + }: { 504 + control: Dialog.DialogControlProps 505 + members: bsky.profile.AnyProfileView[] 506 + reactions?: ChatBskyConvoDefs.ReactionView[] 507 + groupedReactions?: Reaction[] 508 + }) { 509 + const t = useTheme() 510 + const {t: l} = useLingui() 511 + 512 + const [selected, setSelected] = useState('all') 513 + 514 + const handleFilter = (value: string) => { 515 + setSelected(value) 516 + } 517 + 518 + const filteredMembers = 519 + selected === 'all' 520 + ? members 521 + : members.filter(m => 522 + reactions?.some(r => r.sender.did === m.did && r.value === selected), 523 + ) 524 + 525 + const minHeight = members.length * ROW_HEIGHT 526 + 527 + return ( 528 + <Dialog.Outer 529 + control={control} 530 + onClose={() => setSelected('all')} 531 + nativeOptions={{preventExpansion: true, minHeight}}> 532 + <Dialog.Handle /> 533 + <View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}> 534 + <Text style={[a.font_bold, a.text_2xl, a.mb_sm]}> 535 + <Trans>Reactions</Trans> 536 + </Text> 537 + </View> 538 + <ReactionTabs 539 + groupedReactions={groupedReactions} 540 + selected={selected} 541 + totalReactions={reactions?.length ?? 0} 542 + onFilter={handleFilter} 543 + /> 544 + <Dialog.ScrollableInner 545 + label={l`Reactions`} 546 + contentContainerStyle={[a.pt_0]} 547 + style={[web({maxWidth: 400})]}> 548 + {filteredMembers.map(profile => { 549 + const displayName = sanitizeDisplayName( 550 + profile?.displayName || sanitizeHandle(profile?.handle ?? ''), 551 + ) 552 + const handle = sanitizeHandle(profile?.handle ?? '', '@') 553 + const reaction = reactions?.find( 554 + ({sender}) => sender.did === profile.did, 555 + ) 556 + const rt = reaction 557 + ? new RichTextAPI({text: reaction.value}) 558 + : undefined 559 + 560 + return rt ? ( 561 + <View 562 + key={profile.did} 563 + style={[ 564 + a.flex_row, 565 + a.gap_sm, 566 + a.align_center, 567 + a.justify_between, 568 + a.my_sm, 569 + ]}> 570 + <View style={[a.flex_row, a.gap_sm]}> 571 + <UserAvatar 572 + avatar={profile.avatar} 573 + size={42} 574 + type="user" 575 + hideLiveBadge 576 + /> 577 + <View> 578 + <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 579 + {displayName} 580 + </Text> 581 + <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 582 + {handle} 583 + </Text> 584 + </View> 585 + </View> 586 + <View> 587 + <RichText 588 + value={rt} 589 + style={[a.text_md]} 590 + interactiveStyle={a.underline} 591 + enableTags 592 + emojiMultiplier={2} 593 + shouldProxyLinks={true} 594 + /> 595 + </View> 596 + </View> 597 + ) : null 598 + })} 599 + </Dialog.ScrollableInner> 600 + </Dialog.Outer> 601 + ) 602 + } 603 + 604 + function ReactionTabs({ 605 + groupedReactions, 606 + selected, 607 + totalReactions, 608 + onFilter, 609 + }: { 610 + groupedReactions?: Reaction[] 611 + selected: string 612 + totalReactions: number 613 + onFilter: (value: string) => void 614 + }) { 615 + const t = useTheme() 616 + const {t: l} = useLingui() 617 + 618 + const contentSize = useSharedValue(0) 619 + const scrollX = useSharedValue(0) 620 + 621 + const handlePress = (value: string) => { 622 + onFilter(value) 623 + } 624 + 625 + const tabs = [ 626 + { 627 + key: 'all', 628 + value: l`All`, 629 + senders: [], 630 + count: totalReactions, 631 + } as Reaction, 632 + ...(groupedReactions ?? []), 633 + ] 634 + 635 + return ( 636 + <View accessibilityRole="list" style={[t.atoms.bg]}> 637 + <DraggableScrollView 638 + horizontal={true} 639 + showsHorizontalScrollIndicator={false} 640 + onScroll={e => { 641 + scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) 642 + }}> 643 + <Animated.View 644 + style={[ 645 + a.flex_row, 646 + a.flex_grow, 647 + a.gap_sm, 648 + a.align_center, 649 + a.justify_start, 650 + ]} 651 + onLayout={e => { 652 + contentSize.set(e.nativeEvent.layout.width) 653 + }}> 654 + {tabs?.map((reaction, index) => ( 655 + <ReactionTab 656 + key={reaction.value} 657 + index={index} 658 + reaction={reaction} 659 + selected={selected} 660 + total={tabs.length} 661 + onPress={handlePress} 662 + /> 663 + ))} 664 + </Animated.View> 665 + </DraggableScrollView> 666 + </View> 667 + ) 668 + } 669 + 670 + function ReactionTab({ 671 + index, 672 + reaction, 673 + selected, 674 + total, 675 + onPress, 676 + }: { 677 + index: number 678 + reaction: Reaction 679 + selected: string 680 + total: number 681 + onPress: (value: string) => void 682 + }) { 683 + const t = useTheme() 684 + const {t: l} = useLingui() 685 + 686 + return ( 687 + <Pressable 688 + accessibilityRole="button" 689 + accessibilityHint={ 690 + reaction.key === 'all' 691 + ? l`Tap to show all reactions ` 692 + : l`Tap to show ${reaction.value} reactions` 693 + } 694 + hitSlop={HITSLOP_10} 695 + style={[ 696 + a.flex_row, 697 + a.align_center, 698 + a.border, 699 + a.justify_center, 700 + a.rounded_lg, 701 + a.px_md, 702 + a.py_sm, 703 + a.mb_sm, 704 + t.atoms.border_contrast_low, 705 + selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg, 706 + index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null, 707 + ]} 708 + onPress={() => onPress(reaction.key)}> 709 + <Text emoji style={[a.text_sm]}> 710 + {l`${reaction.value} ${reaction.count}`} 711 + </Text> 712 + </Pressable> 713 + ) 714 + }
+39 -3
src/components/dms/MessageItemEmbed.tsx
··· 2 2 import {useWindowDimensions, View} from 'react-native' 3 3 import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api' 4 4 5 - import {atoms as a, native, tokens, useTheme, web} from '#/alf' 5 + import {atoms as a, native, useTheme, web} from '#/alf' 6 6 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 7 7 import {MessageContextProvider} from './MessageContext' 8 + 9 + const CLUSTERED_MESSAGE_GAP = 2 10 + const BORDER_RADIUS = 20 11 + const SQUARED_BORDER_RADIUS = 4 8 12 9 13 let MessageItemEmbed = ({ 10 14 embed, 15 + isFromSelf, 16 + squaredTopCorner, 17 + squaredBottomCorner, 11 18 }: { 12 19 embed: $Typed<AppBskyEmbedRecord.View> 20 + isFromSelf: boolean 21 + squaredTopCorner: boolean 22 + squaredBottomCorner: boolean 13 23 }): React.ReactNode => { 14 24 const t = useTheme() 15 25 const screen = useWindowDimensions() ··· 18 28 <MessageContextProvider> 19 29 <View 20 30 style={[ 21 - a.my_xs, 31 + isFromSelf ? a.mr_sm : a.ml_sm, 22 32 t.atoms.bg, 23 33 a.rounded_md, 24 34 native({ ··· 30 40 minWidth: 280, 31 41 maxWidth: 360, 32 42 }), 43 + { 44 + marginTop: CLUSTERED_MESSAGE_GAP, 45 + }, 33 46 ]}> 34 - <View style={{marginTop: tokens.space.sm * -1}}> 47 + <View style={{marginTop: -8}}> 35 48 <Embed 36 49 embed={embed} 37 50 allowNestedQuotes 38 51 viewContext={PostEmbedViewContext.Feed} 52 + style={[ 53 + a.rounded_xl, 54 + a.border_0, 55 + isFromSelf 56 + ? { 57 + backgroundColor: t.palette.primary_50, 58 + borderBottomRightRadius: squaredBottomCorner 59 + ? SQUARED_BORDER_RADIUS 60 + : BORDER_RADIUS, 61 + borderTopRightRadius: squaredTopCorner 62 + ? SQUARED_BORDER_RADIUS 63 + : BORDER_RADIUS, 64 + } 65 + : { 66 + backgroundColor: t.palette.contrast_50, 67 + borderBottomLeftRadius: squaredBottomCorner 68 + ? SQUARED_BORDER_RADIUS 69 + : BORDER_RADIUS, 70 + borderTopLeftRadius: squaredTopCorner 71 + ? SQUARED_BORDER_RADIUS 72 + : BORDER_RADIUS, 73 + }, 74 + ]} 39 75 /> 40 76 </View> 41 77 </View>
+102 -89
src/components/dms/MessagesListHeader.tsx
··· 5 5 type ModerationCause, 6 6 type ModerationDecision, 7 7 } from '@atproto/api' 8 - import {msg} from '@lingui/core/macro' 9 - import {useLingui} from '@lingui/react' 8 + import {useLingui} from '@lingui/react/macro' 9 + import {useNavigation} from '@react-navigation/native' 10 10 11 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 11 12 import {makeProfileLink} from '#/lib/routes/links' 12 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 + import {type NavigationProp} from '#/lib/routes/types' 14 + import {logger} from '#/logger' 13 15 import {type Shadow} from '#/state/cache/profile-shadow' 14 16 import {isConvoActive, useConvo} from '#/state/messages/convo' 15 17 import {type ConvoItem} from '#/state/messages/convo/types' 18 + import {useSession} from '#/state/session' 16 19 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 17 - import {atoms as a, useTheme, web} from '#/alf' 20 + import {atoms as a, useTheme} from '#/alf' 21 + import {AvatarBubbles} from '#/components/AvatarBubbles' 22 + import {Button, ButtonIcon} from '#/components/Button' 18 23 import {ConvoMenu} from '#/components/dms/ConvoMenu' 19 - import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' 24 + import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2' 25 + import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 20 26 import * as Layout from '#/components/Layout' 21 27 import {Link} from '#/components/Link' 22 - import {PostAlerts} from '#/components/moderation/PostAlerts' 23 28 import {ProfileBadges} from '#/components/ProfileBadges' 24 29 import {Text} from '#/components/Typography' 25 - import {IS_WEB} from '#/env' 30 + import {IS_LIQUID_GLASS, IS_WEB} from '#/env' 26 31 27 32 const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE 28 33 ··· 48 53 }, [moderation]) 49 54 50 55 return ( 51 - <Layout.Header.Outer> 56 + <Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}> 52 57 <View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}> 53 58 <View style={[{minHeight: PFP_SIZE}, a.justify_center]}> 54 59 <Layout.Header.BackButton /> ··· 72 77 <View style={a.gap_xs}> 73 78 <View 74 79 style={[ 75 - {width: 120, height: 16}, 80 + {width: 150, height: 16}, 76 81 a.rounded_xs, 77 82 t.atoms.bg_contrast_25, 78 83 a.mt_xs, 79 84 ]} 80 85 /> 81 - <View 82 - style={[ 83 - {width: 175, height: 12}, 84 - a.rounded_xs, 85 - t.atoms.bg_contrast_25, 86 - ]} 87 - /> 88 86 </View> 89 87 </View> 90 88 ··· 108 106 userBlock?: ModerationCause 109 107 } 110 108 }) { 111 - const {_} = useLingui() 109 + const {t: l} = useLingui() 112 110 const t = useTheme() 113 111 const convoState = useConvo() 112 + const {currentAccount} = useSession() 113 + 114 + const navigation = useNavigation<NavigationProp>() 115 + 116 + const groupInfo = convoState.getGroupInfo?.() 117 + const isGroupChat = groupInfo != null 114 118 115 119 const isDeletedAccount = profile?.handle === 'missing.invalid' 116 - const displayName = isDeletedAccount 117 - ? _(msg`Deleted Account`) 118 - : sanitizeDisplayName( 119 - profile.displayName || profile.handle, 120 - moderation.ui('displayName'), 121 - ) 120 + const displayName = isGroupChat 121 + ? (groupInfo.name ?? l`${profile.handle}'s group chat`) 122 + : isDeletedAccount 123 + ? l`Deleted Account` 124 + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) 122 125 123 - // @ts-ignore findLast is polyfilled - esb 124 126 const latestMessageFromOther = convoState.items.findLast( 125 127 (item: ConvoItem) => 126 - item.type === 'message' && item.message.sender.did === profile.did, 128 + item.type === 'message' && 129 + item.message.sender.did !== currentAccount?.did, 127 130 ) 128 131 129 132 const latestReportableMessage = ··· 131 134 ? latestMessageFromOther.message 132 135 : undefined 133 136 137 + const handleNavigateToSettings = () => { 138 + const convoId = convoState.convo?.id 139 + if (convoId) { 140 + navigation.navigate('MessagesConversationSettings', { 141 + conversation: convoId, 142 + }) 143 + } else { 144 + logger.error(`handleNavigateToSettings: missing convo ID`) 145 + } 146 + } 147 + 134 148 return ( 135 149 <View style={[a.flex_1]}> 136 150 <View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}> 137 - <Link 138 - label={_(msg`View ${displayName}'s profile`)} 139 - style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]} 140 - to={makeProfileLink(profile)}> 141 - <PreviewableUserAvatar 142 - size={PFP_SIZE} 143 - profile={profile} 144 - moderation={moderation.ui('avatar')} 145 - disableHoverCard={moderation.blocked} 146 - /> 147 - <View style={[a.flex_1]}> 148 - <View style={[a.flex_row, a.align_center]}> 149 - <Text 150 - emoji 151 - style={[ 152 - a.text_md, 153 - a.font_semi_bold, 154 - a.self_start, 155 - web(a.leading_normal), 156 - ]} 157 - numberOfLines={1}> 158 - {displayName} 159 - </Text> 160 - <ProfileBadges profile={profile} size="md" style={[a.pl_xs]} /> 161 - </View> 162 - {!isDeletedAccount && ( 163 - <Text 164 - style={[ 165 - t.atoms.text_contrast_medium, 166 - a.text_xs, 167 - web([a.leading_normal, {marginTop: -2}]), 168 - ]} 169 - numberOfLines={1}> 170 - @{profile.handle} 151 + {isGroupChat ? ( 152 + <View 153 + style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}> 154 + <AvatarBubbles 155 + size="small" 156 + profiles={convoState.recipients ?? []} 157 + /> 158 + <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> 159 + {displayName} 160 + </Text> 161 + </View> 162 + ) : ( 163 + <Link 164 + label={l`View ${displayName}'s profile`} 165 + style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]} 166 + to={makeProfileLink(profile)}> 167 + <PreviewableUserAvatar 168 + size={PFP_SIZE} 169 + profile={profile} 170 + moderation={moderation.ui('avatar')} 171 + disableHoverCard={moderation.blocked} 172 + /> 173 + <View style={[a.flex_1]}> 174 + <View style={[a.flex_row, a.align_center]}> 175 + <Text 176 + emoji 177 + style={[a.text_md, a.font_semi_bold, a.self_start]} 178 + numberOfLines={1}> 179 + {displayName} 180 + </Text> 181 + <ProfileBadges profile={profile} size="md" style={[a.pl_xs]} /> 171 182 {convoState.convo?.muted && ( 172 183 <> 173 - {' '} 174 - &middot;{' '} 175 - <BellStroke 176 - size="xs" 184 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 185 + {' '} 186 + &middot;{' '} 187 + </Text> 188 + <BellOffIcon 189 + size="sm" 177 190 style={t.atoms.text_contrast_medium} 178 191 /> 179 192 </> 180 193 )} 181 - </Text> 182 - )} 183 - </View> 184 - </Link> 194 + </View> 195 + </View> 196 + </Link> 197 + )} 185 198 186 199 <View style={[{minHeight: PFP_SIZE}, a.justify_center]}> 187 200 <Layout.Header.Slot> 188 - {isConvoActive(convoState) && ( 189 - <ConvoMenu 190 - convo={convoState.convo} 191 - profile={profile} 192 - currentScreen="conversation" 193 - blockInfo={blockInfo} 194 - latestReportableMessage={latestReportableMessage} 195 - /> 196 - )} 201 + {isConvoActive(convoState) ? ( 202 + isGroupChat ? ( 203 + <Button 204 + label={l`Open group chat settings`} 205 + size="small" 206 + color="secondary" 207 + shape="round" 208 + variant="ghost" 209 + style={[a.bg_transparent]} 210 + onPress={handleNavigateToSettings}> 211 + <ButtonIcon icon={DotsHorizontalIcon} size="md" /> 212 + </Button> 213 + ) : ( 214 + <ConvoMenu 215 + convo={convoState.convo} 216 + profile={profile} 217 + currentScreen="conversation" 218 + blockInfo={blockInfo} 219 + latestReportableMessage={latestReportableMessage} 220 + /> 221 + ) 222 + ) : null} 197 223 </Layout.Header.Slot> 198 224 </View> 199 - </View> 200 - 201 - <View 202 - style={[ 203 - { 204 - paddingLeft: PFP_SIZE + a.gap_md.gap, 205 - }, 206 - ]}> 207 - <PostAlerts 208 - modui={moderation.ui('contentList')} 209 - size="lg" 210 - style={[a.pt_xs]} 211 - /> 212 225 </View> 213 226 </View> 214 227 )
+25 -6
src/components/dms/dialogs/NewChatDialog.tsx
··· 3 3 4 4 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 5 5 import {logger} from '#/logger' 6 + import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' 6 7 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 7 8 import {FAB} from '#/view/com/util/fab/FAB' 8 9 import {useTheme} from '#/alf' 9 10 import * as Dialog from '#/components/Dialog' 10 11 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 11 12 import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' 12 - import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 13 + import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message' 13 14 import * as Toast from '#/components/Toast' 14 15 import {useAnalytics} from '#/analytics' 15 16 ··· 38 39 }, 39 40 onError: error => { 40 41 logger.error('Failed to create chat', {safeMessage: error}) 41 - Toast.show(l`An issue occurred starting the chat`, { 42 + Toast.show(l`An issue occurred starting the chat, please try again`, { 42 43 type: 'error', 43 44 }) 44 45 }, 45 46 }) 46 47 48 + const {mutate: createGroupChat} = useCreateGroupChat({ 49 + onSuccess: data => { 50 + onNewChat(data.convo.id) 51 + ax.metric('groupchat:create', {logContext: 'NewChatDialog'}) 52 + }, 53 + onError: error => { 54 + logger.error('Failed to create groupchat', {safeMessage: error}) 55 + Toast.show( 56 + l`An issue occurred creating the group chat, please try again`, 57 + { 58 + type: 'error', 59 + }, 60 + ) 61 + }, 62 + }) 63 + 47 64 const onCreateChat = useCallback( 48 65 (did: string) => { 49 66 control.close(() => createChat([did])) ··· 52 69 ) 53 70 54 71 const onCreateGroupChat = useCallback( 55 - (_dids: string[], _groupName: string) => { 56 - control.close() 72 + (members: string[], name: string) => { 73 + control.close(() => { 74 + createGroupChat({members, name}) 75 + }) 57 76 }, 58 - [control], 77 + [control, createGroupChat], 59 78 ) 60 79 61 80 const onPress = useCallback(() => { ··· 74 93 <FAB 75 94 testID="newChatFAB" 76 95 onPress={wrappedOnPress} 77 - icon={<Plus size="lg" fill={t.palette.white} />} 96 + icon={<NewChatIcon size="lg" fill={t.palette.white} />} 78 97 accessibilityRole="button" 79 98 accessibilityLabel={l`New chat`} 80 99 accessibilityHint=""
+4
src/components/icons/Message.tsx
··· 15 15 export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({ 16 16 path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', 17 17 }) 18 + 19 + export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ 20 + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z', 21 + })
+1
src/lib/routes/types.ts
··· 73 73 Hashtag: {tag: string; author?: string} 74 74 Topic: {topic: string} 75 75 MessagesConversation: {conversation: string; embed?: string; accept?: true} 76 + MessagesConversationSettings: {conversation: string} 76 77 MessagesSettings: undefined 77 78 MessagesInbox: undefined 78 79 NotificationsActivityList: {posts: string}
+1
src/routes.ts
··· 85 85 MessagesSettings: '/messages/settings', 86 86 MessagesInbox: '/messages/inbox', 87 87 MessagesConversation: '/messages/:conversation', 88 + MessagesConversationSettings: '/messages/:conversation/settings', 88 89 // starter packs 89 90 Start: '/start/:name/:rkey', 90 91 StarterPackEdit: '/starter-pack/edit/:rkey',
+45 -14
src/screens/Messages/Conversation.tsx
··· 1 1 import {useCallback, useEffect, useMemo, useState} from 'react' 2 - import {View} from 'react-native' 2 + import {type LayoutChangeEvent, View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 3 4 import { 4 5 type AppBskyActorDefs, 5 6 moderateProfile, 6 7 type ModerationDecision, 7 8 } from '@atproto/api' 8 - import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect' 9 + import { 10 + ScrollEdgeEffect, 11 + ScrollEdgeEffectProvider, 12 + } from '@bsky.app/expo-scroll-edge-effect' 9 13 import {msg} from '@lingui/core/macro' 10 14 import {useLingui} from '@lingui/react' 11 15 import {Trans} from '@lingui/react/macro' ··· 45 49 import {Error} from '#/components/Error' 46 50 import * as Layout from '#/components/Layout' 47 51 import {Loader} from '#/components/Loader' 48 - import {IS_WEB} from '#/env' 52 + import {IS_LIQUID_GLASS, IS_WEB} from '#/env' 49 53 50 54 type Props = NativeStackScreenProps< 51 55 CommonNavigatorParams, ··· 83 87 ) 84 88 85 89 return ( 86 - <Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}> 90 + <Layout.Screen 91 + testID="convoScreen" 92 + noInsetTop={IS_LIQUID_GLASS} 93 + style={web([{minHeight: 0}, a.flex_1])}> 87 94 <ScrollEdgeEffectProvider> 88 95 <ConvoProvider key={convoId} convoId={convoId}> 89 96 <Inner /> ··· 98 105 const convoState = useConvo() 99 106 const {_} = useLingui() 100 107 const isFocused = useIsFocused() 108 + const {top: topInset} = useSafeAreaInsets() 101 109 102 110 const moderationOpts = useModerationOpts() 103 111 const {data: recipientUnshadowed} = useProfileQuery({ 104 - did: convoState.recipients?.[0].did, 112 + did: convoState.getPrimaryMember?.()?.did, 105 113 }) 106 114 const recipient = useMaybeProfileShadow(recipientUnshadowed) 107 115 ··· 133 141 if (convoState.status === ConvoStatus.Error) { 134 142 return ( 135 143 <> 136 - <Layout.Center style={[a.flex_1]}> 144 + <Layout.Center 145 + style={[a.flex_1, IS_LIQUID_GLASS && {paddingTop: topInset}]}> 137 146 {moderation ? ( 138 - <MessagesListHeader moderation={moderation} profile={recipient} /> 147 + <MessagesListHeader profile={recipient} moderation={moderation} /> 139 148 ) : ( 140 149 <MessagesListHeader /> 141 150 )} ··· 154 163 <Layout.Center style={[a.flex_1]}> 155 164 {/* MessagesList does not use the body scroll */} 156 165 {isFocused && IS_WEB && <RemoveScrollBar />} 157 - {!readyToShow && 158 - (moderation ? ( 159 - <MessagesListHeader moderation={moderation} profile={recipient} /> 160 - ) : ( 161 - <MessagesListHeader /> 162 - ))} 166 + {!readyToShow && ( 167 + <View style={IS_LIQUID_GLASS && {paddingTop: topInset}}> 168 + {moderation ? ( 169 + <MessagesListHeader profile={recipient} moderation={moderation} /> 170 + ) : ( 171 + <MessagesListHeader /> 172 + )} 173 + </View> 174 + )} 163 175 <View style={[a.flex_1]}> 164 176 {moderation && recipient ? ( 165 177 <InnerReady ··· 205 217 }) { 206 218 const convoState = useConvo() 207 219 const navigation = useNavigation<NavigationProp>() 220 + const {top: topInset} = useSafeAreaInsets() 221 + const [headerHeight, setHeaderHeight] = useState(0) 222 + const onHeaderLayout = (e: LayoutChangeEvent) => { 223 + setHeaderHeight(e.nativeEvent.layout.height) 224 + } 208 225 const {params} = 209 226 useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() 210 227 const {needsEmailVerification} = useEmail() ··· 248 265 maybeBlockForEmailVerification() 249 266 }, [maybeBlockForEmailVerification]) 250 267 268 + const header = ( 269 + <MessagesListHeader profile={recipient} moderation={moderation} /> 270 + ) 271 + 251 272 return ( 252 273 <> 253 - <MessagesListHeader profile={recipient} moderation={moderation} /> 274 + {IS_LIQUID_GLASS ? ( 275 + <ScrollEdgeEffect 276 + edge="top" 277 + style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]} 278 + onLayout={onHeaderLayout}> 279 + {header} 280 + </ScrollEdgeEffect> 281 + ) : ( 282 + header 283 + )} 254 284 {isConvoActive(convoState) && ( 255 285 <MessagesList 256 286 hasScrolled={hasScrolled} 257 287 setHasScrolled={setHasScrolled} 258 288 blocked={moderation?.blocked} 259 289 hasAcceptOverride={!!params.accept} 290 + transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0} 260 291 footer={ 261 292 <MessagesListBlockedFooter 262 293 recipient={recipient}
+1082
src/screens/Messages/ConversationSettings.tsx
··· 1 + import {useMemo, useState} from 'react' 2 + import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 3 + import {type ChatBskyConvoDefs, moderateProfile} from '@atproto/api' 4 + import {plural} from '@lingui/core/macro' 5 + import {Trans, useLingui} from '@lingui/react/macro' 6 + import {useNavigation} from '@react-navigation/native' 7 + 8 + import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' 9 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 10 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 11 + import { 12 + type CommonNavigatorParams, 13 + type NativeStackScreenProps, 14 + type NavigationProp, 15 + } from '#/lib/routes/types' 16 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 17 + import {sanitizeHandle} from '#/lib/strings/handles' 18 + import {logger} from '#/logger' 19 + import {type Shadow} from '#/state/cache/types' 20 + import {ConvoProvider, useConvo} from '#/state/messages/convo' 21 + import {ConvoStatus} from '#/state/messages/convo/types' 22 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 23 + import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 24 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 25 + import {useMuteConvo} from '#/state/queries/messages/mute-conversation' 26 + import {useProfileBlockMutationQueue} from '#/state/queries/profile' 27 + import {useSession} from '#/state/session' 28 + import {List} from '#/view/com/util/List' 29 + import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 30 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 31 + import {AvatarBubbles} from '#/components/AvatarBubbles' 32 + import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' 33 + import type * as Dialog from '#/components/Dialog' 34 + import {Error} from '#/components/Error' 35 + import * as TextField from '#/components/forms/TextField' 36 + import {useInteractionState} from '#/components/hooks/useInteractionState' 37 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 38 + import { 39 + Bell2_Stroke2_Corner0_Rounded as BellIcon, 40 + Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, 41 + } from '#/components/icons/Bell2' 42 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 43 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' 44 + import {type Props as SVGIconProps} from '#/components/icons/common' 45 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 46 + import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' 47 + import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 48 + import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 49 + import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 50 + import { 51 + Person_Stroke2_Corner2_Rounded as PersonIcon, 52 + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, 53 + } from '#/components/icons/Person' 54 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 55 + import * as Layout from '#/components/Layout' 56 + import {InlineLinkText} from '#/components/Link' 57 + import * as Menu from '#/components/Menu' 58 + import {type TriggerChildProps} from '#/components/Menu/types' 59 + import * as Prompt from '#/components/Prompt' 60 + import {SubtleHover} from '#/components/SubtleHover' 61 + import * as Toast from '#/components/Toast' 62 + import {Text} from '#/components/Typography' 63 + import {useAnalytics} from '#/analytics' 64 + import {IS_NATIVE} from '#/env' 65 + import type * as bsky from '#/types/bsky' 66 + 67 + const MEMBER_LIMIT = 50 68 + const ROW_SPACING = 10 69 + 70 + type Item = 71 + | { 72 + type: 'MEMBERS_AND_REQUESTS' 73 + } 74 + | { 75 + type: 'ADD_MEMBERS_LINK' 76 + } 77 + | { 78 + type: 'CHAT_MEMBER' 79 + profile: Shadow<bsky.profile.AnyProfileView> 80 + status: 'owner' | 'member' | 'invited' 81 + } 82 + 83 + type Props = NativeStackScreenProps< 84 + CommonNavigatorParams, 85 + 'MessagesConversationSettings' 86 + > 87 + 88 + /** 89 + * TODO This is just layout for now. 90 + */ 91 + export function MessagesConversationSettingsScreen({route}: Props) { 92 + const {gtTablet} = useBreakpoints() 93 + 94 + const convoId = route.params.conversation 95 + 96 + return ( 97 + <Layout.Screen> 98 + <Layout.Header.Outer> 99 + <Layout.Header.BackButton /> 100 + <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 101 + <Layout.Header.TitleText> 102 + <Trans>Group chat settings</Trans> 103 + </Layout.Header.TitleText> 104 + </Layout.Header.Content> 105 + <Layout.Header.Slot /> 106 + </Layout.Header.Outer> 107 + <ConvoProvider key={convoId} convoId={convoId}> 108 + <SettingsInner /> 109 + </ConvoProvider> 110 + </Layout.Screen> 111 + ) 112 + } 113 + 114 + function keyExtractor(item: Item) { 115 + return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type 116 + } 117 + 118 + function SettingsInner() { 119 + const {t: l} = useLingui() 120 + 121 + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) 122 + const bottomBarOffset = useBottomBarOffset() 123 + 124 + const convoState = useConvo() 125 + const {currentAccount} = useSession() 126 + const primaryMember = convoState?.getPrimaryMember?.() 127 + 128 + const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? [] 129 + const invites: string[] = [] 130 + 131 + const items = [ 132 + { 133 + type: 'MEMBERS_AND_REQUESTS', 134 + }, 135 + { 136 + type: 'ADD_MEMBERS_LINK', 137 + }, 138 + ...[...data] 139 + .sort((a, b) => { 140 + const aIsAdmin = a.did === primaryMember?.did 141 + const bIsAdmin = b.did === primaryMember?.did 142 + const aIsSelf = a.did === currentAccount?.did 143 + const bIsSelf = b.did === currentAccount?.did 144 + if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1 145 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 146 + return 0 147 + }) 148 + .map(profile => ({ 149 + type: 'CHAT_MEMBER', 150 + profile, 151 + status: 152 + primaryMember?.did === profile.did 153 + ? 'owner' 154 + : invites.includes(profile.did) 155 + ? 'invited' 156 + : 'member', 157 + })), 158 + ] 159 + 160 + function renderItem({item}: {item: Item}) { 161 + switch (item.type) { 162 + case 'MEMBERS_AND_REQUESTS': 163 + return <MembersAndRequests memberCount={data.length} requestCount={5} /> 164 + case 'ADD_MEMBERS_LINK': 165 + return <AddMembersLink /> 166 + case 'CHAT_MEMBER': 167 + return <Member profile={item.profile} status={item.status} /> 168 + default: 169 + return null 170 + } 171 + } 172 + 173 + if (convoState.status === ConvoStatus.Error) { 174 + return ( 175 + <> 176 + <Error 177 + title={l`Something went wrong`} 178 + message={l`We couldn’t load this conversation’s settings`} 179 + onRetry={() => convoState.error.retry()} 180 + sideBorders={false} 181 + /> 182 + </> 183 + ) 184 + } 185 + 186 + return ( 187 + <List 188 + data={items} 189 + contentContainerStyle={ 190 + IS_NATIVE && {paddingBottom: bottomBarOffset + ROW_SPACING} 191 + } 192 + desktopFixedHeight 193 + initialNumToRender={initialNumToRender} 194 + keyExtractor={keyExtractor} 195 + ListHeaderComponent={ 196 + convoState.convo ? ( 197 + <SettingsHeader convo={convoState.convo} profiles={data} /> 198 + ) : ( 199 + <SettingsHeaderPlaceholder /> 200 + ) 201 + } 202 + renderItem={renderItem} 203 + sideBorders={false} 204 + windowSize={11} 205 + onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} 206 + /> 207 + ) 208 + } 209 + 210 + function MembersAndRequests({ 211 + memberCount, 212 + requestCount, 213 + }: { 214 + memberCount: number 215 + requestCount: number 216 + }) { 217 + const t = useTheme() 218 + const {t: l} = useLingui() 219 + 220 + return ( 221 + <View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}> 222 + <View style={[a.flex_row, a.align_center]}> 223 + <Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}> 224 + <Trans>Members</Trans>{' '} 225 + </Text> 226 + <Text 227 + style={[ 228 + a.text_xs, 229 + a.font_medium, 230 + {color: t.palette.contrast_500}, 231 + ]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text> 232 + </View> 233 + {requestCount > 0 ? ( 234 + <InlineLinkText 235 + label={l`View incoming group chat requests`} 236 + style={[a.text_sm, a.text_right, a.font_semi_bold]} 237 + to="#"> 238 + {l`${plural(requestCount, { 239 + one: '# request', 240 + other: '# requests', 241 + })}`} 242 + </InlineLinkText> 243 + ) : null} 244 + </View> 245 + ) 246 + } 247 + 248 + function AddMembersLink() { 249 + const t = useTheme() 250 + 251 + return ( 252 + <SubtleHoverWrapper> 253 + <View 254 + style={[ 255 + a.mx_xl, 256 + { 257 + marginTop: ROW_SPACING, 258 + marginBottom: ROW_SPACING, 259 + }, 260 + ]}> 261 + <Pressable 262 + accessibilityRole="button" 263 + style={({pressed}) => [ 264 + a.flex_row, 265 + a.align_center, 266 + a.justify_between, 267 + pressed && web({outline: 'none'}), 268 + ]}> 269 + {({pressed}) => ( 270 + <> 271 + <View> 272 + <View style={[a.flex_row, a.align_center]}> 273 + <View 274 + style={[ 275 + a.flex_row, 276 + a.align_center, 277 + a.justify_center, 278 + a.p_lg, 279 + a.rounded_full, 280 + pressed 281 + ? t.atoms.bg_contrast_100 282 + : t.atoms.bg_contrast_50, 283 + { 284 + height: 48, 285 + width: 48, 286 + }, 287 + ]}> 288 + <PlusIcon style={[t.atoms.text_contrast_high]} size="sm" /> 289 + </View> 290 + <Text 291 + style={[ 292 + a.text_md, 293 + a.font_semi_bold, 294 + a.pl_sm, 295 + t.atoms.text, 296 + ]}> 297 + <Trans>Add members</Trans> 298 + </Text> 299 + </View> 300 + </View> 301 + <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 302 + </> 303 + )} 304 + </Pressable> 305 + </View> 306 + </SubtleHoverWrapper> 307 + ) 308 + } 309 + 310 + function Member({ 311 + profile, 312 + status, 313 + }: { 314 + profile: Shadow<bsky.profile.AnyProfileView> 315 + status: 'owner' | 'member' | 'invited' 316 + }) { 317 + const navigation = useNavigation<NavigationProp>() 318 + const t = useTheme() 319 + const {t: l} = useLingui() 320 + 321 + const {currentAccount} = useSession() 322 + const moderationOpts = useModerationOpts() 323 + const moderation = useMemo( 324 + () => 325 + moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, 326 + [profile, moderationOpts], 327 + ) 328 + 329 + if (!moderation) return null 330 + 331 + const isDeletedAccount = profile.handle === 'missing.invalid' 332 + const displayName = isDeletedAccount 333 + ? l`Deleted Account` 334 + : sanitizeDisplayName( 335 + profile.displayName || profile.handle, 336 + moderation.ui('displayName'), 337 + ) 338 + 339 + let statusBadge: React.ReactNode | null = null 340 + if (currentAccount?.did === profile.did) { 341 + switch (status) { 342 + case 'owner': 343 + statusBadge = <StatusBadge label={l`Admin`} /> 344 + break 345 + } 346 + } else { 347 + statusBadge = <MemberMenu profile={profile} type={status} /> 348 + } 349 + 350 + return ( 351 + <SubtleHoverWrapper> 352 + <Pressable 353 + accessibilityRole="button" 354 + style={[ 355 + a.mx_xl, 356 + { 357 + marginTop: ROW_SPACING, 358 + marginBottom: ROW_SPACING, 359 + }, 360 + ]} 361 + onPress={() => { 362 + navigation.navigate('Profile', {name: profile.did}) 363 + }}> 364 + <View style={[a.flex_row, a.align_center, a.justify_between]}> 365 + <View style={[a.flex_row, a.align_center]}> 366 + <PreviewableUserAvatar 367 + profile={profile} 368 + size={48} 369 + moderation={moderation.ui('avatar')} 370 + /> 371 + <View style={[a.mx_sm]}> 372 + <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 373 + {displayName} 374 + </Text> 375 + <Text 376 + style={[ 377 + a.text_xs, 378 + {color: t.palette.contrast_500}, 379 + web(a.pt_2xs), 380 + ]}> 381 + {sanitizeHandle(profile.handle, '@')} 382 + </Text> 383 + </View> 384 + </View> 385 + <View>{statusBadge}</View> 386 + </View> 387 + </Pressable> 388 + </SubtleHoverWrapper> 389 + ) 390 + } 391 + 392 + function StatusBadge({ 393 + label, 394 + style, 395 + }: { 396 + label: string 397 + style?: StyleProp<ViewStyle> 398 + }) { 399 + const t = useTheme() 400 + 401 + return ( 402 + <View 403 + style={[ 404 + a.rounded_xs, 405 + t.atoms.bg_contrast_50, 406 + { 407 + paddingTop: 3, 408 + paddingBottom: 3, 409 + paddingLeft: 6, 410 + paddingRight: 6, 411 + }, 412 + style, 413 + ]}> 414 + <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 415 + {label} 416 + </Text> 417 + </View> 418 + ) 419 + } 420 + 421 + function StatusButton({ 422 + label, 423 + style, 424 + ...rest 425 + }: { 426 + label: string 427 + style?: StyleProp<ViewStyle> 428 + } & TriggerChildProps['props']) { 429 + const t = useTheme() 430 + 431 + return ( 432 + <Pressable 433 + style={[ 434 + a.rounded_xs, 435 + t.atoms.bg_contrast_50, 436 + { 437 + paddingTop: 3, 438 + paddingBottom: 3, 439 + paddingLeft: 6, 440 + paddingRight: 6, 441 + }, 442 + style, 443 + ]} 444 + {...rest}> 445 + <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 446 + {label} 447 + </Text> 448 + </Pressable> 449 + ) 450 + } 451 + 452 + function MemberMenu({ 453 + profile, 454 + type, 455 + }: { 456 + profile: Shadow<bsky.profile.AnyProfileView> 457 + type: 'owner' | 'member' | 'invited' 458 + }) { 459 + const navigation = useNavigation<NavigationProp>() 460 + const t = useTheme() 461 + const {t: l} = useLingui() 462 + const ax = useAnalytics() 463 + 464 + const requireEmailVerification = useRequireEmailVerification() 465 + const convoState = useConvo() 466 + const {currentAccount} = useSession() 467 + 468 + const blockMemberPrompt = Prompt.usePromptControl() 469 + 470 + const isOwner = 471 + currentAccount?.did == null 472 + ? false 473 + : convoState.getPrimaryMember?.()?.did === currentAccount.did 474 + 475 + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 476 + const {mutate: initiateConvo} = useGetConvoForMembers({ 477 + onSuccess: ({convo}) => { 478 + ax.metric('chat:open', {logContext: 'ProfileHeader'}) 479 + navigation.navigate('MessagesConversation', {conversation: convo.id}) 480 + }, 481 + onError: () => { 482 + Toast.show(l`Failed to create conversation`) 483 + }, 484 + }) 485 + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 486 + 487 + const messageMember = () => { 488 + if (!convoAvailability?.canChat) { 489 + return 490 + } 491 + 492 + if (convoAvailability.convo) { 493 + ax.metric('chat:open', {logContext: 'ProfileHeader'}) 494 + navigation.navigate('MessagesConversation', { 495 + conversation: convoAvailability.convo.id, 496 + }) 497 + } else { 498 + ax.metric('chat:create', {logContext: 'ProfileHeader'}) 499 + initiateConvo([profile.did]) 500 + } 501 + } 502 + 503 + const handleMessageMember = requireEmailVerification(messageMember, { 504 + instructions: [ 505 + <Trans key="message"> 506 + Before you can message another user, you must first verify your email. 507 + </Trans>, 508 + ], 509 + }) 510 + 511 + const handleBlockMember = async () => { 512 + if (profile.viewer?.blocking) { 513 + try { 514 + await queueUnblock() 515 + Toast.show(l({message: 'Account unblocked', context: 'toast'})) 516 + } catch (err) { 517 + const e = err as Error 518 + if (e?.name !== 'AbortError') { 519 + ax.logger.error('Failed to unblock account', {message: e}) 520 + Toast.show(l`There was an issue! ${e.toString()}`, { 521 + type: 'error', 522 + }) 523 + } 524 + } 525 + } else { 526 + try { 527 + await queueBlock() 528 + Toast.show(l({message: 'Account blocked', context: 'toast'})) 529 + } catch (err) { 530 + const e = err as Error 531 + if (e?.name !== 'AbortError') { 532 + ax.logger.error('Failed to block account', {message: e}) 533 + Toast.show(l`There was an issue! ${e.toString()}`, { 534 + type: 'error', 535 + }) 536 + } 537 + } 538 + } 539 + } 540 + 541 + const moderationOpts = useModerationOpts() 542 + const moderation = useMemo( 543 + () => 544 + moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, 545 + [profile, moderationOpts], 546 + ) 547 + 548 + if (!moderation) return null 549 + 550 + const isDeletedAccount = profile.handle === 'missing.invalid' 551 + const displayName = isDeletedAccount 552 + ? l`Deleted Account` 553 + : sanitizeDisplayName( 554 + profile.displayName || profile.handle, 555 + moderation.ui('displayName'), 556 + ) 557 + 558 + return ( 559 + <> 560 + <Menu.Root> 561 + <Menu.Trigger label={l`Open chat member options for ${displayName}`}> 562 + {({props, state, control: menuControl}) => 563 + type === 'owner' || type === 'invited' ? ( 564 + <StatusButton 565 + {...props} 566 + label={type === 'owner' ? l`Admin` : l`Invited`} 567 + style={[ 568 + state.hovered || state.pressed || menuControl.isOpen 569 + ? { 570 + backgroundColor: t.palette.contrast_0, 571 + } 572 + : null, 573 + ]} 574 + /> 575 + ) : ( 576 + <Pressable 577 + {...props} 578 + style={[ 579 + a.rounded_full, 580 + a.p_sm, 581 + state.hovered || state.pressed || menuControl.isOpen 582 + ? { 583 + backgroundColor: t.palette.contrast_0, 584 + } 585 + : null, 586 + ]}> 587 + <EllipsisIcon 588 + style={[t.atoms.text_contrast_medium]} 589 + size="md" 590 + /> 591 + </Pressable> 592 + ) 593 + } 594 + </Menu.Trigger> 595 + <Menu.Outer> 596 + <Menu.Group> 597 + <Menu.Item 598 + label={l`View ${displayName}’s profile`} 599 + onPress={() => { 600 + navigation.navigate('Profile', {name: profile.did}) 601 + }}> 602 + <Menu.ItemText> 603 + <Trans>Go to profile</Trans> 604 + </Menu.ItemText> 605 + <Menu.ItemIcon icon={PersonIcon} /> 606 + </Menu.Item> 607 + <Menu.Item 608 + label={l`Message ${displayName}`} 609 + onPress={handleMessageMember}> 610 + <Menu.ItemText> 611 + <Trans>Message</Trans> 612 + </Menu.ItemText> 613 + <Menu.ItemIcon icon={MessageIcon} /> 614 + </Menu.Item> 615 + </Menu.Group> 616 + <Menu.Divider /> 617 + <Menu.Group> 618 + {type === 'owner' || type === 'member' ? ( 619 + <Menu.Item 620 + label={ 621 + profile.viewer?.blocking 622 + ? l`Unblock ${displayName}` 623 + : l`Block ${displayName}` 624 + } 625 + onPress={() => blockMemberPrompt.open()}> 626 + <Menu.ItemText> 627 + <Trans>Block</Trans> 628 + </Menu.ItemText> 629 + <Menu.ItemIcon icon={PersonXIcon} /> 630 + </Menu.Item> 631 + ) : null} 632 + {isOwner ? ( 633 + <Menu.Item 634 + label={l`Remove ${displayName} from this group chat`} 635 + onPress={() => {}}> 636 + <Menu.ItemText> 637 + <Trans>Remove from chat</Trans> 638 + </Menu.ItemText> 639 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 640 + </Menu.Item> 641 + ) : null} 642 + {isOwner && type === 'invited' ? ( 643 + <Menu.Item 644 + label={l`Uninvite ${displayName} from this group chat`} 645 + onPress={() => {}}> 646 + <Menu.ItemText> 647 + <Trans>Uninvite</Trans> 648 + </Menu.ItemText> 649 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 650 + </Menu.Item> 651 + ) : null} 652 + </Menu.Group> 653 + </Menu.Outer> 654 + </Menu.Root> 655 + <BlockMemberPrompt 656 + control={blockMemberPrompt} 657 + onConfirm={() => void handleBlockMember()} 658 + /> 659 + </> 660 + ) 661 + } 662 + 663 + function SettingsHeader({ 664 + convo, 665 + profiles, 666 + }: { 667 + convo: ChatBskyConvoDefs.ConvoView 668 + profiles: bsky.profile.AnyProfileView[] 669 + }) { 670 + const t = useTheme() 671 + const {t: l} = useLingui() 672 + 673 + const convoState = useConvo() 674 + const {currentAccount} = useSession() 675 + 676 + const isOwner = 677 + currentAccount?.did == null 678 + ? false 679 + : convoState.getPrimaryMember?.()?.did === currentAccount.did 680 + 681 + const {mutate: muteConvo} = useMuteConvo(convo.id, { 682 + onSuccess: data => { 683 + if (data.convo.muted) { 684 + Toast.show(l({message: 'Group chat muted', context: 'toast'})) 685 + } else { 686 + Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) 687 + } 688 + }, 689 + onError: () => { 690 + Toast.show(l`Could not mute group chat`, { 691 + type: 'error', 692 + }) 693 + }, 694 + }) 695 + 696 + const editNamePrompt = Prompt.usePromptControl() 697 + const inviteLinkPrompt = Prompt.usePromptControl() 698 + const lockChatPrompt = Prompt.usePromptControl() 699 + 700 + const [groupName, setGroupName] = useState( 701 + convoState.getGroupInfo?.()?.name ?? '', 702 + ) 703 + const [newGroupName, setNewGroupName] = useState(groupName) 704 + 705 + const [isLocked, setIsLocked] = useState(false) 706 + 707 + const handleToggleMute = () => { 708 + try { 709 + muteConvo({mute: !convo?.muted}) 710 + } catch (err) { 711 + const e = err as Error 712 + logger.error('Failed to mute group chat', {message: e}) 713 + Toast.show(l`There was an issue! ${e.toString()}`, {type: 'error'}) 714 + } 715 + } 716 + 717 + const handlePromptName = () => { 718 + editNamePrompt.open() 719 + } 720 + 721 + const handleEditName = () => { 722 + setGroupName(newGroupName) 723 + editNamePrompt.close() 724 + } 725 + 726 + const handlePromptInviteLink = () => { 727 + inviteLinkPrompt.open() 728 + } 729 + 730 + const handleConfirmInviteLink = () => { 731 + inviteLinkPrompt.close() 732 + } 733 + 734 + const handlePromptLock = () => { 735 + lockChatPrompt.open() 736 + } 737 + 738 + const handleConfirmLock = () => { 739 + setIsLocked(true) 740 + } 741 + 742 + const handleUnlock = () => { 743 + setIsLocked(false) 744 + } 745 + 746 + return ( 747 + <> 748 + <View 749 + style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 750 + <View style={[a.align_center, a.justify_center]}> 751 + <AvatarBubbles profiles={profiles} /> 752 + </View> 753 + <Text 754 + style={[ 755 + a.text_2xl, 756 + a.font_bold, 757 + a.text_center, 758 + a.pt_lg, 759 + t.atoms.text, 760 + ]}> 761 + {groupName} 762 + </Text> 763 + <Text 764 + style={[ 765 + a.text_sm, 766 + a.text_center, 767 + a.pt_xs, 768 + a.px_xl, 769 + t.atoms.text_contrast_high, 770 + ]}> 771 + Created April 2, 2026 772 + </Text> 773 + <View 774 + style={[ 775 + a.flex_row, 776 + a.align_center, 777 + a.justify_center, 778 + a.gap_2xl, 779 + a.pt_2xl, 780 + ]}> 781 + <SettingsButton 782 + color={convo?.muted ? 'negative_subtle' : 'secondary'} 783 + icon={convo?.muted ? BellOffIcon : BellIcon} 784 + label={ 785 + convo?.muted ? l`Unmute this group chat` : l`Mute this group chat` 786 + } 787 + text={convo?.muted ? l`Muted` : l`Mute`} 788 + onPress={handleToggleMute} 789 + /> 790 + {isOwner ? ( 791 + <SettingsButton 792 + icon={EditIcon} 793 + label={l`Edit this group chat’s name`} 794 + text={l`Edit name`} 795 + onPress={handlePromptName} 796 + /> 797 + ) : null} 798 + <SettingsButton 799 + icon={ChainLinkIcon} 800 + label={l`Create an invite link for this group chat`} 801 + text={l`Invite link`} 802 + onPress={handlePromptInviteLink} 803 + /> 804 + {isOwner ? ( 805 + <SettingsButton 806 + color={isLocked ? 'negative_subtle' : 'secondary'} 807 + icon={LockIcon} 808 + label={ 809 + isLocked ? l`Unlock this group chat` : l`Lock this group chat` 810 + } 811 + text={isLocked ? l`Locked` : l`Lock`} 812 + onPress={isLocked ? handleUnlock : handlePromptLock} 813 + /> 814 + ) : null} 815 + {isOwner ? null : ( 816 + <SettingsButton 817 + color="secondary" 818 + icon={FlagIcon} 819 + label={l`Report this group chat`} 820 + text={l`Report`} 821 + onPress={() => {}} 822 + /> 823 + )} 824 + {isOwner ? null : ( 825 + <SettingsButton 826 + color="secondary" 827 + icon={ArrowBoxLeftIcon} 828 + label={l`Leave this group chat`} 829 + text={l`Leave`} 830 + onPress={() => {}} 831 + /> 832 + )} 833 + </View> 834 + </View> 835 + <EditNamePrompt 836 + control={editNamePrompt} 837 + value={newGroupName} 838 + onChangeText={setNewGroupName} 839 + onConfirm={handleEditName} 840 + /> 841 + <InviteLinkPrompt 842 + control={inviteLinkPrompt} 843 + onConfirm={handleConfirmInviteLink} 844 + /> 845 + <LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> 846 + </> 847 + ) 848 + } 849 + 850 + function SettingsHeaderPlaceholder() { 851 + const t = useTheme() 852 + const {t: l} = useLingui() 853 + 854 + return ( 855 + <View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 856 + <View style={[a.align_center, a.justify_center]}> 857 + <AvatarBubbles profiles={[]} /> 858 + </View> 859 + <Text 860 + style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}> 861 + {l`…`} 862 + </Text> 863 + <Text 864 + style={[ 865 + a.text_sm, 866 + a.text_center, 867 + a.pt_xs, 868 + a.px_xl, 869 + t.atoms.text_contrast_high, 870 + ]}> 871 + <Trans>…</Trans> 872 + </Text> 873 + <View 874 + style={[ 875 + a.flex_row, 876 + a.align_center, 877 + a.justify_center, 878 + a.gap_2xl, 879 + a.pt_2xl, 880 + ]}> 881 + <SettingsButtonPlaceholder /> 882 + <SettingsButtonPlaceholder /> 883 + <SettingsButtonPlaceholder /> 884 + <SettingsButtonPlaceholder /> 885 + </View> 886 + </View> 887 + ) 888 + } 889 + 890 + function SettingsButton({ 891 + color = 'secondary', 892 + icon, 893 + label, 894 + text, 895 + onPress, 896 + }: { 897 + color?: ButtonColor 898 + icon: React.ComponentType<SVGIconProps> 899 + label: string 900 + text: string 901 + onPress: () => void 902 + }) { 903 + const t = useTheme() 904 + 905 + return ( 906 + <View> 907 + <Button 908 + color={color} 909 + size="large" 910 + shape="round" 911 + label={label} 912 + onPress={onPress}> 913 + <ButtonIcon icon={icon} size="md" /> 914 + </Button> 915 + <Text 916 + numberOfLines={1} 917 + style={[ 918 + a.text_2xs, 919 + a.font_medium, 920 + a.text_center, 921 + a.pt_xs, 922 + t.atoms.text, 923 + ]}> 924 + {text} 925 + </Text> 926 + </View> 927 + ) 928 + } 929 + 930 + function SettingsButtonPlaceholder() { 931 + const t = useTheme() 932 + const {t: l} = useLingui() 933 + 934 + return ( 935 + <View> 936 + <Button color="secondary" size="large" shape="round" label={l`Loading…`}> 937 + <ButtonIcon icon={EllipsisIcon} size="md" /> 938 + </Button> 939 + <Text 940 + numberOfLines={1} 941 + style={[ 942 + a.text_2xs, 943 + a.font_medium, 944 + a.text_center, 945 + a.pt_xs, 946 + t.atoms.text, 947 + ]}> 948 + {l`…`} 949 + </Text> 950 + </View> 951 + ) 952 + } 953 + 954 + function EditNamePrompt({ 955 + control, 956 + value, 957 + onChangeText, 958 + onConfirm, 959 + }: { 960 + control: Dialog.DialogOuterProps['control'] 961 + value: string 962 + onChangeText: (value: string) => void 963 + onConfirm: () => void 964 + }) { 965 + const {t: l} = useLingui() 966 + 967 + return ( 968 + <Prompt.Outer control={control}> 969 + <> 970 + <Prompt.Content> 971 + <Prompt.TitleText> 972 + <Trans>Edit group name</Trans> 973 + </Prompt.TitleText> 974 + <View style={[a.my_sm]}> 975 + <TextField.Root isInvalid={false}> 976 + <TextField.Input 977 + label={l`Edit group name`} 978 + placeholder={l`Group name`} 979 + value={value} 980 + onChangeText={onChangeText} 981 + returnKeyType="done" 982 + autoCapitalize="none" 983 + autoComplete="off" 984 + autoCorrect={false} 985 + onSubmitEditing={onConfirm} 986 + /> 987 + </TextField.Root> 988 + </View> 989 + </Prompt.Content> 990 + <Prompt.Actions> 991 + <Prompt.Action 992 + cta={l`Save`} 993 + shouldCloseOnPress={false} 994 + onPress={onConfirm} 995 + /> 996 + <Prompt.Cancel /> 997 + </Prompt.Actions> 998 + </> 999 + </Prompt.Outer> 1000 + ) 1001 + } 1002 + 1003 + function InviteLinkPrompt({ 1004 + control, 1005 + onConfirm, 1006 + }: { 1007 + control: Dialog.DialogOuterProps['control'] 1008 + onConfirm: () => void 1009 + }) { 1010 + const {t: l} = useLingui() 1011 + 1012 + return ( 1013 + <Prompt.Basic 1014 + control={control} 1015 + title={l`Invite link`} 1016 + description={l`An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone`} 1017 + confirmButtonCta={l`Get started`} 1018 + cancelButtonCta={l`Cancel`} 1019 + onConfirm={onConfirm} 1020 + /> 1021 + ) 1022 + } 1023 + 1024 + function LockChatPrompt({ 1025 + control, 1026 + onConfirm, 1027 + }: { 1028 + control: Dialog.DialogOuterProps['control'] 1029 + onConfirm: () => void 1030 + }) { 1031 + const {t: l} = useLingui() 1032 + 1033 + return ( 1034 + <Prompt.Basic 1035 + control={control} 1036 + title={l`Lock group chat?`} 1037 + description={l`Members can still read chat history but can’t send new messages.`} 1038 + confirmButtonCta={l`Lock group chat`} 1039 + cancelButtonCta={l`Cancel`} 1040 + onConfirm={onConfirm} 1041 + /> 1042 + ) 1043 + } 1044 + 1045 + function BlockMemberPrompt({ 1046 + control, 1047 + onConfirm, 1048 + }: { 1049 + control: Dialog.DialogOuterProps['control'] 1050 + onConfirm: () => void 1051 + }) { 1052 + const {t: l} = useLingui() 1053 + 1054 + return ( 1055 + <Prompt.Basic 1056 + control={control} 1057 + title={l`Block account?`} 1058 + description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 1059 + onConfirm={onConfirm} 1060 + confirmButtonCta={l`Block`} 1061 + confirmButtonColor="negative" 1062 + /> 1063 + ) 1064 + } 1065 + 1066 + function SubtleHoverWrapper({children}: React.PropsWithChildren<unknown>) { 1067 + const { 1068 + state: hover, 1069 + onIn: onHoverIn, 1070 + onOut: onHoverOut, 1071 + } = useInteractionState() 1072 + 1073 + return ( 1074 + <View 1075 + onPointerEnter={onHoverIn} 1076 + onPointerLeave={onHoverOut} 1077 + style={a.pointer}> 1078 + <SubtleHover hover={hover} /> 1079 + {children} 1080 + </View> 1081 + ) 1082 + }
+257 -112
src/screens/Messages/components/ChatListItem.tsx
··· 1 - import {memo, useCallback, useMemo, useState} from 'react' 1 + import {useCallback, useMemo, useState} from 'react' 2 2 import {type GestureResponderEvent, View} from 'react-native' 3 3 import { 4 4 AppBskyEmbedRecord, 5 + ChatBskyActorDefs, 5 6 ChatBskyConvoDefs, 6 7 moderateProfile, 8 + type ModerationDecision, 7 9 type ModerationOpts, 8 10 } from '@atproto/api' 9 - import {msg} from '@lingui/core/macro' 10 - import {useLingui} from '@lingui/react' 11 + import {useLingui} from '@lingui/react/macro' 11 12 import {useQueryClient} from '@tanstack/react-query' 12 13 13 14 import {GestureActionView} from '#/lib/custom-animations/GestureActionView' 14 15 import {useHaptics} from '#/lib/haptics' 16 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 15 17 import {decrementBadgeCount} from '#/lib/notifications/notifications' 16 18 import {sanitizeDisplayName} from '#/lib/strings/display-names' 19 + import {sanitizeHandle} from '#/lib/strings/handles' 17 20 import { 18 21 postUriToRelativePath, 19 22 toBskyAppUrl, 20 23 toShortUrl, 21 24 } from '#/lib/strings/url-helpers' 22 - import {useProfileShadow} from '#/state/cache/profile-shadow' 25 + import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' 23 26 import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 27 import { 25 28 precacheConvoQuery, 26 29 useMarkAsReadMutation, 27 30 } from '#/state/queries/messages/conversation' 28 - import {precacheProfile} from '#/state/queries/profile' 31 + import {unstableCacheProfileView} from '#/state/queries/profile' 29 32 import {useSession} from '#/state/session' 30 33 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 31 34 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 32 35 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 33 36 import * as tokens from '#/alf/tokens' 37 + import {AvatarBubbles} from '#/components/AvatarBubbles' 34 38 import {useDialogControl} from '#/components/Dialog' 35 39 import {ConvoMenu} from '#/components/dms/ConvoMenu' 36 40 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' ··· 45 49 import {Text} from '#/components/Typography' 46 50 import {useAnalytics} from '#/analytics' 47 51 import {IS_NATIVE} from '#/env' 48 - import type * as bsky from '#/types/bsky' 52 + import * as bsky from '#/types/bsky' 49 53 50 54 export const ChatListItemPortal = createPortalGroup() 51 55 52 - export let ChatListItem = ({ 56 + /** 57 + * IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT 58 + * 59 + * TAKE A SECOND PASS PLEASE -sfn 60 + */ 61 + 62 + export function ChatListItem({ 53 63 convo, 54 64 showMenu = true, 55 65 children, ··· 57 67 convo: ChatBskyConvoDefs.ConvoView 58 68 showMenu?: boolean 59 69 children?: React.ReactNode 60 - }): React.ReactNode => { 70 + }) { 61 71 const {currentAccount} = useSession() 62 72 const moderationOpts = useModerationOpts() 63 73 64 - const otherUser = convo.members.find( 65 - member => member.did !== currentAccount?.did, 66 - ) 74 + if (!moderationOpts) { 75 + return null 76 + } 77 + 78 + if ( 79 + bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 80 + convo.kind, 81 + ChatBskyConvoDefs.isGroupConvo, 82 + ) 83 + ) { 84 + const owner = convo.members.find(r => { 85 + if ( 86 + bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( 87 + r.kind, 88 + ChatBskyActorDefs.isGroupConvoMember, 89 + ) 90 + ) { 91 + return r.kind.role === 'owner' 92 + } else { 93 + throw new Error( 94 + 'Expected a GroupConvoMember, got an unknown kind of member', 95 + ) 96 + } 97 + }) 98 + if (!owner) { 99 + // TODO: Determine if this is the right thing to do here. Throwing here so that 100 + // if it turns out to be wrong it'll be very visible 101 + throw new Error('Could not find the group owner in the group members') 102 + } 67 103 68 - if (!otherUser || !moderationOpts) { 104 + return ( 105 + <GroupChatItem 106 + convo={convo} 107 + groupOwner={owner} 108 + groupInfo={convo.kind} 109 + moderationOpts={moderationOpts} 110 + showMenu={showMenu} 111 + /> 112 + ) 113 + } else if ( 114 + bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>( 115 + convo.kind, 116 + ChatBskyConvoDefs.isDirectConvo, 117 + ) 118 + ) { 119 + const otherMember = convo.members.find( 120 + member => member.did !== currentAccount?.did, 121 + ) 122 + 123 + if (!otherMember) { 124 + return null 125 + } 126 + return ( 127 + <DirectChatItem 128 + convo={convo} 129 + profile={otherMember} 130 + moderationOpts={moderationOpts} 131 + showMenu={showMenu}> 132 + {children} 133 + </DirectChatItem> 134 + ) 135 + } else { 69 136 return null 70 137 } 138 + } 139 + 140 + function DirectChatItem({ 141 + convo, 142 + profile: profileUnshadowed, 143 + moderationOpts, 144 + showMenu, 145 + children, 146 + }: { 147 + convo: ChatBskyConvoDefs.ConvoView 148 + profile: bsky.profile.AnyProfileView 149 + moderationOpts: ModerationOpts 150 + showMenu?: boolean 151 + children?: React.ReactNode 152 + }) { 153 + const {t: l} = useLingui() 154 + const profile = useProfileShadow(profileUnshadowed) 155 + 156 + const moderation = useMemo( 157 + () => moderateProfile(profile, moderationOpts), 158 + [profile, moderationOpts], 159 + ) 160 + 161 + const isDeletedAccount = profile.handle === 'missing.invalid' 162 + const displayName = isDeletedAccount 163 + ? l`Deleted Account` 164 + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) 71 165 72 166 return ( 73 - <ChatListItemReady 167 + <BaseChatItem 74 168 convo={convo} 75 - profile={otherUser} 76 - moderationOpts={moderationOpts} 77 - showMenu={showMenu}> 169 + avatar={ 170 + <PreviewableUserAvatar 171 + profile={profile} 172 + size={52} 173 + moderation={moderation.ui('avatar')} 174 + /> 175 + } 176 + primaryProfile={profile} 177 + primaryProfileModeration={moderation} 178 + title={displayName} 179 + subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)} 180 + accessibilityHint={ 181 + !isDeletedAccount 182 + ? l`Go to conversation with ${profile.handle}` 183 + : l`This conversation is with a deleted or a deactivated account. Press for options` 184 + } 185 + showMenu={showMenu} 186 + isDeletedAccount={isDeletedAccount} 187 + isBlockedAccount={moderation.blocked} 188 + showProfileBadges 189 + postAlerts={ 190 + <PostAlerts 191 + modui={moderation.ui('contentList')} 192 + size="lg" 193 + style={[a.pt_xs]} 194 + /> 195 + }> 78 196 {children} 79 - </ChatListItemReady> 197 + </BaseChatItem> 80 198 ) 81 199 } 82 200 83 - ChatListItem = memo(ChatListItem) 84 - 85 - function ChatListItemReady({ 201 + function GroupChatItem({ 86 202 convo, 87 - profile: profileUnshadowed, 203 + groupOwner: groupOwnerUnshadowed, 204 + groupInfo, 88 205 moderationOpts, 89 206 showMenu, 90 207 children, 91 208 }: { 92 209 convo: ChatBskyConvoDefs.ConvoView 93 - profile: bsky.profile.AnyProfileView 210 + groupOwner: bsky.profile.AnyProfileView 211 + groupInfo: ChatBskyConvoDefs.GroupConvo 94 212 moderationOpts: ModerationOpts 95 213 showMenu?: boolean 96 214 children?: React.ReactNode 97 215 }) { 216 + const {t: l} = useLingui() 217 + const groupOwner = useProfileShadow(groupOwnerUnshadowed) 218 + 219 + const moderation = useMemo( 220 + () => moderateProfile(groupOwner, moderationOpts), 221 + [groupOwner, moderationOpts], 222 + ) 223 + 224 + const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` 225 + 226 + return ( 227 + <BaseChatItem 228 + convo={convo} 229 + avatar={<AvatarBubbles profiles={convo.members} size="medium" />} 230 + title={chatName} 231 + accessibilityHint={l`Go to the group chat named "${chatName}"`} 232 + primaryProfile={groupOwner} 233 + primaryProfileModeration={moderation} 234 + isBlockedAccount={false} 235 + isDeletedAccount={false} 236 + showProfileBadges={false} 237 + showMenu={showMenu}> 238 + {children} 239 + </BaseChatItem> 240 + ) 241 + } 242 + 243 + function BaseChatItem({ 244 + convo, 245 + avatar, 246 + title, 247 + subtitle, 248 + accessibilityHint, 249 + isDeletedAccount, 250 + isBlockedAccount, 251 + primaryProfile, 252 + primaryProfileModeration, 253 + showMenu, 254 + showProfileBadges, 255 + postAlerts, 256 + children, 257 + }: { 258 + convo: ChatBskyConvoDefs.ConvoView 259 + avatar: React.ReactNode 260 + title: string 261 + subtitle?: string 262 + accessibilityHint: string 263 + isDeletedAccount: boolean 264 + isBlockedAccount: boolean 265 + primaryProfile: Shadow<bsky.profile.AnyProfileView> 266 + primaryProfileModeration: ModerationDecision 267 + showMenu?: boolean 268 + showProfileBadges: boolean 269 + postAlerts?: React.ReactNode 270 + children?: React.ReactNode 271 + }) { 98 272 const ax = useAnalytics() 99 273 const t = useTheme() 100 - const {_} = useLingui() 274 + const {t: l} = useLingui() 101 275 const {currentAccount} = useSession() 102 276 const menuControl = useMenuControl() 103 277 const leaveConvoControl = useDialogControl() 104 - const {gtMobile} = useBreakpoints() 105 - const profile = useProfileShadow(profileUnshadowed) 106 278 const {mutate: markAsRead} = useMarkAsReadMutation() 107 - const moderation = useMemo( 108 - () => moderateProfile(profile, moderationOpts), 109 - [profile, moderationOpts], 110 - ) 279 + const {gtMobile} = useBreakpoints() 280 + 111 281 const playHaptic = useHaptics() 112 282 const queryClient = useQueryClient() 113 283 const isUnread = convo.unreadCount > 0 114 284 115 285 const blockInfo = useMemo(() => { 116 - const modui = moderation.ui('profileView') 286 + const modui = primaryProfileModeration.ui('profileView') 117 287 const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 118 288 const listBlocks = blocks.filter(alert => alert.source.type === 'list') 119 289 const userBlock = blocks.find(alert => alert.source.type === 'user') ··· 121 291 listBlocks, 122 292 userBlock, 123 293 } 124 - }, [moderation]) 125 - 126 - const isDeletedAccount = profile.handle === 'missing.invalid' 127 - const displayName = isDeletedAccount 128 - ? _(msg`Deleted Account`) 129 - : sanitizeDisplayName( 130 - profile.displayName || profile.handle, 131 - moderation.ui('displayName'), 132 - ) 294 + }, [primaryProfileModeration]) 133 295 134 - const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount 296 + const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount 135 297 136 298 const {lastMessage, lastMessageSentAt, latestReportableMessage} = 137 299 useMemo(() => { 138 - let lastMessage = _(msg`No messages yet`) 300 + let lastMessage = l`No messages yet` 139 301 140 302 let lastMessageSentAt: string | null = null 141 303 ··· 150 312 151 313 if (convo.lastMessage.text) { 152 314 if (isFromMe) { 153 - lastMessage = _(msg`You: ${convo.lastMessage.text}`) 315 + lastMessage = l`You: ${convo.lastMessage.text}` 154 316 } else { 155 317 lastMessage = convo.lastMessage.text 156 318 } 157 319 } else if (convo.lastMessage.embed) { 158 - const defaultEmbeddedContentMessage = _( 159 - msg`(contains embedded content)`, 160 - ) 320 + const defaultEmbeddedContentMessage = l`(contains embedded content)` 161 321 162 322 if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { 163 323 const embed = convo.lastMessage.embed ··· 172 332 ? toShortUrl(href) 173 333 : defaultEmbeddedContentMessage 174 334 if (isFromMe) { 175 - lastMessage = _(msg`You: ${short}`) 335 + lastMessage = l`You: ${short}` 176 336 } else { 177 337 lastMessage = short 178 338 } 179 339 } 180 340 } else { 181 341 if (isFromMe) { 182 - lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) 342 + lastMessage = l`You: ${defaultEmbeddedContentMessage}` 183 343 } else { 184 344 lastMessage = defaultEmbeddedContentMessage 185 345 } ··· 192 352 lastMessageSentAt = convo.lastMessage.sentAt 193 353 194 354 lastMessage = isDeletedAccount 195 - ? _(msg`Conversation deleted`) 196 - : _(msg`Message deleted`) 355 + ? l`Conversation deleted` 356 + : l`Message deleted` 197 357 } 198 358 199 359 if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) { ··· 205 365 const isFromMe = 206 366 convo.lastReaction.reaction.sender.did === currentAccount?.did 207 367 const lastMessageText = convo.lastReaction.message.text 208 - const fallbackMessage = _( 209 - msg({ 210 - message: 'a message', 211 - comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, 212 - }), 213 - ) 368 + const fallbackMessage = l({ 369 + message: 'a message', 370 + comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`, 371 + }) 214 372 215 373 if (isFromMe) { 216 - lastMessage = _( 217 - msg`You reacted ${convo.lastReaction.reaction.value} to ${ 218 - lastMessageText 219 - ? `"${convo.lastReaction.message.text}"` 220 - : fallbackMessage 221 - }`, 222 - ) 374 + lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${ 375 + lastMessageText 376 + ? `"${convo.lastReaction.message.text}"` 377 + : fallbackMessage 378 + }` 223 379 } else { 224 380 const senderDid = convo.lastReaction.reaction.sender.did 225 381 const sender = convo.members.find( 226 382 member => member.did === senderDid, 227 383 ) 228 384 if (sender) { 229 - lastMessage = _( 230 - msg`${sanitizeDisplayName( 231 - sender.displayName || sender.handle, 232 - )} reacted ${convo.lastReaction.reaction.value} to ${ 233 - lastMessageText 234 - ? `"${convo.lastReaction.message.text}"` 235 - : fallbackMessage 236 - }`, 237 - ) 385 + lastMessage = l`${sanitizeDisplayName( 386 + sender.displayName || sender.handle, 387 + )} reacted ${convo.lastReaction.reaction.value} to ${ 388 + lastMessageText 389 + ? `"${convo.lastReaction.message.text}"` 390 + : fallbackMessage 391 + }` 238 392 } else { 239 - lastMessage = _( 240 - msg`Someone reacted ${convo.lastReaction.reaction.value} to ${ 241 - lastMessageText 242 - ? `"${convo.lastReaction.message.text}"` 243 - : fallbackMessage 244 - }`, 245 - ) 393 + lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${ 394 + lastMessageText 395 + ? `"${convo.lastReaction.message.text}"` 396 + : fallbackMessage 397 + }` 246 398 } 247 399 } 248 400 } ··· 254 406 latestReportableMessage, 255 407 } 256 408 }, [ 257 - _, 409 + l, 258 410 convo.lastMessage, 259 411 convo.lastReaction, 260 412 currentAccount?.did, ··· 279 431 280 432 const onPress = useCallback( 281 433 (e: GestureResponderEvent) => { 282 - precacheProfile(queryClient, profile) 434 + for (const member of convo.members) { 435 + unstableCacheProfileView(queryClient, member) 436 + } 283 437 precacheConvoQuery(queryClient, convo) 284 - decrementBadgeCount(convo.unreadCount) 438 + void decrementBadgeCount(convo.unreadCount) 285 439 if (isDeletedAccount) { 286 440 e.preventDefault() 287 441 menuControl.open() ··· 290 444 ax.metric('chat:open', {logContext: 'ChatsList'}) 291 445 } 292 446 }, 293 - [ax, isDeletedAccount, menuControl, queryClient, profile, convo], 447 + [ax, isDeletedAccount, menuControl, queryClient, convo], 294 448 ) 295 449 296 450 const onLongPress = useCallback(() => { ··· 345 499 a.absolute, 346 500 {top: tokens.space.md, left: tokens.space.lg}, 347 501 ]}> 348 - <PreviewableUserAvatar 349 - profile={profile} 350 - size={52} 351 - moderation={moderation.ui('avatar')} 352 - /> 502 + {avatar} 353 503 </View> 354 504 355 505 <Link 356 506 to={`/messages/${convo.id}`} 357 - label={displayName} 358 - accessibilityHint={ 359 - !isDeletedAccount 360 - ? _(msg`Go to conversation with ${profile.handle}`) 361 - : _( 362 - msg`This conversation is with a deleted or a deactivated account. Press for options`, 363 - ) 364 - } 507 + label={title} 508 + accessibilityHint={accessibilityHint} 365 509 accessibilityActions={ 366 510 IS_NATIVE 367 511 ? [ 368 512 { 369 513 name: 'magicTap', 370 - label: _(msg`Open conversation options`), 514 + label: l`Open conversation options`, 371 515 }, 372 516 { 373 517 name: 'longpress', 374 - label: _(msg`Open conversation options`), 518 + label: l`Open conversation options`, 375 519 }, 376 520 ] 377 521 : undefined ··· 407 551 {lineHeight: 21}, 408 552 isDimStyle && t.atoms.text_contrast_medium, 409 553 ]}> 410 - {displayName} 554 + {title} 411 555 </Text> 412 556 </View> 413 - <ProfileBadges 414 - profile={profile} 415 - size="md" 416 - style={[a.pl_xs, a.self_center]} 417 - /> 557 + 558 + {showProfileBadges && ( 559 + <ProfileBadges 560 + profile={primaryProfile} 561 + size="md" 562 + style={[a.pl_xs, a.self_center]} 563 + /> 564 + )} 565 + 418 566 {lastMessageSentAt && ( 419 567 <View style={[a.pl_xs]}> 420 568 <TimeElapsed timestamp={lastMessageSentAt}> ··· 432 580 </TimeElapsed> 433 581 </View> 434 582 )} 435 - {(convo.muted || moderation.blocked) && ( 583 + {(convo.muted || isBlockedAccount) && ( 436 584 <Text 437 585 style={[ 438 586 a.text_sm, ··· 450 598 )} 451 599 </View> 452 600 453 - {!isDeletedAccount && ( 601 + {subtitle && ( 454 602 <Text 455 603 numberOfLines={1} 456 604 style={[ ··· 458 606 t.atoms.text_contrast_medium, 459 607 a.pb_xs, 460 608 ]}> 461 - @{profile.handle} 609 + {subtitle} 462 610 </Text> 463 611 )} 464 612 ··· 474 622 {lastMessage} 475 623 </Text> 476 624 477 - <PostAlerts 478 - modui={moderation.ui('contentList')} 479 - size="lg" 480 - style={[a.pt_xs]} 481 - /> 625 + {postAlerts} 482 626 483 627 {children} 484 628 </View> ··· 509 653 {showMenu && ( 510 654 <ConvoMenu 511 655 convo={convo} 512 - profile={profile} 656 + profile={primaryProfile} 513 657 control={menuControl} 514 658 currentScreen="list" 515 659 showMarkAsRead={convo.unreadCount > 0} ··· 529 673 latestReportableMessage={latestReportableMessage} 530 674 /> 531 675 )} 676 + 532 677 <LeaveConvoPrompt 533 678 control={leaveConvoControl} 534 679 convoId={convo.id}
+10 -11
src/screens/Messages/components/MessageInput.tsx
··· 15 15 } from 'react-native-reanimated' 16 16 import {useSafeAreaInsets} from 'react-native-safe-area-context' 17 17 import {GlassContainer} from 'expo-glass-effect' 18 - import {msg} from '@lingui/core/macro' 19 - import {useLingui} from '@lingui/react' 18 + import {useLingui} from '@lingui/react/macro' 20 19 import {countGraphemes} from 'unicode-segmenter/grapheme' 21 20 22 21 import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' ··· 47 46 children, 48 47 }: { 49 48 textInputId?: string 50 - onSendMessage: (message: string) => void 49 + onSendMessage: (message: string) => Promise<void> | void 51 50 hasEmbed: boolean 52 51 setEmbed: (embedUrl: string | undefined) => void 53 52 children?: React.ReactNode 54 53 openEmojiPicker?: (pos: EmojiPickerPosition) => void 55 54 }) { 56 - const {_} = useLingui() 55 + const {t: l} = useLingui() 57 56 const t = useTheme() 58 57 const playHaptic = useHaptics() 59 58 const {getDraft, clearDraft} = useMessageDraft() ··· 82 81 return 83 82 } 84 83 if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 85 - Toast.show(_(msg`Message is too long`), { 84 + Toast.show(l`Message is too long`, { 86 85 type: 'error', 87 86 }) 88 87 return 89 88 } 90 89 clearDraft() 91 - onSendMessage(message) 90 + void onSendMessage(message) 92 91 playHaptic() 93 92 setEmbed(undefined) 94 93 setMessage('') ··· 111 110 playHaptic, 112 111 setEmbed, 113 112 inputRef, 114 - _, 113 + l, 115 114 ]) 116 115 117 116 useFocusedInputHandler( ··· 169 168 fallbackStyle={[t.atoms.bg_contrast_50]}> 170 169 <AnimatedTextInput 171 170 nativeID={textInputId} 172 - accessibilityLabel={_(msg`Message input field`)} 173 - accessibilityHint={_(msg`Type your message here`)} 174 - placeholder={_(msg`Message`)} 171 + accessibilityLabel={l`Message input field`} 172 + accessibilityHint={l`Type your message here`} 173 + placeholder={l`Message`} 175 174 placeholderTextColor={t.palette.contrast_500} 176 175 value={message} 177 176 onChange={evt => { ··· 225 224 }}> 226 225 <Pressable 227 226 accessibilityRole="button" 228 - accessibilityLabel={_(msg`Send message`)} 227 + accessibilityLabel={l`Send message`} 229 228 accessibilityHint="" 230 229 hitSlop={HITSLOP_10} 231 230 style={[
+7 -8
src/screens/Messages/components/MessageInput.web.tsx
··· 1 1 import {useCallback, useEffect, useRef, useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 3 + import {useLingui} from '@lingui/react/macro' 5 4 import {flushSync} from 'react-dom' 6 5 import TextareaAutosize from 'react-textarea-autosize' 7 6 import {countGraphemes} from 'unicode-segmenter/grapheme' ··· 40 39 openEmojiPicker?: (pos: EmojiPickerPosition) => void 41 40 }) { 42 41 const {isMobile} = useWebMediaQueries() 43 - const {_} = useLingui() 42 + const {t: l} = useLingui() 44 43 const t = useTheme() 45 44 const {getDraft, clearDraft} = useMessageDraft() 46 45 const [message, setMessage] = useState(getDraft) ··· 57 56 return 58 57 } 59 58 if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 60 - Toast.show(_(msg`Message is too long`), { 59 + Toast.show(l`Message is too long`, { 61 60 type: 'error', 62 61 }) 63 62 return ··· 66 65 onSendMessage(message) 67 66 setMessage('') 68 67 setEmbed(undefined) 69 - }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) 68 + }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) 70 69 71 70 const onKeyDown = useCallback( 72 71 (e: React.KeyboardEvent<HTMLTextAreaElement>) => { ··· 177 176 width: 30, 178 177 }, 179 178 ]} 180 - label={_(msg`Open emoji picker`)}> 179 + label={l`Open emoji picker`}> 181 180 {state => ( 182 181 <View 183 182 style={[ ··· 210 209 }, 211 210 ])} 212 211 maxRows={12} 213 - placeholder={_(msg`Write a message`)} 212 + placeholder={l`Message`} 214 213 defaultValue="" 215 214 value={message} 216 215 dirName="ltr" ··· 231 230 /> 232 231 <Pressable 233 232 accessibilityRole="button" 234 - accessibilityLabel={_(msg`Send message`)} 233 + accessibilityLabel={l`Send message`} 235 234 accessibilityHint="" 236 235 style={[ 237 236 a.rounded_full,
+22 -23
src/screens/Messages/components/MessageListError.tsx
··· 1 1 import {useMemo} from 'react' 2 2 import {View} from 'react-native' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 3 + import {useLingui} from '@lingui/react/macro' 5 4 6 5 import {type ConvoItem, ConvoItemError} from '#/state/messages/convo/types' 7 6 import {atoms as a, useTheme} from '#/alf' 8 7 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 9 - import {InlineLinkText} from '#/components/Link' 8 + import {createStaticClick, InlineLinkText} from '#/components/Link' 10 9 import {Text} from '#/components/Typography' 11 10 12 11 export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) { 13 12 const t = useTheme() 14 - const {_} = useLingui() 13 + const {t: l} = useLingui() 15 14 const {description, help, cta} = useMemo(() => { 16 15 return { 17 16 [ConvoItemError.FirehoseFailed]: { 18 - description: _(msg`This chat was disconnected`), 19 - help: _(msg`Press to attempt reconnection`), 20 - cta: _(msg`Reconnect`), 17 + description: l`This chat was disconnected`, 18 + help: l`Press to attempt reconnection`, 19 + cta: l`Reconnect`, 21 20 }, 22 21 [ConvoItemError.HistoryFailed]: { 23 - description: _(msg`Failed to load past messages`), 24 - help: _(msg`Press to retry`), 25 - cta: _(msg`Retry`), 22 + description: l`Failed to load past messages`, 23 + help: l`Press to retry`, 24 + cta: l`Retry`, 26 25 }, 27 26 }[item.code] 28 - }, [_, item.code]) 27 + }, [l, item.code]) 29 28 30 29 return ( 31 - <View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}> 30 + <View style={[a.my_md, a.w_full, a.flex_row, a.justify_center]}> 32 31 <View 33 32 style={[ 34 33 a.flex_1, ··· 41 40 <CircleInfo size="sm" fill={t.palette.negative_400} /> 42 41 43 42 <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> 44 - {description} &middot;{' '} 43 + {description} 45 44 {item.retry && ( 46 - <InlineLinkText 47 - to="#" 48 - label={help} 49 - onPress={e => { 50 - e.preventDefault() 51 - item.retry?.() 52 - return false 53 - }}> 54 - {cta} 55 - </InlineLinkText> 45 + <> 46 + &middot;{' '} 47 + <InlineLinkText 48 + label={help} 49 + {...createStaticClick(() => { 50 + item.retry?.() 51 + })}> 52 + {cta} 53 + </InlineLinkText> 54 + </> 56 55 )} 57 56 </Text> 58 57 </View>
+87 -38
src/screens/Messages/components/MessagesList.tsx
··· 5 5 type KeyboardChatScrollViewProps, 6 6 KeyboardGestureArea, 7 7 } from 'react-native-keyboard-controller' 8 - import Animated, { 8 + import { 9 9 runOnJS, 10 10 type ScrollEvent, 11 11 type SharedValue, ··· 77 77 ) 78 78 } 79 79 80 - function renderItem({item}: {item: ConvoItem}) { 81 - if (item.type === 'message' || item.type === 'pending-message') { 82 - return <MessageItem item={item} /> 83 - } else if (item.type === 'deleted-message') { 84 - return <Text>Deleted message</Text> 85 - } else if (item.type === 'error') { 86 - return <MessageListError item={item} /> 87 - } 88 - 89 - return null 90 - } 91 - 92 80 function keyExtractor(item: ConvoItem) { 93 81 return item.key 94 82 } ··· 103 91 blocked, 104 92 footer, 105 93 hasAcceptOverride, 94 + transparentHeaderHeight, 106 95 }: { 107 96 hasScrolled: boolean 108 97 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> 109 98 blocked?: boolean 110 99 footer?: React.ReactNode 111 100 hasAcceptOverride?: boolean 101 + transparentHeaderHeight?: number 112 102 }) { 113 103 const ax = useAnalytics() 114 104 const convoState = useConvoActive() ··· 155 145 const prevContentHeight = useRef(0) 156 146 const prevItemCount = useRef(0) 157 147 148 + // Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset 149 + // (which causes an early onScroll with negative offset) can't prevent the first scroll. 150 + // Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding). 151 + const hasInitiallyScrolled = useRef(false) 152 + const prevHasScrolled = useRef(hasScrolled) 153 + if (prevHasScrolled.current && !hasScrolled) { 154 + hasInitiallyScrolled.current = false 155 + } 156 + prevHasScrolled.current = hasScrolled 157 + 158 158 // -- Keep track of background state and positioning for new pill 159 159 const layoutHeight = useSharedValue(0) 160 160 const didBackground = useRef(false) ··· 187 187 }) 188 188 } 189 189 190 - // This number _must_ be the height of the MaybeLoader component 191 - if (height > 50 && isAtBottom.get()) { 190 + // Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset 191 + // can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here. 192 + if (!hasInitiallyScrolled.current && convoState.items.length > 0) { 193 + hasInitiallyScrolled.current = true 194 + flatListRef.current?.scrollToOffset({offset: height, animated: false}) 195 + // If history is already done loading, mark ready after a frame for the scroll to settle. 196 + // Otherwise, the footer sentinel's onLayout will handle it when history finishes. 197 + if (!convoState.isFetchingHistory) { 198 + requestAnimationFrame(() => { 199 + setHasScrolled(true) 200 + }) 201 + } 202 + prevContentHeight.current = height 203 + prevItemCount.current = convoState.items.length 204 + return 205 + } 206 + 207 + // Subsequent: auto-scroll only if user is at the bottom 208 + if (isAtBottom.get()) { 192 209 // If the size of the content is changing by more than the height of the screen, then we don't 193 210 // want to scroll further than the start of all the new content. Since we are storing the previous offset, 194 211 // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill ··· 212 229 offset: height, 213 230 animated: hasScrolled && height > prevContentHeight.current, 214 231 }) 215 - 216 - // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, 217 - // because otherwise there is too much of a delay between the time the content 218 - // scrolls and the time the screen appears, causing a flicker. 219 - // We cannot actually use a synchronous scroll here, because `onContentSizeChange` 220 - // is actually async itself - all the info has to come across the bridge first. 221 - if (!hasScrolled && !convoState.isFetchingHistory) { 222 - setTimeout(() => { 223 - setHasScrolled(true) 224 - }, 100) 225 - } 226 232 } 227 233 } 228 234 ··· 369 375 setEmojiPickerState({isOpen: true, pos}) 370 376 }, []) 371 377 378 + const renderItem = ({item}: {item: ConvoItem}) => { 379 + if (item.type === 'message' || item.type === 'pending-message') { 380 + return ( 381 + <MessageItem 382 + item={item} 383 + profile={convoState.convo.members.find( 384 + member => member.did === item.message.sender.did, 385 + )} 386 + isGroupChat={convoState.getGroupInfo?.() != null} 387 + /> 388 + ) 389 + } else if (item.type === 'deleted-message') { 390 + return <Text>Deleted message</Text> 391 + } else if (item.type === 'error') { 392 + return <MessageListError item={item} /> 393 + } 394 + 395 + return null 396 + } 397 + 398 + // Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time 399 + // new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled. 400 + const onFooterLayout = useCallback(() => { 401 + if ( 402 + hasInitiallyScrolled.current && 403 + !hasScrolled && 404 + !convoState.isFetchingHistory 405 + ) { 406 + requestAnimationFrame(() => { 407 + setHasScrolled(true) 408 + }) 409 + } 410 + }, [hasScrolled, setHasScrolled, convoState.isFetchingHistory]) 411 + 372 412 const renderScrollComponent = useCallback( 373 413 (props: ScrollViewProps) => ( 374 414 <ChatScrollComponent {...props} inputHeight={inputHeightUI} /> ··· 382 422 interpolator="ios" 383 423 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 384 424 offset={Math.round(inputHeightJS)} 385 - textInputNativeID={textInputId} 425 + // slightly too buggy unfortunately, enable when possible 426 + // textInputNativeID={textInputId} 386 427 style={[a.flex_1]}> 387 428 {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} 388 429 <ScrollProvider onScroll={onScroll}> ··· 411 452 } 412 453 // native only (prop is not supported on web) 413 454 renderScrollComponent={renderScrollComponent} 414 - // pushes up the content under the input on web (renderScrollComponent handles it on native) 415 - ListFooterComponent={web( 416 - <WebInputSpacer inputHeight={inputHeightJS} />, 417 - )} 455 + contentContainerStyle={{ 456 + paddingBottom: platform({ 457 + // ios is slightly larger as the input has no top padding 458 + ios: tokens.space.lg, 459 + android: tokens.space.md, 460 + web: 0, // web uses ListFooterComponent instead for scroll reasons 461 + }), 462 + }} 463 + ListFooterComponent={ 464 + <View 465 + style={web({height: tokens.space.md + inputHeightJS})} 466 + onLayout={onFooterLayout} 467 + /> 468 + } 418 469 style={web({ 419 470 scrollbarWidth: 'thin', 420 471 scrollbarColor: `${t.palette.contrast_100} transparent`, 421 472 scrollbarGutter: 'stable both-edges', 422 473 })} 474 + contentInset={{top: transparentHeaderHeight}} 475 + scrollIndicatorInsets={{top: transparentHeaderHeight}} 423 476 /> 424 477 </ScrollProvider> 425 478 <KeyboardStickyView ··· 444 497 {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( 445 498 <MessageComposer 446 499 textInputId={textInputId} 447 - onSendMessage={onSendMessage} 500 + onSendMessage={(message: string) => 501 + void onSendMessage(message) 502 + } 448 503 hasEmbed={!!embedUri} 449 504 setEmbed={setEmbed}> 450 505 <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> ··· 516 571 {...props} 517 572 /> 518 573 ) 519 - } 520 - 521 - function WebInputSpacer({inputHeight}: {inputHeight: number}) { 522 - if (!IS_WEB) return null 523 - 524 - return <Animated.View style={{height: inputHeight}} /> 525 574 } 526 575 527 576 type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
+85 -29
src/state/messages/convo/agent.ts
··· 1 1 import { 2 2 type AtpAgent, 3 - type ChatBskyActorDefs, 3 + ChatBskyActorDefs, 4 4 ChatBskyConvoDefs, 5 5 type ChatBskyConvoGetLog, 6 6 type ChatBskyConvoSendMessage, ··· 37 37 import {type MessagesEventBus} from '#/state/messages/events/agent' 38 38 import {type MessagesEventBusError} from '#/state/messages/events/types' 39 39 import {IS_NATIVE} from '#/env' 40 + import * as bsky from '#/types/bsky' 40 41 41 42 const logger = Logger.create(Logger.Context.ConversationAgent) 42 43 ··· 112 113 this.markConvoAccepted = this.markConvoAccepted.bind(this) 113 114 this.addReaction = this.addReaction.bind(this) 114 115 this.removeReaction = this.removeReaction.bind(this) 116 + this.isGroup = this.isGroup.bind(this) 117 + this.getGroupInfo = this.getGroupInfo.bind(this) 118 + this.getPrimaryMember = this.getPrimaryMember.bind(this) 115 119 } 116 120 117 121 private commit() { ··· 155 159 markConvoAccepted: undefined, 156 160 addReaction: undefined, 157 161 removeReaction: undefined, 162 + isGroup: this.isGroup, 163 + getGroupInfo: this.getGroupInfo, 164 + getPrimaryMember: this.getPrimaryMember, 158 165 } 159 166 } 160 167 case ConvoStatus.Disabled: ··· 175 182 markConvoAccepted: this.markConvoAccepted, 176 183 addReaction: this.addReaction, 177 184 removeReaction: this.removeReaction, 185 + isGroup: this.isGroup, 186 + getGroupInfo: this.getGroupInfo, 187 + getPrimaryMember: this.getPrimaryMember, 178 188 } 179 189 } 180 190 case ConvoStatus.Error: { ··· 192 202 markConvoAccepted: undefined, 193 203 addReaction: undefined, 194 204 removeReaction: undefined, 205 + isGroup: undefined, 206 + getGroupInfo: undefined, 207 + getPrimaryMember: undefined, 195 208 } 196 209 } 197 210 default: { ··· 209 222 markConvoAccepted: undefined, 210 223 addReaction: undefined, 211 224 removeReaction: undefined, 225 + isGroup: this.isGroup, 226 + getGroupInfo: this.getGroupInfo, 227 + getPrimaryMember: this.getPrimaryMember, 212 228 } 213 229 } 214 230 } ··· 222 238 switch (action.event) { 223 239 case ConvoDispatchEvent.Init: { 224 240 this.status = ConvoStatus.Initializing 225 - this.setup() 241 + void this.setup() 226 242 this.setupFirehose() 227 243 this.requestPollInterval(ACTIVE_POLL_INTERVAL) 228 244 break ··· 234 250 switch (action.event) { 235 251 case ConvoDispatchEvent.Ready: { 236 252 this.status = ConvoStatus.Ready 237 - this.fetchMessageHistory() 253 + void this.fetchMessageHistory() 238 254 break 239 255 } 240 256 case ConvoDispatchEvent.Background: { 241 257 this.status = ConvoStatus.Backgrounded 242 - this.fetchMessageHistory() 258 + void this.fetchMessageHistory() 243 259 this.requestPollInterval(BACKGROUND_POLL_INTERVAL) 244 260 break 245 261 } ··· 258 274 } 259 275 case ConvoDispatchEvent.Disable: { 260 276 this.status = ConvoStatus.Disabled 261 - this.fetchMessageHistory() // finish init 277 + void this.fetchMessageHistory() // finish init 262 278 this.cleanupFirehoseConnection?.() 263 279 this.withdrawRequestedPollInterval() 264 280 break ··· 269 285 case ConvoStatus.Ready: { 270 286 switch (action.event) { 271 287 case ConvoDispatchEvent.Resume: { 272 - this.refreshConvo() 288 + void this.refreshConvo() 273 289 this.requestPollInterval(ACTIVE_POLL_INTERVAL) 274 290 break 275 291 } ··· 308 324 } else { 309 325 if (this.convo) { 310 326 this.status = ConvoStatus.Ready 311 - this.refreshConvo() 327 + void this.refreshConvo() 312 328 this.maybeRecoverFromNetworkError() 313 329 } else { 314 330 this.status = ConvoStatus.Initializing 315 - this.setup() 331 + void this.setup() 316 332 } 317 333 this.requestPollInterval(ACTIVE_POLL_INTERVAL) 318 334 } ··· 435 451 this.firehoseError = undefined 436 452 this.commit() 437 453 } else { 438 - this.batchRetryPendingMessages() 454 + void this.batchRetryPendingMessages() 439 455 } 440 456 441 457 if (this.fetchMessageHistoryError) { ··· 487 503 } else { 488 504 this.dispatch({event: ConvoDispatchEvent.Ready}) 489 505 } 490 - } catch (e: any) { 506 + } catch (err) { 507 + const e = err as Error 491 508 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { 492 509 logger.error('setup failed', { 493 510 safeMessage: e.message, ··· 557 574 async fetchConvo() { 558 575 if (this.pendingFetchConvo) return this.pendingFetchConvo 559 576 560 - this.pendingFetchConvo = new Promise<{ 561 - convo: ChatBskyConvoDefs.ConvoView 562 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 563 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 564 - }>(async (resolve, reject) => { 577 + this.pendingFetchConvo = (async () => { 565 578 try { 566 579 const response = await networkRetry(2, () => { 567 580 return this.agent.api.chat.bsky.convo.getConvo( ··· 574 587 575 588 const convo = response.data.convo 576 589 577 - resolve({ 590 + return { 578 591 convo, 579 592 sender: convo.members.find(m => m.did === this.senderUserDid), 580 593 recipients: convo.members.filter(m => m.did !== this.senderUserDid), 581 - }) 582 - } catch (e) { 583 - reject(e) 594 + } 584 595 } finally { 585 596 this.pendingFetchConvo = undefined 586 597 } 587 - }) 598 + })() 588 599 589 600 return this.pendingFetchConvo 590 601 } ··· 596 607 this.convo = convo || this.convo 597 608 this.sender = sender || this.sender 598 609 this.recipients = recipients || this.recipients 599 - } catch (e: any) { 610 + } catch (err) { 611 + const e = err as Error 600 612 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { 601 613 logger.error(`failed to refresh convo`, { 602 614 safeMessage: e.message, ··· 664 676 this.pastMessages.set(message.id, message) 665 677 } 666 678 } 667 - } catch (e: any) { 679 + } catch (err) { 680 + const e = err as Error 668 681 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { 669 682 logger.error('failed to fetch message history', { 670 683 safeMessage: e.message, ··· 673 686 674 687 this.fetchMessageHistoryError = { 675 688 retry: () => { 676 - this.fetchMessageHistory() 689 + void this.fetchMessageHistory() 677 690 }, 678 691 } 679 692 } finally { ··· 716 729 717 730 onFirehoseConnect() { 718 731 this.firehoseError = undefined 719 - this.batchRetryPendingMessages() 732 + void this.batchRetryPendingMessages() 720 733 this.commit() 721 734 } 722 735 ··· 761 774 /** 762 775 * If this message is already in new messages, it was added by our 763 776 * sending logic, and is based on client-ordering. When we receive 764 - * the "commited" event from the log, we should replace this 765 - * reference and re-insert in order to respect the order we receied 777 + * the "committed" event from the log, we should replace this 778 + * reference and re-insert in order to respect the order we received 766 779 * from the log. 767 780 */ 768 781 if (this.newMessages.has(ev.message.id)) { ··· 836 849 this.commit() 837 850 838 851 if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { 839 - this.processPendingMessages() 852 + void this.processPendingMessages() 840 853 } 841 854 } 842 855 ··· 912 925 } 913 926 } 914 927 915 - private handleSendMessageFailure(e: any) { 928 + private handleSendMessageFailure(e: Error | XRPCError) { 916 929 if (e instanceof XRPCError) { 917 930 if (NETWORK_FAILURE_STATUSES.includes(e.status)) { 918 931 this.pendingMessageFailure = 'recoverable' ··· 1026 1039 {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, 1027 1040 ) 1028 1041 }) 1029 - } catch (e: any) { 1042 + } catch (err) { 1043 + const e = err as Error 1030 1044 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { 1031 1045 logger.error(`failed to delete message`, { 1032 1046 safeMessage: e.message, ··· 1332 1346 } catch (error) { 1333 1347 if (restore) restore() 1334 1348 throw error 1349 + } 1350 + } 1351 + 1352 + // Group utilities 1353 + 1354 + isGroup(): boolean | undefined { 1355 + if (!this.convo) return undefined 1356 + const info = this.getGroupInfo() 1357 + return !!info 1358 + } 1359 + 1360 + getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined { 1361 + if ( 1362 + this.convo && 1363 + bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 1364 + this.convo.kind, 1365 + ChatBskyConvoDefs.isGroupConvo, 1366 + ) 1367 + ) { 1368 + return this.convo.kind 1369 + } 1370 + return undefined 1371 + } 1372 + 1373 + getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined { 1374 + if (this.isGroup()) { 1375 + return this.recipients?.find(r => { 1376 + if ( 1377 + bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( 1378 + r.kind, 1379 + ChatBskyActorDefs.isGroupConvoMember, 1380 + ) 1381 + ) { 1382 + return r.kind.role === 'owner' 1383 + } else { 1384 + throw new Error( 1385 + 'Expected a GroupConvoMember, got an unknown kind of member', 1386 + ) 1387 + } 1388 + }) 1389 + } else { 1390 + return this.recipients?.find(r => r.did !== this.senderUserDid) 1335 1391 } 1336 1392 } 1337 1393 }
+24
src/state/messages/convo/types.ts
··· 144 144 type MarkConvoAccepted = () => void 145 145 type AddReaction = (messageId: string, reaction: string) => Promise<void> 146 146 type RemoveReaction = (messageId: string, reaction: string) => Promise<void> 147 + type IsGroup = () => boolean | undefined 148 + type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined 149 + type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined 147 150 148 151 export type ConvoStateUninitialized = { 149 152 status: ConvoStatus.Uninitialized ··· 159 162 markConvoAccepted: undefined 160 163 addReaction: undefined 161 164 removeReaction: undefined 165 + isGroup: IsGroup 166 + getGroupInfo: GetGroupInfo 167 + getPrimaryMember: GetPrimaryMember 162 168 } 163 169 export type ConvoStateInitializing = { 164 170 status: ConvoStatus.Initializing ··· 174 180 markConvoAccepted: undefined 175 181 addReaction: undefined 176 182 removeReaction: undefined 183 + isGroup: IsGroup 184 + getGroupInfo: GetGroupInfo 185 + getPrimaryMember: GetPrimaryMember 177 186 } 178 187 export type ConvoStateReady = { 179 188 status: ConvoStatus.Ready ··· 189 198 markConvoAccepted: MarkConvoAccepted 190 199 addReaction: AddReaction 191 200 removeReaction: RemoveReaction 201 + isGroup: IsGroup 202 + getGroupInfo: GetGroupInfo 203 + getPrimaryMember: GetPrimaryMember 192 204 } 193 205 export type ConvoStateBackgrounded = { 194 206 status: ConvoStatus.Backgrounded ··· 204 216 markConvoAccepted: MarkConvoAccepted 205 217 addReaction: AddReaction 206 218 removeReaction: RemoveReaction 219 + isGroup: IsGroup 220 + getGroupInfo: GetGroupInfo 221 + getPrimaryMember: GetPrimaryMember 207 222 } 208 223 export type ConvoStateSuspended = { 209 224 status: ConvoStatus.Suspended ··· 219 234 markConvoAccepted: MarkConvoAccepted 220 235 addReaction: AddReaction 221 236 removeReaction: RemoveReaction 237 + isGroup: IsGroup 238 + getGroupInfo: GetGroupInfo 239 + getPrimaryMember: GetPrimaryMember 222 240 } 223 241 export type ConvoStateError = { 224 242 status: ConvoStatus.Error ··· 234 252 markConvoAccepted: undefined 235 253 addReaction: undefined 236 254 removeReaction: undefined 255 + isGroup: undefined 256 + getGroupInfo: undefined 257 + getPrimaryMember: undefined 237 258 } 238 259 export type ConvoStateDisabled = { 239 260 status: ConvoStatus.Disabled ··· 249 270 markConvoAccepted: MarkConvoAccepted 250 271 addReaction: AddReaction 251 272 removeReaction: RemoveReaction 273 + isGroup: IsGroup 274 + getGroupInfo: GetGroupInfo 275 + getPrimaryMember: GetPrimaryMember 252 276 } 253 277 export type ConvoState = 254 278 | ConvoStateUninitialized
+37
src/state/queries/messages/create-group-chat.ts
··· 1 + import {type ChatBskyGroupCreateGroup} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 5 + import {logger} from '#/logger' 6 + import {useAgent} from '#/state/session' 7 + import {precacheConvoQuery} from './conversation' 8 + 9 + export function useCreateGroupChat({ 10 + onSuccess, 11 + onError, 12 + }: { 13 + onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void 14 + onError?: (error: Error) => void 15 + }) { 16 + const queryClient = useQueryClient() 17 + const agent = useAgent() 18 + 19 + return useMutation({ 20 + mutationFn: async ({name, members}: {name: string; members: string[]}) => { 21 + const {data} = await agent.chat.bsky.group.createGroup( 22 + {name, members}, 23 + {headers: DM_SERVICE_HEADERS}, 24 + ) 25 + 26 + return data 27 + }, 28 + onSuccess: data => { 29 + precacheConvoQuery(queryClient, data.convo) 30 + onSuccess?.(data) 31 + }, 32 + onError: error => { 33 + logger.error(error) 34 + onError?.(error) 35 + }, 36 + }) 37 + }
+2 -2
src/state/queries/messages/mute-conversation.ts
··· 31 31 mutationFn: async ({mute}: {mute: boolean}) => { 32 32 if (!convoId) throw new Error('No convoId provided') 33 33 if (mute) { 34 - const {data} = await agent.api.chat.bsky.convo.muteConvo( 34 + const {data} = await agent.chat.bsky.convo.muteConvo( 35 35 {convoId}, 36 36 {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 37 37 ) 38 38 return data 39 39 } else { 40 - const {data} = await agent.api.chat.bsky.convo.unmuteConvo( 40 + const {data} = await agent.chat.bsky.convo.unmuteConvo( 41 41 {convoId}, 42 42 {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 43 43 )
+20 -20
yarn.lock
··· 20 20 "@jridgewell/gen-mapping" "^0.3.0" 21 21 "@jridgewell/trace-mapping" "^0.3.9" 22 22 23 - "@atproto/api@^0.19.8": 24 - version "0.19.8" 25 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.8.tgz#ae847abece43f0108535c6305780079e8782ab29" 26 - integrity sha512-b79kuI3AzEmpLLi9afRNq6T0KFEEVL4d+vHFAtWxeDwS7lfwUOIIngMjAVvwmwC5nJRZIrK8L9d4y7LD8zdvsg== 23 + "@atproto/api@^0.19.9": 24 + version "0.19.9" 25 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.9.tgz#f09ed8412159d6878eeaf25a0a8b4445c62fa9eb" 26 + integrity sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw== 27 27 dependencies: 28 - "@atproto/common-web" "^0.4.20" 28 + "@atproto/common-web" "^0.4.21" 29 29 "@atproto/lexicon" "^0.6.2" 30 - "@atproto/syntax" "^0.5.3" 30 + "@atproto/syntax" "^0.5.4" 31 31 "@atproto/xrpc" "^0.7.7" 32 32 await-lock "^2.2.2" 33 33 multiformats "^9.9.0" ··· 44 44 "@atproto/syntax" "^0.5.1" 45 45 zod "^3.23.8" 46 46 47 - "@atproto/common-web@^0.4.20": 48 - version "0.4.20" 49 - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.20.tgz#bb455868e674d45ed1044c68ccccae3c08168d47" 50 - integrity sha512-RcsYT28yQgVi/Glb/hHPGpqpzIlKrbMLeldEd7PmmMLWDaJL2j3lb92qytvxjl1yhi2Ssq2TEuMZ2NlWaAbpow== 47 + "@atproto/common-web@^0.4.21": 48 + version "0.4.21" 49 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.21.tgz#2198583f842a000f495f1caec6f7e4eda207191b" 50 + integrity sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw== 51 51 dependencies: 52 52 "@atproto/lex-data" "^0.0.15" 53 - "@atproto/lex-json" "^0.0.15" 54 - "@atproto/syntax" "^0.5.3" 53 + "@atproto/lex-json" "^0.0.16" 54 + "@atproto/syntax" "^0.5.4" 55 55 zod "^3.23.8" 56 56 57 57 "@atproto/lex-data@^0.0.14": ··· 82 82 "@atproto/lex-data" "^0.0.14" 83 83 tslib "^2.8.1" 84 84 85 - "@atproto/lex-json@^0.0.15": 86 - version "0.0.15" 87 - resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.15.tgz#34d300e5dfd8a0ec76ca7363f264a488e17c1bd9" 88 - integrity sha512-kCLdP629H6GhgPjBTpZibUoqlpmW0hnVfZVwcD4s4Jch1KAqY/QcfL24Ih8wrW0Ok1YvtMIhjk98evdTA2OJcw== 85 + "@atproto/lex-json@^0.0.16": 86 + version "0.0.16" 87 + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.16.tgz#c99b5147560310f9f7f74405c57858a12c3e365a" 88 + integrity sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg== 89 89 dependencies: 90 90 "@atproto/lex-data" "^0.0.15" 91 91 tslib "^2.8.1" ··· 108 108 dependencies: 109 109 tslib "^2.8.1" 110 110 111 - "@atproto/syntax@^0.5.3": 112 - version "0.5.3" 113 - resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.3.tgz#4331d01f63fe56c374dcf95d4432a22b62271a17" 114 - integrity sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ== 111 + "@atproto/syntax@^0.5.4": 112 + version "0.5.4" 113 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.4.tgz#89842eb8b8ab181752b04ed840cc6b100e296b00" 114 + integrity sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw== 115 115 dependencies: 116 116 tslib "^2.8.1" 117 117