Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

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

Group Clops Feature Branch (#10360)

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

+3517 -1844
+2
app.config.js
··· 66 66 infoPlist: { 67 67 CADisableMinimumFrameDurationOnPhone: true, 68 68 UIBackgroundModes: ['remote-notification'], 69 + NSUserActivityTypes: ['INSendMessageIntent'], 69 70 NSCameraUsageDescription: 70 71 'Used for profile pictures, posts, and other kinds of content.', 71 72 NSMicrophoneUsageDescription: ··· 123 124 'com.apple.developer.kernel.increased-memory-limit': true, 124 125 'com.apple.developer.kernel.extended-virtual-addressing': true, 125 126 'com.apple.security.application-groups': 'group.app.bsky', 127 + 'com.apple.developer.usernotifications.communication': true, 126 128 // 'com.apple.developer.device-information.user-assigned-device-name': true, 127 129 }, 128 130 privacyManifests: {
+1
assets/icons/editBig_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>
+1
assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>
+1 -1
assets/icons/unlock_stroke2_corner2_rounded.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z" clip-rule="evenodd"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>
+7
modules/BlueskyNSE/Info.plist
··· 8 8 <string>com.apple.usernotifications.service</string> 9 9 <key>NSExtensionPrincipalClass</key> 10 10 <string>$(PRODUCT_MODULE_NAME).NotificationService</string> 11 + <key>NSExtensionAttributes</key> 12 + <dict> 13 + <key>IntentsSupported</key> 14 + <array> 15 + <string>INSendMessageIntent</string> 16 + </array> 17 + </dict> 11 18 </dict> 12 19 <key>MainAppScheme</key> 13 20 <string>bluesky</string>
+84 -7
modules/BlueskyNSE/NotificationService.swift
··· 1 1 import UserNotifications 2 2 import UIKit 3 + import Intents 3 4 4 5 let APP_GROUP = "group.app.bsky" 5 6 typealias ContentHandler = (UNNotificationContent) -> Void ··· 40 41 } 41 42 42 43 self.bestAttempt = bestAttempt 43 - if reason == "chat-message" { 44 + 45 + if reason == "chat-message" || reason == "chat-reaction" { 44 46 mutateWithChatMessage(bestAttempt) 47 + let finalContent = createCommunicationNotification( 48 + from: bestAttempt, 49 + userInfo: request.content.userInfo 50 + ) 51 + contentHandler(finalContent) 45 52 } else { 46 53 mutateWithBadge(bestAttempt) 54 + contentHandler(bestAttempt) 47 55 } 48 - 49 - // Any image downloading (or other network tasks) should be handled at the end 50 - // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire 51 - // gets called, we might not have all the needed mutations completed in time. 52 - 53 - contentHandler(bestAttempt) 54 56 } 55 57 56 58 override func serviceExtensionTimeWillExpire() { ··· 59 61 return 60 62 } 61 63 contentHandler(bestAttempt) 64 + } 65 + 66 + // MARK: Communication Notification 67 + 68 + func createCommunicationNotification( 69 + from content: UNMutableNotificationContent, 70 + userInfo: [AnyHashable: Any] 71 + ) -> UNNotificationContent { 72 + let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown" 73 + let convoId = userInfo["convoId"] as? String 74 + var avatarImage: INImage? = nil 75 + if let avatarUrlString = userInfo["senderAvatarUrl"] as? String { 76 + avatarImage = downloadAvatarImage(from: avatarUrlString) 77 + } 78 + 79 + let senderHandleValue = userInfo["senderHandle"] as? String 80 + let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown) 81 + let sender = INPerson( 82 + personHandle: senderHandle, 83 + nameComponents: nil, 84 + displayName: senderDisplayName, 85 + image: avatarImage, 86 + contactIdentifier: nil, 87 + customIdentifier: nil 88 + ) 89 + 90 + let intent = INSendMessageIntent( 91 + recipients: nil, 92 + outgoingMessageType: .outgoingMessageText, 93 + content: content.body, 94 + speakableGroupName: nil, 95 + conversationIdentifier: convoId, 96 + serviceName: nil, 97 + sender: sender, 98 + attachments: nil 99 + ) 100 + 101 + let interaction = INInteraction(intent: intent, response: nil) 102 + interaction.direction = .incoming 103 + interaction.donate(completion: nil) 104 + 105 + do { 106 + return try content.updating(from: intent) 107 + } catch { 108 + return content 109 + } 110 + } 111 + 112 + func downloadAvatarImage(from urlString: String) -> INImage? { 113 + let thumbnailUrlString = urlString.replacingOccurrences( 114 + of: "/img/avatar/", 115 + with: "/img/avatar_thumbnail/" 116 + ) 117 + 118 + guard let url = URL(string: thumbnailUrlString) else { return nil } 119 + 120 + var request = URLRequest(url: url) 121 + request.timeoutInterval = 5 122 + 123 + var imageData: Data? = nil 124 + let semaphore = DispatchSemaphore(value: 0) 125 + 126 + let task = URLSession.shared.dataTask(with: request) { data, response, error in 127 + if let data = data, 128 + let httpResponse = response as? HTTPURLResponse, 129 + httpResponse.statusCode == 200 { 130 + imageData = data 131 + } 132 + semaphore.signal() 133 + } 134 + task.resume() 135 + semaphore.wait() 136 + 137 + guard let data = imageData else { return nil } 138 + return INImage(imageData: data) 62 139 } 63 140 64 141 // MARK: Mutations
+1 -1
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
··· 13 13 return 14 14 } 15 15 16 - if (remoteMessage.data["reason"] == "chat-message") { 16 + if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") { 17 17 mutateWithChatMessage(remoteMessage) 18 18 } else { 19 19 mutateWithOtherReason(remoteMessage)
+6 -1
src/analytics/metrics/types.ts
··· 556 556 | 'FindContacts' 557 557 } 558 558 'chat:create': { 559 - logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' 559 + logContext: 560 + | 'ProfileHeader' 561 + | 'NewChatDialog' 562 + | 'SendViaChatDialog' 563 + | 'ConvoSettings' 560 564 } 561 565 'chat:open': { 562 566 logContext: ··· 564 568 | 'NewChatDialog' 565 569 | 'ChatsList' 566 570 | 'SendViaChatDialog' 571 + | 'ConvoSettings' 567 572 } 568 573 'groupchat:create': { 569 574 logContext: 'NewChatDialog'
+87 -177
src/components/AvatarBubbles.tsx
··· 1 - import {useCallback, useEffect} from 'react' 2 - import {type StyleProp, View, type ViewStyle} from 'react-native' 1 + import {useEffect} from 'react' 2 + import {View} from 'react-native' 3 3 import Animated, { 4 4 Easing, 5 - interpolate, 6 5 type SharedValue, 7 6 useAnimatedStyle, 8 7 useSharedValue, ··· 16 15 import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 17 16 import type * as bsky from '#/types/bsky' 18 17 18 + type Layout = { 19 + size: number 20 + x: number 21 + y: number 22 + zIndex?: number 23 + border?: boolean 24 + } 25 + 19 26 type Props = { 20 27 animate?: boolean 21 28 profiles: bsky.profile.AnyProfileView[] 22 - size?: 'small' | 'medium' | 'large' | number 29 + size?: number 23 30 } 24 31 25 32 export function AvatarBubbles({ 26 33 animate = false, 27 34 profiles: allProfiles, 28 - size = 'large', 35 + size = 120, 29 36 }: Props) { 30 37 const {currentAccount} = useSession() 31 38 const profiles = 32 39 allProfiles.length > 2 33 40 ? allProfiles.filter(p => p.did !== currentAccount?.did) 34 41 : allProfiles 35 - const containerSize = 36 - typeof size === 'number' 37 - ? size 38 - : size === 'small' 39 - ? 40 40 - : size === 'medium' 41 - ? 56 42 - : 120 43 - const scale = 44 - typeof size === 'number' 45 - ? size / 120 46 - : size === 'small' 47 - ? 40 / 120 48 - : size === 'medium' 49 - ? 56 / 120 50 - : 1 51 - const marginOffset = 52 - (typeof size === 'number' && size < 120) || 53 - size === 'small' || 54 - size === 'medium' 55 - ? -2 56 - : 0 42 + const scale = profiles.length <= 1 ? 1 : size / 120 43 + const marginOffset = size < 120 ? -2 : 0 57 44 58 45 const initialValue = animate ? 0 : 1 59 46 const p0 = useSharedValue(initialValue) ··· 61 48 const p2 = useSharedValue(initialValue) 62 49 const p3 = useSharedValue(initialValue) 63 50 64 - const animateScale = (p: Animated.SharedValue<number>, index: number) => { 65 - p.set(0) 66 - p.set(() => 67 - withDelay( 68 - 500 + index * 100, 69 - withTiming(1, { 70 - duration: 250, 71 - easing: Easing.out(Easing.back(1.75)), 72 - }), 73 - ), 74 - ) 75 - } 76 - 77 - const playScaleAnimation = useCallback(() => { 78 - animateScale(p0, 0) 79 - animateScale(p1, 1) 80 - animateScale(p2, 2) 81 - animateScale(p3, 3) 82 - }, [p0, p1, p2, p3]) 83 - 84 51 useEffect(() => { 85 52 if (!animate) return 86 - playScaleAnimation() 87 - }, [animate, playScaleAnimation]) 88 - 89 - let avatars = ( 90 - <> 91 - <AvatarBubble 92 - profile={profiles[0]} 93 - scale={p0} 94 - size={76} 95 - x={-2} 96 - y={-2} 97 - style={[a.z_20]} 98 - includeProfileBorder 99 - /> 100 - <AvatarBubble 101 - profile={profiles[1]} 102 - scale={p1} 103 - size={76} 104 - x={42} 105 - y={42} 106 - style={[a.z_10]} 107 - includeProfileBorder 108 - /> 109 - </> 110 - ) 111 - 112 - if (profiles.length === 3) { 113 - avatars = ( 114 - <> 115 - <AvatarBubble 116 - profile={profiles[0]} 117 - scale={p0} 118 - size={68} 119 - x={-2} 120 - y={-2} 121 - /> 122 - <AvatarBubble 123 - profile={profiles[1]} 124 - scale={p1} 125 - size={56} 126 - x={38} 127 - y={62} 128 - /> 129 - <AvatarBubble 130 - profile={profiles[2]} 131 - scale={p2} 132 - size={46} 133 - x={71} 134 - y={18} 135 - /> 136 - </> 137 - ) 138 - } 53 + const animateBubble = (p: SharedValue<number>, i: number) => { 54 + p.set(0) 55 + p.set(() => 56 + withDelay( 57 + 500 + i * 100, 58 + withTiming(1, { 59 + duration: 250, 60 + easing: Easing.out(Easing.back(1.75)), 61 + }), 62 + ), 63 + ) 64 + } 65 + animateBubble(p0, 0) 66 + animateBubble(p1, 1) 67 + animateBubble(p2, 2) 68 + animateBubble(p3, 3) 69 + }, [animate, p0, p1, p2, p3]) 139 70 140 - if (profiles.length >= 4) { 141 - avatars = ( 142 - <> 143 - <AvatarBubble 144 - profile={profiles[0]} 145 - scale={p0} 146 - size={68} 147 - x={-2} 148 - y={-2} 149 - /> 150 - <AvatarBubble 151 - profile={profiles[1]} 152 - scale={p1} 153 - size={56} 154 - x={60} 155 - y={49} 156 - /> 157 - <AvatarBubble 158 - profile={profiles[2]} 159 - scale={p2} 160 - size={42} 161 - x={14} 162 - y={74} 163 - /> 164 - <AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} /> 165 - </> 166 - ) 167 - } 71 + const scales = [p0, p1, p2, p3] 72 + const layouts = getLayouts(profiles.length) 168 73 169 74 return ( 170 - <Animated.View 171 - style={[ 172 - a.p_2xs, 173 - { 174 - height: containerSize, 175 - width: containerSize, 176 - }, 177 - ]}> 75 + <Animated.View style={[a.p_2xs, {height: size, width: size}]}> 178 76 <View 179 - style={[ 180 - { 181 - marginTop: marginOffset, 182 - marginLeft: marginOffset, 183 - transform: [{scale}], 184 - transformOrigin: 'top left', 185 - }, 186 - ]}> 187 - {avatars} 77 + style={{ 78 + marginTop: marginOffset, 79 + marginLeft: marginOffset, 80 + transform: [{scale}], 81 + transformOrigin: 'top left', 82 + }}> 83 + {layouts.map((layout, i) => ( 84 + <AvatarBubble 85 + key={i} 86 + profile={profiles[i]} 87 + scale={scales[i]} 88 + size={layout.size} 89 + x={layout.x} 90 + y={layout.y} 91 + zIndex={layout.zIndex} 92 + includeProfileBorder={layout.border} 93 + /> 94 + ))} 188 95 </View> 189 96 </Animated.View> 190 97 ) ··· 194 101 profile, 195 102 scale, 196 103 size, 197 - style, 198 104 x, 199 105 y, 106 + zIndex, 200 107 includeProfileBorder, 201 108 }: { 202 109 profile?: bsky.profile.AnyProfileView 203 110 scale: SharedValue<number> 204 111 size: number 205 - style?: StyleProp<ViewStyle> 206 112 x: number 207 113 y: number 114 + zIndex?: number 208 115 includeProfileBorder?: boolean 209 116 }) { 210 117 const t = useTheme() 211 118 212 119 const animatedStyle = useAnimatedStyle(() => ({ 213 - transform: [ 214 - {translateX: x}, 215 - {translateY: y}, 216 - {scale: interpolate(scale.get(), [0, 1], [0, 1])}, 217 - ], 120 + transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}], 218 121 })) 219 122 220 123 return ( ··· 227 130 borderColor: t.atoms.text_inverted.color, 228 131 borderWidth: 2, 229 132 }, 230 - style, 133 + zIndex != null && {zIndex}, 231 134 animatedStyle, 232 135 ]}> 233 136 {profile ? ( 234 - <Avatar profile={profile} size={size} /> 137 + <UserAvatar 138 + avatar={profile.avatar} 139 + size={size} 140 + type="user" 141 + hideLiveBadge 142 + noBorder 143 + /> 235 144 ) : ( 236 145 <AvatarPlaceholder size={size} /> 237 146 )} ··· 239 148 ) 240 149 } 241 150 242 - function Avatar({ 243 - profile, 244 - size = 76, 245 - }: { 246 - profile: bsky.profile.AnyProfileView 247 - size?: number 248 - }) { 249 - return ( 250 - <UserAvatar 251 - avatar={profile.avatar} 252 - size={size} 253 - type="user" 254 - hideLiveBadge 255 - noBorder 256 - /> 257 - ) 258 - } 259 - 260 - function AvatarPlaceholder({size = 76}: {size?: number}) { 151 + function AvatarPlaceholder({size}: {size: number}) { 261 152 const t = useTheme() 262 153 263 154 return ( ··· 267 158 a.justify_center, 268 159 a.rounded_full, 269 160 t.atoms.bg_contrast_200, 270 - { 271 - width: size, 272 - height: size, 273 - }, 161 + {width: size, height: size}, 274 162 ]}> 275 163 <PersonIcon 276 164 width={size * 0.5} ··· 280 168 </View> 281 169 ) 282 170 } 171 + 172 + function getLayouts(count: number): Layout[] { 173 + if (count === 3) { 174 + return [ 175 + {size: 68, x: -2, y: -2}, 176 + {size: 56, x: 38, y: 62}, 177 + {size: 46, x: 71, y: 18}, 178 + ] 179 + } 180 + if (count >= 4) { 181 + return [ 182 + {size: 68, x: -2, y: -2}, 183 + {size: 56, x: 60, y: 49}, 184 + {size: 42, x: 14, y: 74}, 185 + {size: 32, x: 72, y: 9}, 186 + ] 187 + } 188 + return [ 189 + {size: 76, x: -2, y: -2, zIndex: 20, border: true}, 190 + {size: 76, x: 42, y: 42, zIndex: 10, border: true}, 191 + ] 192 + }
+6
src/components/Button.tsx
··· 77 77 focused: boolean 78 78 pressed: boolean 79 79 disabled: boolean 80 + /** 81 + * Alias for hovered || focused || pressed 82 + */ 83 + interacting: boolean 80 84 } 81 85 82 86 export type ButtonContext = VariantProps & ButtonState ··· 120 124 focused: false, 121 125 pressed: false, 122 126 disabled: false, 127 + interacting: false, 123 128 }) 124 129 Context.displayName = 'ButtonContext' 125 130 ··· 536 541 const context = useMemo<ButtonContext>( 537 542 () => ({ 538 543 ...state, 544 + interacting: state.hovered || state.focused || state.pressed, 539 545 variant, 540 546 color, 541 547 size,
+6 -6
src/components/ProfileCard.tsx
··· 201 201 <View 202 202 style={[ 203 203 a.rounded_full, 204 - t.atoms.bg_contrast_25, 204 + t.atoms.bg_contrast_50, 205 205 { 206 206 width: size, 207 207 height: size, ··· 348 348 <View 349 349 style={[ 350 350 a.rounded_xs, 351 - t.atoms.bg_contrast_25, 351 + t.atoms.bg_contrast_50, 352 352 { 353 353 width: '60%', 354 354 height: 14, ··· 359 359 <View 360 360 style={[ 361 361 a.rounded_xs, 362 - t.atoms.bg_contrast_25, 362 + t.atoms.bg_contrast_50, 363 363 { 364 364 width: '40%', 365 365 height: 10, ··· 377 377 <View 378 378 style={[ 379 379 a.rounded_xs, 380 - t.atoms.bg_contrast_25, 380 + t.atoms.bg_contrast_50, 381 381 { 382 382 width: '60%', 383 383 height: 14, ··· 439 439 style={[ 440 440 a.rounded_xs, 441 441 a.w_full, 442 - t.atoms.bg_contrast_25, 442 + t.atoms.bg_contrast_50, 443 443 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, 444 444 ]} 445 445 /> ··· 600 600 <View 601 601 style={[ 602 602 a.rounded_sm, 603 - t.atoms.bg_contrast_25, 603 + t.atoms.bg_contrast_50, 604 604 a.w_full, 605 605 { 606 606 height: 33,
+1 -1
src/components/dialogs/SearchablePeopleList.tsx
··· 512 512 ]}> 513 513 <ProfileCard.Header> 514 514 {convo.kind === 'group' ? ( 515 - <AvatarBubbles profiles={convo.members} size="small" /> 515 + <AvatarBubbles profiles={convo.members} size={40} /> 516 516 ) : ( 517 517 <ProfileCard.Avatar 518 518 profile={convo.primaryMember}
+7 -1
src/components/dms/ActionsWrapper.tsx
··· 4 4 5 5 import {atoms as a} from '#/alf' 6 6 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 + import type * as bsky from '#/types/bsky' 7 8 8 9 export function ActionsWrapper({ 9 10 message, 10 11 isFromSelf, 12 + senderProfile, 11 13 children, 12 14 onTap, 13 15 }: { 14 16 message: ChatBskyConvoDefs.MessageView 15 17 hasReactions?: boolean 16 18 isFromSelf: boolean 19 + senderProfile?: bsky.profile.AnyProfileView 17 20 children: React.ReactNode 18 21 onTap?: () => void 19 22 }) { 20 23 const {t: l} = useLingui() 21 24 22 25 return ( 23 - <MessageContextMenu message={message} onTap={onTap}> 26 + <MessageContextMenu 27 + message={message} 28 + senderProfile={senderProfile} 29 + onTap={onTap}> 24 30 {trigger => 25 31 // will always be true, since this file is platform split 26 32 trigger.IS_NATIVE && (
+4 -1
src/components/dms/ActionsWrapper.web.tsx
··· 10 10 import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 11 11 import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 12 12 import * as Toast from '#/components/Toast' 13 + import type * as bsky from '#/types/bsky' 13 14 import {EmojiReactionPicker} from './EmojiReactionPicker' 14 15 import {hasReachedReactionLimit} from './util' 15 16 ··· 17 18 message, 18 19 hasReactions, 19 20 isFromSelf, 21 + senderProfile, 20 22 children, 21 23 onTap, 22 24 }: { 23 25 message: ChatBskyConvoDefs.MessageView 24 26 hasReactions?: boolean 25 27 isFromSelf: boolean 28 + senderProfile?: bsky.profile.AnyProfileView 26 29 children: React.ReactNode 27 30 onTap?: () => void 28 31 }) { ··· 114 117 ) 115 118 }} 116 119 </EmojiReactionPicker> 117 - <MessageContextMenu message={message}> 120 + <MessageContextMenu message={message} senderProfile={senderProfile}> 118 121 {({props, state, IS_NATIVE, control}) => { 119 122 // always false, file is platform split 120 123 if (IS_NATIVE) return null
+14 -5
src/components/dms/AddMembersFlow.tsx
··· 98 98 } 99 99 100 100 export function AddMembersFlow({ 101 + members, 101 102 title, 102 103 onAddMembers, 103 104 }: { 105 + members: string[] 104 106 title: string 105 - onAddMembers: (dids: string[]) => void 107 + onAddMembers: ( 108 + dids: string[], 109 + profiles: bsky.profile.AnyProfileView[], 110 + ) => void 106 111 }) { 107 112 const t = useTheme() 108 113 const {t: l} = useLingui() ··· 154 159 } else if (searchText.length) { 155 160 if (results?.length) { 156 161 for (const profile of results) { 157 - if (profile.did === currentAccount?.did) continue 162 + if ( 163 + profile.did === currentAccount?.did || 164 + members.includes(profile.did) 165 + ) 166 + continue 158 167 _items.push({ 159 168 type: 'profile', 160 169 key: profile.did, ··· 202 211 } 203 212 204 213 return _items 205 - }, [isError, searchText, l, results, currentAccount?.did, follows]) 214 + }, [isError, searchText, l, results, currentAccount?.did, members, follows]) 206 215 207 216 if (searchText && !isFetching && !items.length && !isError) { 208 217 items.push({type: 'empty', key: 'empty', message: l`No results`}) ··· 213 222 }, [control]) 214 223 215 224 const handlePressAdd = useCallback(() => { 216 - onAddMembers(groupChatDids) 217 - }, [groupChatDids, onAddMembers]) 225 + onAddMembers(groupChatDids, groupChatProfiles) 226 + }, [groupChatDids, groupChatProfiles, onAddMembers]) 218 227 219 228 const renderItems = useCallback( 220 229 ({item}: {item: Item}) => {
+1 -1
src/components/dms/DateDivider.tsx
··· 4 4 import {subDays} from 'date-fns' 5 5 6 6 import {atoms as a, useTheme} from '#/alf' 7 - import {Text} from '../Typography' 7 + import {Text} from '#/components/Typography' 8 8 import {localDateString} from './util' 9 9 10 10 const timeFormatter = new Intl.DateTimeFormat(undefined, {
+6 -5
src/components/dms/MessageContextMenu.tsx
··· 25 25 import * as Toast from '#/components/Toast' 26 26 import {useAnalytics} from '#/analytics' 27 27 import {IS_NATIVE} from '#/env' 28 + import type * as bsky from '#/types/bsky' 28 29 import {EmojiReactionPicker} from './EmojiReactionPicker' 29 30 import {hasReachedReactionLimit} from './util' 30 31 31 32 export let MessageContextMenu = ({ 32 33 message, 34 + senderProfile, 33 35 children, 34 36 onTap, 35 37 }: { 36 38 message: ChatBskyConvoDefs.MessageView 39 + senderProfile?: bsky.profile.AnyProfileView 37 40 children: TriggerProps['children'] 38 41 onTap?: () => void 39 42 }): React.ReactNode => { ··· 110 113 [l, convo, message, currentAccount?.did], 111 114 ) 112 115 113 - const sender = convo.convo.members.find( 114 - member => member.did === message.sender.did, 115 - ) 116 + const sender = senderProfile 116 117 117 118 return ( 118 119 <> ··· 183 184 control={reportControl} 184 185 subject={{ 185 186 view: 'message', 186 - convoId: convo.convo.id, 187 + convoId: convo.convo.view.id, 187 188 message, 188 189 }} 189 190 onAfterSubmit={() => { ··· 197 198 control={blockOrDeleteControl} 198 199 currentScreen="conversation" 199 200 params={{ 200 - convoId: convo.convo.id, 201 + convoId: convo.convo.view.id, 201 202 message, 202 203 }} 203 204 />
+19 -15
src/components/dms/MessageItem.tsx
··· 30 30 31 31 import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 32 32 import {makeProfileLink} from '#/lib/routes/links' 33 - import {useConvoActive} from '#/state/messages/convo' 34 33 import {type ConvoItem} from '#/state/messages/convo/types' 35 34 import {useModerationOpts} from '#/state/preferences/moderation-opts' 36 35 import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' ··· 43 42 import * as ProfileCard from '#/components/ProfileCard' 44 43 import {RichText} from '#/components/RichText' 45 44 import {Text} from '#/components/Typography' 46 - import type * as bsky from '#/types/bsky' 47 45 import {DateDivider} from './DateDivider' 48 46 import {useDateDividerToggle} from './DateDividerToggle' 49 47 import {MessageItemEmbed} from './MessageItemEmbed' ··· 93 91 let MessageItem = ({ 94 92 item, 95 93 isGroupChat = false, 96 - profile, 97 94 }: { 98 95 item: ConvoItem & {type: 'message' | 'pending-message'} 99 96 isGroupChat?: boolean 100 - profile?: bsky.profile.AnyProfileView 101 97 }): React.ReactNode => { 102 98 const t = useTheme() 103 99 const {currentAccount} = useSession() 104 100 const {t: l} = useLingui() 105 - const {convo} = useConvoActive() 106 101 const moderationOpts = useModerationOpts() 107 102 const queryClient = useQueryClient() 103 + 104 + const profile = item.relatedProfiles.get(item.message.sender.did) 108 105 109 106 const reactionsControl = useDialogControl() 110 107 const reactionTapRef = useRef(false) ··· 277 274 return l`You reacted ${reaction.value}` 278 275 } else { 279 276 const senderDid = reaction.sender.did 280 - const memberSender = convo.members.find( 281 - member => member.did === senderDid, 282 - ) 277 + const memberSender = item.relatedProfiles.get(senderDid) 283 278 if (memberSender) { 284 279 return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` 285 280 } ··· 290 285 one: '# person', 291 286 other: '# people', 292 287 })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` 293 - }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) 288 + }, [ 289 + reactions, 290 + groupedReactions, 291 + currentAccount?.did, 292 + item.relatedProfiles, 293 + l, 294 + ]) 294 295 295 296 const appliedReactions = ( 296 297 <LayoutAnimationConfig skipEntering skipExiting> ··· 375 376 ) : null} 376 377 <ReactionsDialog 377 378 control={reactionsControl} 378 - members={convo.members} 379 + relatedProfiles={item.relatedProfiles} 379 380 message={message} 380 381 reactions={message.reactions} 381 382 groupedReactions={groupedReactions} ··· 391 392 392 393 return ( 393 394 <> 394 - {(hasLargeGapFromPrev || isDateDividerToggled) && ( 395 - <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> 396 - <DateDivider date={message.sentAt} /> 397 - </Animated.View> 398 - )} 395 + <LayoutAnimationConfig skipExiting skipEntering> 396 + {(hasLargeGapFromPrev || isDateDividerToggled) && ( 397 + <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> 398 + <DateDivider date={message.sentAt} /> 399 + </Animated.View> 400 + )} 401 + </LayoutAnimationConfig> 399 402 <View style={[messageInset, effectiveFirstInCluster && a.mt_md]}> 400 403 <View style={[a.relative]}> 401 404 {showAvatar ? ( ··· 434 437 hasReactions={hasReactions} 435 438 isFromSelf={isFromSelf} 436 439 message={message} 440 + senderProfile={profile} 437 441 onTap={() => { 438 442 if (reactionTapRef.current) return 439 443 if (!hasLargeGapFromPrev) {
+4 -1
src/components/dms/MessagesListHeader.tsx
··· 161 161 }) 162 162 } 163 163 164 + const lockStatus = convo.details.lockStatus 165 + 164 166 return ( 165 167 <Wrapper 166 168 heading={ 167 169 <> 168 - <AvatarBubbles size="small" profiles={convo.members} /> 170 + <AvatarBubbles size={40} profiles={convo.members} /> 169 171 <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> 170 172 {convo.details.name} 171 173 </Text> ··· 175 177 settings={ 176 178 <Button 177 179 label={l`Open group chat settings`} 180 + disabled={lockStatus === 'locked-permanently'} 178 181 size="small" 179 182 color="secondary" 180 183 shape="round"
+4 -4
src/components/dms/ReactionsDialog.tsx
··· 7 7 View, 8 8 } from 'react-native' 9 9 import Animated from 'react-native-reanimated' 10 - import {type ChatBskyConvoDefs} from '@atproto/api' 10 + import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' 11 11 import {Trans, useLingui} from '@lingui/react/macro' 12 12 13 13 import {HITSLOP_10} from '#/lib/constants' ··· 33 33 34 34 export function ReactionsDialog({ 35 35 control, 36 - members, 36 + relatedProfiles, 37 37 message, 38 38 reactions, 39 39 groupedReactions, 40 40 }: { 41 41 control: Dialog.DialogControlProps 42 - members: bsky.profile.AnyProfileView[] 42 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 43 43 message: ChatBskyConvoDefs.MessageView 44 44 reactions?: ChatBskyConvoDefs.ReactionView[] 45 45 groupedReactions?: Reaction[] ··· 100 100 return 0 101 101 }) 102 102 .map(reaction => { 103 - const sender = members.find(m => m.did === reaction.sender.did) 103 + const sender = relatedProfiles.get(reaction.sender.did) 104 104 if (!sender) return null 105 105 return ( 106 106 <ReactionRow
+3 -3
src/components/dms/getSystemMessageInfo.ts
··· 23 23 24 24 function getReferredDisplayName( 25 25 user: ChatBskyConvoDefs.SystemMessageReferredUser, 26 - relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], 26 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>, 27 27 ): string | null { 28 - const profile = relatedProfiles.find(p => p.did === user.did) 28 + const profile = relatedProfiles.get(user.did) 29 29 return profile ? createSanitizedDisplayName(profile) : null 30 30 } 31 31 32 32 export function getSystemMessageInfo( 33 33 data: ChatBskyConvoDefs.SystemMessageView['data'], 34 - relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], 34 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>, 35 35 ): SystemMessageInfo | null { 36 36 if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) { 37 37 const name = getReferredDisplayName(data.member, relatedProfiles)
+2 -2
src/components/dms/util.ts
··· 68 68 export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( 69 69 | { 70 70 kind: 'group' 71 - details: ChatBskyConvoDefs.GroupConvo 71 + details: $Typed<ChatBskyConvoDefs.GroupConvo> 72 72 primaryMember: GroupConvoMember // the owner 73 73 members: Array<GroupConvoMember> 74 74 } 75 75 | { 76 76 kind: 'direct' 77 - details: ChatBskyConvoDefs.DirectConvo 77 + details: $Typed<ChatBskyConvoDefs.DirectConvo> 78 78 primaryMember: DirectConvoMember // the other user 79 79 members: Array<DirectConvoMember> 80 80 }
+11 -1
src/components/forms/Toggle/index.tsx
··· 81 81 isInvalid?: boolean 82 82 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 83 83 hitSlop?: PressableProps['hitSlop'] 84 + highlightRow?: boolean 84 85 } 85 86 86 87 export function useItemContext() { ··· 160 161 style, 161 162 type = 'checkbox', 162 163 label, 164 + highlightRow, 163 165 ...rest 164 166 }: ItemProps) { 167 + const t = useTheme() 168 + 165 169 const { 166 170 values: selectedValues, 167 171 type: groupType, ··· 207 211 [name, selected, disabled, hovered, pressed, focused, isInvalid], 208 212 ) 209 213 214 + const highlightStyle = highlightRow 215 + ? selected 216 + ? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}] 217 + : [a.rounded_full, a.p_md] 218 + : null 219 + 210 220 return ( 211 221 <ItemContext.Provider value={state}> 212 222 <Pressable ··· 232 242 onPressOut={onPressOut} 233 243 onFocus={onFocus} 234 244 onBlur={onBlur} 235 - style={[a.flex_row, a.align_center, a.gap_sm, style]}> 245 + style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}> 236 246 {typeof children === 'function' ? children(state) : children} 237 247 </Pressable> 238 248 </ItemContext.Provider>
+7
src/components/icons/EditBig.tsx
··· 5 5 path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z', 6 6 }) 7 7 8 + /** 9 + * @deprecated Use EditBig_Stroke2_Corner2_Rounded 10 + */ 8 11 export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({ 9 12 path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z', 10 13 }) 14 + 15 + export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({ 16 + path: 'M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z', 17 + })
+1 -1
src/components/icons/Lock.tsx
··· 9 9 }) 10 10 11 11 export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({ 12 - path: 'M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z', 12 + path: 'M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1', 13 13 })
+7
src/components/icons/SquareBehindSquare4.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 + /** 4 + * @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded 5 + */ 3 6 export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 7 path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', 5 8 }) 9 + 10 + export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({ 11 + path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z', 12 + })
+18 -5
src/lib/hooks/useNotificationHandler.ts
··· 29 29 | 'reply' 30 30 | 'quote' 31 31 | 'chat-message' 32 + | 'chat-reaction' 32 33 | 'starterpack-joined' 33 34 | 'like-via-repost' 34 35 | 'repost-via-repost' ··· 44 45 export type NotificationPayload = 45 46 | undefined 46 47 | { 47 - reason: Exclude<NotificationReason, 'chat-message'> 48 + reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'> 48 49 uri: string 49 50 subject: string 50 51 recipientDid: string 51 52 } 52 53 | { 53 54 reason: 'chat-message' 55 + convoId: string 56 + messageId: string 57 + recipientDid: string 58 + } 59 + | { 60 + reason: 'chat-reaction' 54 61 convoId: string 55 62 messageId: string 56 63 recipientDid: string ··· 192 199 const handleNotification = (payload?: NotificationPayload) => { 193 200 if (!payload) return 194 201 195 - if (payload.reason === 'chat-message') { 196 - logger.debug(`useNotificationsHandler: handling chat message`, { 202 + if ( 203 + payload.reason === 'chat-message' || 204 + payload.reason === 'chat-reaction' 205 + ) { 206 + logger.debug(`useNotificationsHandler: handling chat notification`, { 197 207 payload, 198 208 }) 199 209 ··· 270 280 logger.debug('useNotificationsHandler: incoming', {e, payload}) 271 281 272 282 if ( 273 - payload.reason === 'chat-message' && 283 + (payload.reason === 'chat-message' || 284 + payload.reason === 'chat-reaction') && 274 285 payload.recipientDid === currentAccount?.did 275 286 ) { 276 287 const shouldAlert = payload.convoId !== currentConvoId ··· 341 352 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. 342 353 // Whenever currentAccount changes, we should try to handle it again. 343 354 if ( 344 - storedAccountSwitchPayload?.reason === 'chat-message' && 355 + (storedAccountSwitchPayload?.reason === 'chat-message' || 356 + storedAccountSwitchPayload?.reason === 'chat-reaction') && 345 357 currentAccount?.did === storedAccountSwitchPayload.recipientDid 346 358 ) { 347 359 handleNotification(storedAccountSwitchPayload) ··· 429 441 return `/profile/${urip.host}` 430 442 } 431 443 case 'chat-message': 444 + case 'chat-reaction': 432 445 // should be handled separately 433 446 return null 434 447 case 'verified':
-1202
src/screens/Messages/ConversationSettings.tsx
··· 1 - import {useMemo, useState} from 'react' 2 - import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 3 - import {moderateProfile} from '@atproto/api' 4 - import {plural} from '@lingui/core/macro' 5 - import {Trans, useLingui} from '@lingui/react/macro' 6 - import {StackActions, 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 {useEditGroupName} from '#/state/queries/messages/edit-group-name' 24 - import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 25 - import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 26 - import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 27 - import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' 28 - import {useMuteConvo} from '#/state/queries/messages/mute-conversation' 29 - import {useProfileBlockMutationQueue} from '#/state/queries/profile' 30 - import {useSession} from '#/state/session' 31 - import {List} from '#/view/com/util/List' 32 - import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 33 - import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 34 - import {AvatarBubbles} from '#/components/AvatarBubbles' 35 - import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' 36 - import * as Dialog from '#/components/Dialog' 37 - import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 38 - import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' 39 - import {Error} from '#/components/Error' 40 - import * as TextField from '#/components/forms/TextField' 41 - import {useInteractionState} from '#/components/hooks/useInteractionState' 42 - import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 43 - import { 44 - Bell2_Stroke2_Corner0_Rounded as BellIcon, 45 - Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, 46 - } from '#/components/icons/Bell2' 47 - import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 48 - import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' 49 - import {type Props as SVGIconProps} from '#/components/icons/common' 50 - import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 51 - import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' 52 - import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 53 - import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 54 - import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 55 - import { 56 - Person_Stroke2_Corner2_Rounded as PersonIcon, 57 - PersonX_Stroke2_Corner0_Rounded as PersonXIcon, 58 - } from '#/components/icons/Person' 59 - import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 60 - import * as Layout from '#/components/Layout' 61 - import {InlineLinkText} from '#/components/Link' 62 - import * as Menu from '#/components/Menu' 63 - import {type TriggerChildProps} from '#/components/Menu/types' 64 - import * as Prompt from '#/components/Prompt' 65 - import {SubtleHover} from '#/components/SubtleHover' 66 - import * as Toast from '#/components/Toast' 67 - import {Text} from '#/components/Typography' 68 - import {useAnalytics} from '#/analytics' 69 - import {IS_NATIVE} from '#/env' 70 - import type * as bsky from '#/types/bsky' 71 - 72 - const MEMBER_LIMIT = 50 73 - const ROW_SPACING = 10 74 - 75 - type Item = 76 - | { 77 - type: 'MEMBERS_AND_REQUESTS' 78 - } 79 - | { 80 - type: 'ADD_MEMBERS_LINK' 81 - } 82 - | { 83 - type: 'CHAT_MEMBER' 84 - profile: Shadow<bsky.profile.AnyProfileView> 85 - status: 'owner' | 'member' | 'invited' 86 - } 87 - 88 - type Props = NativeStackScreenProps< 89 - CommonNavigatorParams, 90 - 'MessagesConversationSettings' 91 - > 92 - 93 - /** 94 - * TODO This is just layout for now. 95 - */ 96 - export function MessagesConversationSettingsScreen({route}: Props) { 97 - const {gtTablet} = useBreakpoints() 98 - 99 - const convoId = route.params.conversation 100 - 101 - return ( 102 - <Layout.Screen> 103 - <Layout.Header.Outer> 104 - <Layout.Header.BackButton /> 105 - <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 106 - <Layout.Header.TitleText> 107 - <Trans>Group chat settings</Trans> 108 - </Layout.Header.TitleText> 109 - </Layout.Header.Content> 110 - <Layout.Header.Slot /> 111 - </Layout.Header.Outer> 112 - <ConvoProvider key={convoId} convoId={convoId}> 113 - <SettingsInner convoId={convoId} /> 114 - </ConvoProvider> 115 - </Layout.Screen> 116 - ) 117 - } 118 - 119 - function keyExtractor(item: Item) { 120 - return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type 121 - } 122 - 123 - function SettingsInner({convoId}: {convoId: string}) { 124 - const {t: l} = useLingui() 125 - 126 - const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) 127 - const bottomBarOffset = useBottomBarOffset() 128 - 129 - const convoState = useConvo() 130 - const {currentAccount} = useSession() 131 - 132 - const convo = convoState.convo 133 - ? parseConvoView(convoState.convo, currentAccount?.did) 134 - : null 135 - const primaryMember = convo?.primaryMember 136 - const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did 137 - 138 - const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] 139 - const invites: string[] = [] 140 - 141 - const {data: joinRequestsData, hasNextPage: hasMoreRequests} = 142 - useListJoinRequestsQuery({ 143 - convoId, 144 - enabled: isOwner, 145 - }) 146 - const requestCount = 147 - joinRequestsData?.pages.reduce( 148 - (sum, page) => sum + page.requests.length, 149 - 0, 150 - ) ?? 0 151 - 152 - const items = [ 153 - { 154 - type: 'MEMBERS_AND_REQUESTS', 155 - }, 156 - { 157 - type: 'ADD_MEMBERS_LINK', 158 - }, 159 - ...[...data] 160 - .sort((a, b) => { 161 - const aIsOwner = a.did === primaryMember?.did 162 - const bIsOwner = b.did === primaryMember?.did 163 - const aIsSelf = a.did === currentAccount?.did 164 - const bIsSelf = b.did === currentAccount?.did 165 - if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 166 - if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 167 - return 0 168 - }) 169 - .map(profile => ({ 170 - type: 'CHAT_MEMBER', 171 - profile, 172 - status: 173 - primaryMember?.did === profile.did 174 - ? 'owner' 175 - : invites.includes(profile.did) 176 - ? 'invited' 177 - : 'member', 178 - })), 179 - ] 180 - 181 - function renderItem({item}: {item: Item}) { 182 - switch (item.type) { 183 - case 'MEMBERS_AND_REQUESTS': 184 - return ( 185 - <MembersAndRequests 186 - memberCount={data.length} 187 - requestCount={requestCount} 188 - hasMoreRequests={!!hasMoreRequests} 189 - isOwner={isOwner} 190 - /> 191 - ) 192 - case 'ADD_MEMBERS_LINK': 193 - return <AddMembersLink isOwner={isOwner} /> 194 - case 'CHAT_MEMBER': 195 - return ( 196 - <Member 197 - profile={item.profile} 198 - status={item.status} 199 - isOwner={isOwner} 200 - /> 201 - ) 202 - default: 203 - return null 204 - } 205 - } 206 - 207 - if (convoState.status === ConvoStatus.Error) { 208 - return ( 209 - <> 210 - <Error 211 - title={l`Something went wrong`} 212 - message={l`We couldn’t load this conversation’s settings`} 213 - onRetry={() => convoState.error.retry()} 214 - sideBorders={false} 215 - /> 216 - </> 217 - ) 218 - } 219 - 220 - return ( 221 - <List 222 - data={items} 223 - contentContainerStyle={{paddingBottom: bottomBarOffset + ROW_SPACING}} 224 - desktopFixedHeight 225 - initialNumToRender={initialNumToRender} 226 - keyExtractor={keyExtractor} 227 - ListHeaderComponent={ 228 - convo ? ( 229 - <SettingsHeader convo={convo} isOwner={isOwner} /> 230 - ) : ( 231 - <SettingsHeaderPlaceholder /> 232 - ) 233 - } 234 - renderItem={renderItem} 235 - sideBorders={false} 236 - windowSize={11} 237 - onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} 238 - /> 239 - ) 240 - } 241 - 242 - function MembersAndRequests({ 243 - memberCount, 244 - requestCount, 245 - hasMoreRequests, 246 - isOwner, 247 - }: { 248 - memberCount: number 249 - requestCount: number 250 - hasMoreRequests: boolean 251 - isOwner: boolean 252 - }) { 253 - const t = useTheme() 254 - const {t: l} = useLingui() 255 - 256 - return ( 257 - <View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}> 258 - <View style={[a.flex_row, a.align_center]}> 259 - <Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}> 260 - <Trans>Members</Trans>{' '} 261 - </Text> 262 - <Text 263 - style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}> 264 - {l({ 265 - message: `${memberCount}/${MEMBER_LIMIT}`, 266 - comment: 267 - 'The number of group chat members out of the total number of permitted users.', 268 - })} 269 - </Text> 270 - </View> 271 - {isOwner && requestCount > 0 ? ( 272 - <InlineLinkText 273 - label={l`View incoming group chat requests`} 274 - style={[a.text_sm, a.text_right, a.font_semi_bold]} 275 - to="#"> 276 - {hasMoreRequests 277 - ? l({ 278 - message: `${requestCount}+ requests`, 279 - comment: 280 - 'Displayed when there are more than 50 requests to join a group chat', 281 - }) 282 - : l({ 283 - message: `${plural(requestCount, { 284 - one: '# request', 285 - other: '# requests', 286 - })}`, 287 - comment: 'The number of requests to join a group chat.', 288 - })} 289 - </InlineLinkText> 290 - ) : null} 291 - </View> 292 - ) 293 - } 294 - 295 - function AddMembersLink({isOwner}: {isOwner: boolean}) { 296 - const t = useTheme() 297 - const {t: l} = useLingui() 298 - 299 - const addMembersControl = Dialog.useDialogControl() 300 - 301 - if (!isOwner) { 302 - return null 303 - } 304 - 305 - return ( 306 - <> 307 - <SubtleHoverWrapper> 308 - <View 309 - style={[ 310 - a.mx_xl, 311 - { 312 - marginTop: ROW_SPACING, 313 - marginBottom: ROW_SPACING, 314 - }, 315 - ]}> 316 - <Pressable 317 - accessibilityRole="button" 318 - style={({pressed}) => [ 319 - a.flex_row, 320 - a.align_center, 321 - a.justify_between, 322 - pressed && web({outline: 'none'}), 323 - ]} 324 - onPress={() => addMembersControl.open()}> 325 - {({pressed}) => ( 326 - <> 327 - <View> 328 - <View style={[a.flex_row, a.align_center]}> 329 - <View 330 - style={[ 331 - a.flex_row, 332 - a.align_center, 333 - a.justify_center, 334 - a.p_lg, 335 - a.rounded_full, 336 - pressed 337 - ? t.atoms.bg_contrast_100 338 - : t.atoms.bg_contrast_50, 339 - { 340 - height: 48, 341 - width: 48, 342 - }, 343 - ]}> 344 - <PlusIcon 345 - style={[t.atoms.text_contrast_high]} 346 - size="sm" 347 - /> 348 - </View> 349 - <Text 350 - style={[ 351 - a.text_md, 352 - a.font_semi_bold, 353 - a.pl_sm, 354 - t.atoms.text, 355 - ]}> 356 - <Trans>Add members</Trans> 357 - </Text> 358 - </View> 359 - </View> 360 - <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 361 - </> 362 - )} 363 - </Pressable> 364 - </View> 365 - </SubtleHoverWrapper> 366 - 367 - <Dialog.Outer 368 - control={addMembersControl} 369 - testID="addChatMembersDialog" 370 - nativeOptions={{fullHeight: true}}> 371 - <Dialog.Handle /> 372 - <AddMembersFlow 373 - title={l`Add members`} 374 - onAddMembers={(_dids: string[]) => { 375 - // TODO Add members here 376 - addMembersControl.close() 377 - }} 378 - /> 379 - </Dialog.Outer> 380 - </> 381 - ) 382 - } 383 - 384 - function Member({ 385 - profile, 386 - status, 387 - isOwner, 388 - }: { 389 - profile: Shadow<bsky.profile.AnyProfileView> 390 - status: 'owner' | 'member' | 'invited' 391 - isOwner: boolean 392 - }) { 393 - const navigation = useNavigation<NavigationProp>() 394 - const t = useTheme() 395 - const {t: l} = useLingui() 396 - 397 - const {currentAccount} = useSession() 398 - const moderationOpts = useModerationOpts() 399 - const moderation = useMemo( 400 - () => 401 - moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, 402 - [profile, moderationOpts], 403 - ) 404 - 405 - if (!moderation) return null 406 - 407 - const isDeletedAccount = profile.handle === 'missing.invalid' 408 - const displayName = isDeletedAccount 409 - ? l`Deleted Account` 410 - : sanitizeDisplayName( 411 - profile.displayName || profile.handle, 412 - moderation.ui('displayName'), 413 - ) 414 - 415 - let statusBadge: React.ReactNode | null = null 416 - if (currentAccount?.did === profile.did) { 417 - switch (status) { 418 - case 'owner': 419 - statusBadge = <StatusBadge label={l`Admin`} /> 420 - break 421 - } 422 - } else { 423 - statusBadge = ( 424 - <MemberMenu profile={profile} type={status} isOwner={isOwner} /> 425 - ) 426 - } 427 - 428 - return ( 429 - <SubtleHoverWrapper> 430 - <Pressable 431 - accessibilityRole="button" 432 - style={[ 433 - a.mx_xl, 434 - { 435 - marginTop: ROW_SPACING, 436 - marginBottom: ROW_SPACING, 437 - }, 438 - ]} 439 - onPress={() => { 440 - navigation.navigate('Profile', {name: profile.did}) 441 - }}> 442 - <View style={[a.flex_row, a.align_center, a.justify_between]}> 443 - <View style={[a.flex_row, a.align_center]}> 444 - <PreviewableUserAvatar 445 - profile={profile} 446 - size={48} 447 - moderation={moderation.ui('avatar')} 448 - /> 449 - <View style={[a.mx_sm]}> 450 - <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 451 - {displayName} 452 - </Text> 453 - <Text 454 - style={[ 455 - a.text_xs, 456 - {color: t.palette.contrast_500}, 457 - web(a.pt_2xs), 458 - ]}> 459 - {sanitizeHandle(profile.handle, '@')} 460 - </Text> 461 - </View> 462 - </View> 463 - <View>{statusBadge}</View> 464 - </View> 465 - </Pressable> 466 - </SubtleHoverWrapper> 467 - ) 468 - } 469 - 470 - function StatusBadge({ 471 - label, 472 - style, 473 - }: { 474 - label: string 475 - style?: StyleProp<ViewStyle> 476 - }) { 477 - const t = useTheme() 478 - 479 - return ( 480 - <View 481 - style={[ 482 - a.rounded_xs, 483 - t.atoms.bg_contrast_50, 484 - { 485 - paddingTop: 3, 486 - paddingBottom: 3, 487 - paddingLeft: 6, 488 - paddingRight: 6, 489 - }, 490 - style, 491 - ]}> 492 - <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 493 - {label} 494 - </Text> 495 - </View> 496 - ) 497 - } 498 - 499 - function StatusButton({ 500 - label, 501 - style, 502 - ...rest 503 - }: { 504 - label: string 505 - style?: StyleProp<ViewStyle> 506 - } & TriggerChildProps['props']) { 507 - const t = useTheme() 508 - 509 - return ( 510 - <Pressable 511 - style={[ 512 - a.rounded_xs, 513 - t.atoms.bg_contrast_50, 514 - { 515 - paddingTop: 3, 516 - paddingBottom: 3, 517 - paddingLeft: 6, 518 - paddingRight: 6, 519 - }, 520 - style, 521 - ]} 522 - {...rest}> 523 - <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 524 - {label} 525 - </Text> 526 - </Pressable> 527 - ) 528 - } 529 - 530 - function MemberMenu({ 531 - profile, 532 - type, 533 - isOwner, 534 - }: { 535 - profile: Shadow<bsky.profile.AnyProfileView> 536 - type: 'owner' | 'member' | 'invited' 537 - isOwner: boolean 538 - }) { 539 - const navigation = useNavigation<NavigationProp>() 540 - const t = useTheme() 541 - const {t: l} = useLingui() 542 - const ax = useAnalytics() 543 - 544 - const requireEmailVerification = useRequireEmailVerification() 545 - 546 - const blockMemberPrompt = Prompt.usePromptControl() 547 - 548 - const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 549 - const {mutate: initiateConvo} = useGetConvoForMembers({ 550 - onSuccess: ({convo}) => { 551 - ax.metric('chat:open', {logContext: 'ProfileHeader'}) 552 - navigation.navigate('MessagesConversation', {conversation: convo.id}) 553 - }, 554 - onError: () => { 555 - Toast.show(l`Failed to create conversation`) 556 - }, 557 - }) 558 - const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 559 - 560 - const messageMember = () => { 561 - if (!convoAvailability?.canChat) { 562 - return 563 - } 564 - 565 - if (convoAvailability.convo) { 566 - ax.metric('chat:open', {logContext: 'ProfileHeader'}) 567 - navigation.navigate('MessagesConversation', { 568 - conversation: convoAvailability.convo.id, 569 - }) 570 - } else { 571 - ax.metric('chat:create', {logContext: 'ProfileHeader'}) 572 - initiateConvo([profile.did]) 573 - } 574 - } 575 - 576 - const handleMessageMember = requireEmailVerification(messageMember, { 577 - instructions: [ 578 - <Trans key="message"> 579 - Before you can message another user, you must first verify your email. 580 - </Trans>, 581 - ], 582 - }) 583 - 584 - const handleBlockMember = async () => { 585 - if (profile.viewer?.blocking) { 586 - try { 587 - await queueUnblock() 588 - Toast.show(l({message: 'Account unblocked', context: 'toast'})) 589 - } catch (err) { 590 - const e = err as Error 591 - if (e?.name !== 'AbortError') { 592 - ax.logger.error('Failed to unblock account', {message: e}) 593 - Toast.show(l`There was an issue! ${e.toString()}`, { 594 - type: 'error', 595 - }) 596 - } 597 - } 598 - } else { 599 - try { 600 - await queueBlock() 601 - Toast.show(l({message: 'Account blocked', context: 'toast'})) 602 - } catch (err) { 603 - const e = err as Error 604 - if (e?.name !== 'AbortError') { 605 - ax.logger.error('Failed to block account', {message: e}) 606 - Toast.show(l`There was an issue! ${e.toString()}`, { 607 - type: 'error', 608 - }) 609 - } 610 - } 611 - } 612 - } 613 - 614 - const moderationOpts = useModerationOpts() 615 - const moderation = useMemo( 616 - () => 617 - moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, 618 - [profile, moderationOpts], 619 - ) 620 - 621 - if (!moderation) return null 622 - 623 - const isDeletedAccount = profile.handle === 'missing.invalid' 624 - const displayName = isDeletedAccount 625 - ? l`Deleted Account` 626 - : sanitizeDisplayName( 627 - profile.displayName || profile.handle, 628 - moderation.ui('displayName'), 629 - ) 630 - 631 - return ( 632 - <> 633 - <Menu.Root> 634 - <Menu.Trigger label={l`Open chat member options for ${displayName}`}> 635 - {({props, state, control: menuControl}) => 636 - type === 'owner' || type === 'invited' ? ( 637 - <StatusButton 638 - {...props} 639 - label={type === 'owner' ? l`Admin` : l`Invited`} 640 - style={[ 641 - state.hovered || state.pressed || menuControl.isOpen 642 - ? { 643 - backgroundColor: t.palette.contrast_0, 644 - } 645 - : null, 646 - ]} 647 - /> 648 - ) : ( 649 - <Pressable 650 - {...props} 651 - style={[ 652 - a.rounded_full, 653 - a.p_sm, 654 - state.hovered || state.pressed || menuControl.isOpen 655 - ? { 656 - backgroundColor: t.palette.contrast_0, 657 - } 658 - : null, 659 - ]}> 660 - <EllipsisIcon 661 - style={[t.atoms.text_contrast_medium]} 662 - size="md" 663 - /> 664 - </Pressable> 665 - ) 666 - } 667 - </Menu.Trigger> 668 - <Menu.Outer> 669 - <Menu.Group> 670 - <Menu.Item 671 - label={l`View ${displayName}’s profile`} 672 - onPress={() => { 673 - navigation.navigate('Profile', {name: profile.did}) 674 - }}> 675 - <Menu.ItemText> 676 - <Trans>Go to profile</Trans> 677 - </Menu.ItemText> 678 - <Menu.ItemIcon icon={PersonIcon} /> 679 - </Menu.Item> 680 - <Menu.Item 681 - label={l`Message ${displayName}`} 682 - onPress={handleMessageMember}> 683 - <Menu.ItemText> 684 - <Trans context="action">Message</Trans> 685 - </Menu.ItemText> 686 - <Menu.ItemIcon icon={MessageIcon} /> 687 - </Menu.Item> 688 - </Menu.Group> 689 - <Menu.Divider /> 690 - <Menu.Group> 691 - {type === 'owner' || type === 'member' ? ( 692 - <Menu.Item 693 - label={ 694 - profile.viewer?.blocking 695 - ? l`Unblock ${displayName}` 696 - : l`Block ${displayName}` 697 - } 698 - onPress={() => blockMemberPrompt.open()}> 699 - <Menu.ItemText> 700 - <Trans>Block</Trans> 701 - </Menu.ItemText> 702 - <Menu.ItemIcon icon={PersonXIcon} /> 703 - </Menu.Item> 704 - ) : null} 705 - {isOwner ? ( 706 - <Menu.Item 707 - label={l`Remove ${displayName} from this group chat`} 708 - onPress={() => {}}> 709 - <Menu.ItemText> 710 - <Trans>Remove from chat</Trans> 711 - </Menu.ItemText> 712 - <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 713 - </Menu.Item> 714 - ) : null} 715 - {isOwner && type === 'invited' ? ( 716 - <Menu.Item 717 - label={l`Uninvite ${displayName} from this group chat`} 718 - onPress={() => {}}> 719 - <Menu.ItemText> 720 - <Trans>Uninvite</Trans> 721 - </Menu.ItemText> 722 - <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 723 - </Menu.Item> 724 - ) : null} 725 - </Menu.Group> 726 - </Menu.Outer> 727 - </Menu.Root> 728 - <BlockMemberPrompt 729 - control={blockMemberPrompt} 730 - onConfirm={() => void handleBlockMember()} 731 - /> 732 - </> 733 - ) 734 - } 735 - 736 - function SettingsHeader({ 737 - convo, 738 - isOwner, 739 - }: { 740 - convo: ConvoWithDetails 741 - isOwner: boolean 742 - }) { 743 - const t = useTheme() 744 - const {t: l} = useLingui() 745 - 746 - const navigation = useNavigation<NavigationProp>() 747 - 748 - const groupName = convo.kind === 'group' ? convo.details.name : '' 749 - const [newGroupName, setNewGroupName] = useState(groupName) 750 - 751 - const [isLocked, setIsLocked] = useState(false) 752 - 753 - const {mutate: editGroupName} = useEditGroupName(convo.view.id, { 754 - onError: e => { 755 - setNewGroupName(groupName) 756 - logger.error('Failed to edit group chat name', {message: e}) 757 - Toast.show(l`Failed to edit group chat name`, { 758 - type: 'error', 759 - }) 760 - }, 761 - }) 762 - 763 - const {mutate: muteConvo} = useMuteConvo(convo.view.id, { 764 - onSuccess: data => { 765 - if (data.convo.muted) { 766 - Toast.show(l({message: 'Group chat muted', context: 'toast'})) 767 - } else { 768 - Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) 769 - } 770 - }, 771 - onError: e => { 772 - logger.error('Failed to mute group chat', {message: e}) 773 - Toast.show(l`Failed to mute group chat`, { 774 - type: 'error', 775 - }) 776 - }, 777 - }) 778 - 779 - const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { 780 - onMutate: () => { 781 - navigation.dispatch(StackActions.pop(2)) 782 - }, 783 - onError: e => { 784 - logger.error('Failed to leave group chat', {message: e}) 785 - Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { 786 - type: 'error', 787 - }) 788 - }, 789 - }) 790 - 791 - const editNamePrompt = Prompt.usePromptControl() 792 - const inviteLinkPrompt = Prompt.usePromptControl() 793 - const lockChatPrompt = Prompt.usePromptControl() 794 - const leaveChatPrompt = Prompt.usePromptControl() 795 - 796 - const handleToggleMute = () => { 797 - muteConvo({mute: !convo.view.muted}) 798 - } 799 - 800 - const handleLeaveChat = () => { 801 - leaveChatPrompt.open() 802 - } 803 - 804 - const handleReportChat = () => {} 805 - 806 - const handlePromptName = () => { 807 - editNamePrompt.open() 808 - } 809 - 810 - const handleEditName = () => { 811 - editGroupName({name: newGroupName}) 812 - editNamePrompt.close() 813 - } 814 - 815 - const handlePromptInviteLink = () => { 816 - inviteLinkPrompt.open() 817 - } 818 - 819 - const handleConfirmInviteLink = () => { 820 - inviteLinkPrompt.close() 821 - } 822 - 823 - const handlePromptLock = () => { 824 - lockChatPrompt.open() 825 - } 826 - 827 - const handleConfirmLock = () => { 828 - setIsLocked(true) 829 - } 830 - 831 - const handleUnlock = () => { 832 - setIsLocked(false) 833 - } 834 - 835 - return ( 836 - <> 837 - <View 838 - style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 839 - <View style={[a.align_center, a.justify_center]}> 840 - <AvatarBubbles profiles={convo.members} /> 841 - </View> 842 - <Text 843 - style={[ 844 - a.text_2xl, 845 - a.font_bold, 846 - a.text_center, 847 - a.pt_lg, 848 - t.atoms.text, 849 - ]}> 850 - {groupName} 851 - </Text> 852 - <Text 853 - style={[ 854 - a.text_sm, 855 - a.text_center, 856 - a.pt_xs, 857 - a.px_xl, 858 - t.atoms.text_contrast_high, 859 - ]}> 860 - Created April 2, 2026 861 - </Text> 862 - <View 863 - style={[ 864 - a.flex_row, 865 - a.align_center, 866 - a.justify_center, 867 - a.gap_2xl, 868 - a.pt_2xl, 869 - ]}> 870 - <SettingsButton 871 - color={convo.view.muted ? 'negative_subtle' : 'secondary'} 872 - icon={convo.view.muted ? BellOffIcon : BellIcon} 873 - label={ 874 - convo.view.muted 875 - ? l`Unmute this group chat` 876 - : l`Mute this group chat` 877 - } 878 - text={convo.view.muted ? l`Muted` : l`Mute`} 879 - onPress={handleToggleMute} 880 - /> 881 - {isOwner ? ( 882 - <SettingsButton 883 - icon={EditIcon} 884 - label={l`Edit this group chat’s name`} 885 - text={l`Edit name`} 886 - onPress={handlePromptName} 887 - /> 888 - ) : null} 889 - <SettingsButton 890 - icon={ChainLinkIcon} 891 - label={l`Create an invite link for this group chat`} 892 - text={l`Invite link`} 893 - onPress={handlePromptInviteLink} 894 - /> 895 - {isOwner ? ( 896 - <SettingsButton 897 - color={isLocked ? 'negative_subtle' : 'secondary'} 898 - icon={LockIcon} 899 - label={ 900 - isLocked ? l`Unlock this group chat` : l`Lock this group chat` 901 - } 902 - text={isLocked ? l`Locked` : l`Lock`} 903 - onPress={isLocked ? handleUnlock : handlePromptLock} 904 - /> 905 - ) : null} 906 - {isOwner ? null : ( 907 - <SettingsButton 908 - color="secondary" 909 - icon={FlagIcon} 910 - label={l`Report this group chat`} 911 - text={l`Report`} 912 - onPress={handleReportChat} 913 - /> 914 - )} 915 - {isOwner ? null : ( 916 - <SettingsButton 917 - color="secondary" 918 - icon={ArrowBoxLeftIcon} 919 - label={l`Leave this group chat`} 920 - text={l`Leave`} 921 - onPress={handleLeaveChat} 922 - /> 923 - )} 924 - </View> 925 - </View> 926 - <EditNamePrompt 927 - control={editNamePrompt} 928 - value={newGroupName} 929 - onChangeText={setNewGroupName} 930 - onConfirm={handleEditName} 931 - /> 932 - <InviteLinkPrompt 933 - control={inviteLinkPrompt} 934 - onConfirm={handleConfirmInviteLink} 935 - /> 936 - <LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> 937 - <LeaveChatPrompt 938 - control={leaveChatPrompt} 939 - groupName={groupName} 940 - onConfirm={leaveConvo} 941 - /> 942 - </> 943 - ) 944 - } 945 - 946 - function SettingsHeaderPlaceholder() { 947 - const t = useTheme() 948 - 949 - return ( 950 - <View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 951 - <View style={[a.align_center, a.justify_center]}> 952 - <AvatarBubbles profiles={[]} /> 953 - </View> 954 - <Text 955 - style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}> 956 - 957 - </Text> 958 - <Text 959 - style={[ 960 - a.text_sm, 961 - a.text_center, 962 - a.pt_xs, 963 - a.px_xl, 964 - t.atoms.text_contrast_high, 965 - ]}> 966 - <Trans>…</Trans> 967 - </Text> 968 - <View 969 - style={[ 970 - a.flex_row, 971 - a.align_center, 972 - a.justify_center, 973 - a.gap_2xl, 974 - a.pt_2xl, 975 - ]}> 976 - <SettingsButtonPlaceholder /> 977 - <SettingsButtonPlaceholder /> 978 - <SettingsButtonPlaceholder /> 979 - <SettingsButtonPlaceholder /> 980 - </View> 981 - </View> 982 - ) 983 - } 984 - 985 - function SettingsButton({ 986 - color = 'secondary', 987 - icon, 988 - label, 989 - text, 990 - onPress, 991 - }: { 992 - color?: ButtonColor 993 - icon: React.ComponentType<SVGIconProps> 994 - label: string 995 - text: string 996 - onPress: () => void 997 - }) { 998 - const t = useTheme() 999 - 1000 - return ( 1001 - <View> 1002 - <Button 1003 - color={color} 1004 - size="large" 1005 - shape="round" 1006 - label={label} 1007 - onPress={onPress}> 1008 - <ButtonIcon icon={icon} size="md" /> 1009 - </Button> 1010 - <Text 1011 - numberOfLines={1} 1012 - style={[ 1013 - a.text_2xs, 1014 - a.font_medium, 1015 - a.text_center, 1016 - a.pt_xs, 1017 - t.atoms.text, 1018 - ]}> 1019 - {text} 1020 - </Text> 1021 - </View> 1022 - ) 1023 - } 1024 - 1025 - function SettingsButtonPlaceholder() { 1026 - const t = useTheme() 1027 - const {t: l} = useLingui() 1028 - 1029 - return ( 1030 - <View> 1031 - <Button color="secondary" size="large" shape="round" label={l`Loading…`}> 1032 - <ButtonIcon icon={EllipsisIcon} size="md" /> 1033 - </Button> 1034 - <Text 1035 - numberOfLines={1} 1036 - style={[ 1037 - a.text_2xs, 1038 - a.font_medium, 1039 - a.text_center, 1040 - a.pt_xs, 1041 - t.atoms.text, 1042 - ]}> 1043 - 1044 - </Text> 1045 - </View> 1046 - ) 1047 - } 1048 - 1049 - function EditNamePrompt({ 1050 - control, 1051 - value, 1052 - onChangeText, 1053 - onConfirm, 1054 - }: { 1055 - control: Dialog.DialogOuterProps['control'] 1056 - value: string 1057 - onChangeText: (value: string) => void 1058 - onConfirm: () => void 1059 - }) { 1060 - const {t: l} = useLingui() 1061 - 1062 - return ( 1063 - <Prompt.Outer control={control}> 1064 - <> 1065 - <Prompt.Content> 1066 - <Prompt.TitleText> 1067 - <Trans>Edit group name</Trans> 1068 - </Prompt.TitleText> 1069 - <View style={[a.my_sm]}> 1070 - <TextField.Root isInvalid={false}> 1071 - <TextField.Input 1072 - label={l`Edit group name`} 1073 - placeholder={l`Group name`} 1074 - value={value} 1075 - onChangeText={onChangeText} 1076 - returnKeyType="done" 1077 - autoCapitalize="none" 1078 - autoComplete="off" 1079 - autoCorrect={false} 1080 - autoFocus 1081 - onSubmitEditing={onConfirm} 1082 - /> 1083 - </TextField.Root> 1084 - </View> 1085 - </Prompt.Content> 1086 - <Prompt.Actions> 1087 - <Prompt.Action 1088 - cta={l`Save`} 1089 - shouldCloseOnPress={false} 1090 - onPress={onConfirm} 1091 - /> 1092 - <Prompt.Cancel /> 1093 - </Prompt.Actions> 1094 - </> 1095 - </Prompt.Outer> 1096 - ) 1097 - } 1098 - 1099 - function InviteLinkPrompt({ 1100 - control, 1101 - onConfirm, 1102 - }: { 1103 - control: Dialog.DialogOuterProps['control'] 1104 - onConfirm: () => void 1105 - }) { 1106 - const {t: l} = useLingui() 1107 - 1108 - return ( 1109 - <Prompt.Basic 1110 - control={control} 1111 - title={l`Invite link`} 1112 - 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.`} 1113 - confirmButtonCta={l`Get started`} 1114 - cancelButtonCta={l`Cancel`} 1115 - onConfirm={onConfirm} 1116 - /> 1117 - ) 1118 - } 1119 - 1120 - function LockChatPrompt({ 1121 - control, 1122 - onConfirm, 1123 - }: { 1124 - control: Dialog.DialogOuterProps['control'] 1125 - onConfirm: () => void 1126 - }) { 1127 - const {t: l} = useLingui() 1128 - 1129 - return ( 1130 - <Prompt.Basic 1131 - control={control} 1132 - title={l`Lock group chat?`} 1133 - description={l`Members can still read chat history but can’t send new messages.`} 1134 - confirmButtonCta={l`Lock group chat`} 1135 - cancelButtonCta={l`Cancel`} 1136 - onConfirm={onConfirm} 1137 - /> 1138 - ) 1139 - } 1140 - 1141 - function LeaveChatPrompt({ 1142 - control, 1143 - groupName, 1144 - onConfirm, 1145 - }: { 1146 - control: Dialog.DialogOuterProps['control'] 1147 - groupName: string 1148 - onConfirm: () => void 1149 - }) { 1150 - const {t: l} = useLingui() 1151 - 1152 - return ( 1153 - <Prompt.Basic 1154 - control={control} 1155 - title={l`Are you sure you want to leave ${groupName}?`} 1156 - description={l`You won’t be able to rejoin unless you’re invited.`} 1157 - confirmButtonCta={l`Leave group chat`} 1158 - confirmButtonColor="negative" 1159 - cancelButtonCta={l`Cancel`} 1160 - onConfirm={onConfirm} 1161 - /> 1162 - ) 1163 - } 1164 - 1165 - function BlockMemberPrompt({ 1166 - control, 1167 - onConfirm, 1168 - }: { 1169 - control: Dialog.DialogOuterProps['control'] 1170 - onConfirm: () => void 1171 - }) { 1172 - const {t: l} = useLingui() 1173 - 1174 - return ( 1175 - <Prompt.Basic 1176 - control={control} 1177 - title={l`Block account?`} 1178 - description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 1179 - onConfirm={onConfirm} 1180 - confirmButtonCta={l`Block`} 1181 - confirmButtonColor="negative" 1182 - /> 1183 - ) 1184 - } 1185 - 1186 - function SubtleHoverWrapper({children}: React.PropsWithChildren<unknown>) { 1187 - const { 1188 - state: hover, 1189 - onIn: onHoverIn, 1190 - onOut: onHoverOut, 1191 - } = useInteractionState() 1192 - 1193 - return ( 1194 - <View 1195 - onPointerEnter={onHoverIn} 1196 - onPointerLeave={onHoverOut} 1197 - style={a.pointer}> 1198 - <SubtleHover hover={hover} /> 1199 - {children} 1200 - </View> 1201 - ) 1202 - }
+108
src/screens/Messages/ConversationSettings/AddMembersLink.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans, useLingui} from '@lingui/react/macro' 3 + 4 + import {logger} from '#/logger' 5 + import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {Button} from '#/components/Button' 8 + import * as Dialog from '#/components/Dialog' 9 + import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 10 + import {type ConvoWithDetails} from '#/components/dms/util' 11 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' 12 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 13 + import {Loader} from '#/components/Loader' 14 + import * as Toast from '#/components/Toast' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function AddMembersLink({ 18 + convo, 19 + members, 20 + }: { 21 + convo: ConvoWithDetails 22 + members: string[] 23 + }) { 24 + const t = useTheme() 25 + const {t: l} = useLingui() 26 + 27 + const addMembersControl = Dialog.useDialogControl() 28 + 29 + const convoId = convo.view.id 30 + const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers( 31 + convoId, 32 + { 33 + onSuccess: () => { 34 + addMembersControl.close() 35 + }, 36 + onError: e => { 37 + logger.error('Failed to add group chat members', {message: e}) 38 + Toast.show(l`Failed to add members`, {type: 'error'}) 39 + }, 40 + }, 41 + ) 42 + 43 + return ( 44 + <> 45 + <Button 46 + disabled={isAddPending} 47 + label={l`Add members`} 48 + onPress={addMembersControl.open}> 49 + {({interacting}) => ( 50 + <View 51 + style={[ 52 + a.w_full, 53 + a.flex_row, 54 + a.align_center, 55 + a.justify_between, 56 + a.px_xl, 57 + a.py_sm, 58 + interacting ? [t.atoms.bg_contrast_25] : [], 59 + ]}> 60 + <View style={[a.flex_row, a.align_center]}> 61 + <View 62 + style={[ 63 + a.flex_row, 64 + a.align_center, 65 + a.justify_center, 66 + a.p_lg, 67 + a.rounded_full, 68 + interacting 69 + ? t.atoms.bg_contrast_100 70 + : t.atoms.bg_contrast_50, 71 + { 72 + height: 48, 73 + width: 48, 74 + }, 75 + ]}> 76 + <PlusIcon style={[t.atoms.text_contrast_high]} size="sm" /> 77 + </View> 78 + <Text 79 + numberOfLines={1} 80 + style={[a.text_md, a.font_semi_bold, a.mx_sm, t.atoms.text]}> 81 + <Trans>Add members</Trans> 82 + </Text> 83 + </View> 84 + {isAddPending ? ( 85 + <Loader size="md" /> 86 + ) : ( 87 + <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 88 + )} 89 + </View> 90 + )} 91 + </Button> 92 + 93 + <Dialog.Outer 94 + control={addMembersControl} 95 + testID="addChatMembersDialog" 96 + nativeOptions={{fullHeight: true}}> 97 + <Dialog.Handle /> 98 + <AddMembersFlow 99 + members={members} 100 + title={l`Add members`} 101 + onAddMembers={(members, profiles) => { 102 + addGroupMembers({members, profiles}) 103 + }} 104 + /> 105 + </Dialog.Outer> 106 + </> 107 + ) 108 + }
+129
src/screens/Messages/ConversationSettings/Member.tsx
··· 1 + import {View} from 'react-native' 2 + import {moderateProfile} from '@atproto/api' 3 + import {useLingui} from '@lingui/react/macro' 4 + 5 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 6 + import {useProfileShadow} from '#/state/cache/profile-shadow' 7 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 + import {useSession} from '#/state/session' 9 + import {atoms as a, native, useTheme, web} from '#/alf' 10 + import { 11 + type ConvoWithDetails, 12 + type GroupConvoMember, 13 + } from '#/components/dms/util' 14 + import * as ProfileCard from '#/components/ProfileCard' 15 + import {Text} from '#/components/Typography' 16 + import {MemberMenu} from './MemberMenu' 17 + import {StatusBadge} from './StatusBadge' 18 + import {SubtleHoverWrapper} from './SubtleHoverWrapper' 19 + 20 + const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm] 21 + 22 + export function Member({ 23 + convo, 24 + profile: profileUnshadowed, 25 + status, 26 + isOwner, 27 + }: { 28 + convo: ConvoWithDetails 29 + profile: GroupConvoMember 30 + status: 'owner' | 'standard' | 'invited' 31 + isOwner: boolean 32 + }) { 33 + const t = useTheme() 34 + const {t: l} = useLingui() 35 + 36 + const profile = useProfileShadow(profileUnshadowed) 37 + const {currentAccount} = useSession() 38 + const moderationOpts = useModerationOpts() 39 + 40 + if (!moderationOpts) { 41 + return <MemberPlaceholder /> 42 + } 43 + 44 + const moderation = moderateProfile(profile, moderationOpts) 45 + 46 + const isDeletedAccount = profile.handle === 'missing.invalid' 47 + const displayName = isDeletedAccount 48 + ? l`Deleted Account` 49 + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) 50 + const isProfileOwner = profile.did === convo.primaryMember.did 51 + const isSelf = currentAccount?.did === profile.did 52 + let statusBadge: React.ReactNode | null = null 53 + if (isSelf) { 54 + if (status === 'owner') { 55 + statusBadge = <StatusBadge label={l`Admin`} /> 56 + } 57 + } else { 58 + statusBadge = ( 59 + <MemberMenu 60 + convo={convo} 61 + profile={profile} 62 + displayName={displayName} 63 + type={status} 64 + isOwner={isOwner} 65 + /> 66 + ) 67 + } 68 + 69 + const joinedReason = profile.kind?.addedBy 70 + ? l`Added by ${createSanitizedDisplayName( 71 + profile.kind.addedBy, 72 + true, 73 + moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'), 74 + )}` 75 + : `Added by invite link` 76 + 77 + return ( 78 + <SubtleHoverWrapper> 79 + <View style={outerStyles}> 80 + <ProfileCard.Link profile={profile} style={[a.flex_1]}> 81 + <ProfileCard.Outer> 82 + <ProfileCard.Header> 83 + <ProfileCard.Avatar 84 + size={48} 85 + profile={profile} 86 + moderationOpts={moderationOpts} 87 + /> 88 + <View style={[a.flex_1]}> 89 + <ProfileCard.Name 90 + profile={profile} 91 + moderationOpts={moderationOpts} 92 + /> 93 + <ProfileCard.Handle 94 + profile={profile} 95 + textStyle={[a.text_xs, native({top: -1})]} 96 + /> 97 + {!isProfileOwner && ( 98 + <Text 99 + style={[ 100 + a.text_xs, 101 + a.leading_snug, 102 + t.atoms.text_contrast_medium, 103 + web(a.pt_2xs), 104 + ]}> 105 + {joinedReason} 106 + </Text> 107 + )} 108 + </View> 109 + </ProfileCard.Header> 110 + </ProfileCard.Outer> 111 + </ProfileCard.Link> 112 + {statusBadge} 113 + </View> 114 + </SubtleHoverWrapper> 115 + ) 116 + } 117 + 118 + export function MemberPlaceholder() { 119 + return ( 120 + <View style={outerStyles}> 121 + <ProfileCard.Outer> 122 + <ProfileCard.Header> 123 + <ProfileCard.AvatarPlaceholder size={48} /> 124 + <ProfileCard.NameAndHandlePlaceholder /> 125 + </ProfileCard.Header> 126 + </ProfileCard.Outer> 127 + </View> 128 + ) 129 + }
+252
src/screens/Messages/ConversationSettings/MemberMenu.tsx
··· 1 + import {useState} from 'react' 2 + import {Pressable} from 'react-native' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + import {useNavigation} from '@react-navigation/native' 5 + 6 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 7 + import {type NavigationProp} from '#/lib/routes/types' 8 + import {logger} from '#/logger' 9 + import {type Shadow} from '#/state/cache/types' 10 + import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 11 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 12 + import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' 13 + import {useProfileBlockMutationQueue} from '#/state/queries/profile' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {type ConvoWithDetails} from '#/components/dms/util' 16 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 17 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 18 + import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 19 + import { 20 + Person_Stroke2_Corner2_Rounded as PersonIcon, 21 + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, 22 + } from '#/components/icons/Person' 23 + import * as Menu from '#/components/Menu' 24 + import * as Prompt from '#/components/Prompt' 25 + import * as Toast from '#/components/Toast' 26 + import {useAnalytics} from '#/analytics' 27 + import type * as bsky from '#/types/bsky' 28 + import {BlockMemberPrompt} from './prompts' 29 + import {StatusBadge} from './StatusBadge' 30 + 31 + export function MemberMenu({ 32 + convo, 33 + profile, 34 + displayName, 35 + type, 36 + isOwner, 37 + }: { 38 + convo: ConvoWithDetails 39 + profile: Shadow<bsky.profile.AnyProfileView> 40 + type: 'owner' | 'standard' | 'invited' 41 + displayName: string 42 + isOwner: boolean 43 + }) { 44 + const navigation = useNavigation<NavigationProp>() 45 + const t = useTheme() 46 + const {t: l} = useLingui() 47 + const ax = useAnalytics() 48 + 49 + const requireEmailVerification = useRequireEmailVerification() 50 + 51 + const blockMemberPrompt = Prompt.usePromptControl() 52 + 53 + const [menuDidOpen, setMenuDidOpen] = useState(false) 54 + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { 55 + enabled: menuDidOpen, 56 + }) 57 + const {mutate: initiateConvo} = useGetConvoForMembers({ 58 + onSuccess: ({convo}) => { 59 + ax.metric('chat:open', {logContext: 'ConvoSettings'}) 60 + navigation.navigate('MessagesConversation', {conversation: convo.id}) 61 + }, 62 + onError: () => { 63 + Toast.show(l`Failed to create conversation`, {type: 'error'}) 64 + }, 65 + }) 66 + const convoId = convo.view.id 67 + const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, { 68 + onError: e => { 69 + logger.error('Failed to remove group chat member', {message: e}) 70 + Toast.show(l`Failed to remove group chat member`, {type: 'error'}) 71 + }, 72 + }) 73 + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 74 + 75 + const messageMember = () => { 76 + if (!convoAvailability?.canChat) { 77 + return 78 + } 79 + 80 + if (convoAvailability.convo) { 81 + ax.metric('chat:open', {logContext: 'ConvoSettings'}) 82 + navigation.navigate('MessagesConversation', { 83 + conversation: convoAvailability.convo.id, 84 + }) 85 + } else { 86 + ax.metric('chat:create', {logContext: 'ConvoSettings'}) 87 + initiateConvo([profile.did]) 88 + } 89 + } 90 + 91 + const handleMessageMember = requireEmailVerification(messageMember, { 92 + instructions: [ 93 + <Trans key="message"> 94 + Before you can message another user, you must first verify your email. 95 + </Trans>, 96 + ], 97 + }) 98 + 99 + const handleBlockMember = async () => { 100 + if (profile.viewer?.blocking) { 101 + try { 102 + await queueUnblock() 103 + Toast.show(l({message: 'Account unblocked', context: 'toast'})) 104 + } catch (err) { 105 + const e = err as Error 106 + if (e?.name !== 'AbortError') { 107 + logger.error('Failed to unblock account', {message: e}) 108 + Toast.show(l`There was an issue! ${e.toString()}`, { 109 + type: 'error', 110 + }) 111 + } 112 + } 113 + } else { 114 + try { 115 + await queueBlock() 116 + Toast.show(l({message: 'Account blocked', context: 'toast'})) 117 + } catch (err) { 118 + const e = err as Error 119 + if (e?.name !== 'AbortError') { 120 + logger.error('Failed to block account', {message: e}) 121 + Toast.show(l`There was an issue! ${e.toString()}`, { 122 + type: 'error', 123 + }) 124 + } 125 + } 126 + } 127 + } 128 + 129 + const canBlockMember = type === 'owner' || type === 'standard' 130 + const canRemoveMember = isOwner && type !== 'invited' 131 + // TODO Need to integrate this. -dsb 132 + const canUninviteMember = false 133 + // const canUninviteMember = isOwner && type === 'invited' 134 + 135 + return ( 136 + <> 137 + <Menu.Root> 138 + <Menu.Trigger label={l`Open chat member options for ${displayName}`}> 139 + {({props, state, control: menuControl}) => { 140 + const isActive = 141 + state.hovered || state.pressed || menuControl.isOpen 142 + const triggerProps = { 143 + ...props, 144 + onPress: () => { 145 + setMenuDidOpen(true) 146 + props.onPress() 147 + }, 148 + } 149 + return type === 'owner' || type === 'invited' ? ( 150 + <StatusBadge 151 + label={type === 'owner' ? l`Admin` : l`Invited`} 152 + pressableProps={triggerProps} 153 + style={[ 154 + isActive 155 + ? { 156 + backgroundColor: t.palette.contrast_0, 157 + } 158 + : null, 159 + ]} 160 + /> 161 + ) : ( 162 + <Pressable 163 + {...triggerProps} 164 + style={[ 165 + a.rounded_full, 166 + a.p_sm, 167 + isActive 168 + ? { 169 + backgroundColor: t.palette.contrast_0, 170 + } 171 + : null, 172 + ]}> 173 + <EllipsisIcon 174 + style={[t.atoms.text_contrast_medium]} 175 + size="md" 176 + /> 177 + </Pressable> 178 + ) 179 + }} 180 + </Menu.Trigger> 181 + <Menu.Outer> 182 + <Menu.Group> 183 + <Menu.Item 184 + label={l`View ${displayName}’s profile`} 185 + onPress={() => { 186 + navigation.navigate('Profile', {name: profile.did}) 187 + }}> 188 + <Menu.ItemText> 189 + <Trans>Go to profile</Trans> 190 + </Menu.ItemText> 191 + <Menu.ItemIcon icon={PersonIcon} /> 192 + </Menu.Item> 193 + <Menu.Item 194 + label={l`Message ${displayName}`} 195 + onPress={handleMessageMember}> 196 + <Menu.ItemText> 197 + <Trans context="action">Message</Trans> 198 + </Menu.ItemText> 199 + <Menu.ItemIcon icon={MessageIcon} /> 200 + </Menu.Item> 201 + </Menu.Group> 202 + <Menu.Divider /> 203 + <Menu.Group> 204 + {canBlockMember ? ( 205 + <Menu.Item 206 + label={ 207 + profile.viewer?.blocking 208 + ? l`Unblock ${displayName}` 209 + : l`Block ${displayName}` 210 + } 211 + onPress={ 212 + profile.viewer?.blocking 213 + ? handleBlockMember 214 + : blockMemberPrompt.open 215 + }> 216 + <Menu.ItemText> 217 + <Trans>Block</Trans> 218 + </Menu.ItemText> 219 + <Menu.ItemIcon icon={PersonXIcon} /> 220 + </Menu.Item> 221 + ) : null} 222 + {canRemoveMember ? ( 223 + <Menu.Item 224 + label={l`Remove ${displayName} from this group chat`} 225 + onPress={() => removeMembers({members: [profile.did]})}> 226 + <Menu.ItemText> 227 + <Trans>Remove from chat</Trans> 228 + </Menu.ItemText> 229 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 230 + </Menu.Item> 231 + ) : null} 232 + {canUninviteMember ? ( 233 + <Menu.Item 234 + label={l`Uninvite ${displayName} from this group chat`} 235 + // TODO Need to wire up the uninvite flow. -dsb 236 + onPress={() => {}}> 237 + <Menu.ItemText> 238 + <Trans>Uninvite</Trans> 239 + </Menu.ItemText> 240 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 241 + </Menu.Item> 242 + ) : null} 243 + </Menu.Group> 244 + </Menu.Outer> 245 + </Menu.Root> 246 + <BlockMemberPrompt 247 + control={blockMemberPrompt} 248 + onConfirm={() => void handleBlockMember()} 249 + /> 250 + </> 251 + ) 252 + }
+65
src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
··· 1 + import {View} from 'react-native' 2 + import {plural} from '@lingui/core/macro' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {InlineLinkText} from '#/components/Link' 7 + import {Text} from '#/components/Typography' 8 + import {MEMBER_LIMIT} from './constants' 9 + 10 + export function MembersAndRequests({ 11 + memberCount, 12 + requestCount, 13 + hasMoreRequests, 14 + isOwner, 15 + }: { 16 + memberCount: number 17 + requestCount: number 18 + hasMoreRequests: boolean 19 + isOwner: boolean 20 + }) { 21 + const t = useTheme() 22 + const {t: l} = useLingui() 23 + 24 + return ( 25 + <View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}> 26 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 27 + <Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}> 28 + <Trans>Members</Trans> 29 + </Text> 30 + <View 31 + style={[a.px_xs, a.py_2xs, t.atoms.bg_contrast_50, a.rounded_full]}> 32 + <Text 33 + style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}> 34 + {l({ 35 + message: `${memberCount}/${MEMBER_LIMIT}`, 36 + comment: 37 + 'The number of group chat members out of the total number of permitted users.', 38 + })} 39 + </Text> 40 + </View> 41 + </View> 42 + {isOwner && requestCount > 0 ? ( 43 + <InlineLinkText 44 + label={l`View incoming group chat requests`} 45 + style={[a.text_sm, a.text_right, a.font_semi_bold]} 46 + // TODO Need to implement this. -dsb 47 + to="#"> 48 + {hasMoreRequests 49 + ? l({ 50 + message: `${requestCount}+ requests`, 51 + comment: 52 + 'Displayed when there are more than 50 requests to join a group chat', 53 + }) 54 + : l({ 55 + message: plural(requestCount, { 56 + one: '# request', 57 + other: '# requests', 58 + }), 59 + comment: 'The number of requests to join a group chat.', 60 + })} 61 + </InlineLinkText> 62 + ) : null} 63 + </View> 64 + ) 65 + }
+44
src/screens/Messages/ConversationSettings/StatusBadge.tsx
··· 1 + import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {type TriggerChildProps} from '#/components/Menu/types' 5 + import {Text} from '#/components/Typography' 6 + 7 + export function StatusBadge({ 8 + label, 9 + style, 10 + pressableProps, 11 + }: { 12 + label: string 13 + style?: StyleProp<ViewStyle> 14 + pressableProps?: TriggerChildProps['props'] 15 + }) { 16 + const t = useTheme() 17 + 18 + const badgeStyle = [ 19 + a.rounded_xs, 20 + t.atoms.bg_contrast_50, 21 + { 22 + paddingTop: 3, 23 + paddingBottom: 3, 24 + paddingLeft: 6, 25 + paddingRight: 6, 26 + }, 27 + style, 28 + ] 29 + 30 + const labelText = ( 31 + <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 32 + {label} 33 + </Text> 34 + ) 35 + 36 + if (pressableProps) { 37 + return ( 38 + <Pressable style={badgeStyle} {...pressableProps}> 39 + {labelText} 40 + </Pressable> 41 + ) 42 + } 43 + return <View style={badgeStyle}>{labelText}</View> 44 + }
+27
src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {atoms as a} from '#/alf' 4 + import {useInteractionState} from '#/components/hooks/useInteractionState' 5 + import {SubtleHover} from '#/components/SubtleHover' 6 + 7 + export function SubtleHoverWrapper({ 8 + children, 9 + }: React.PropsWithChildren<unknown>) { 10 + const { 11 + state: hover, 12 + onIn: onHoverIn, 13 + onOut: onHoverOut, 14 + } = useInteractionState() 15 + 16 + return ( 17 + <View 18 + // Web-only 19 + onPointerEnter={onHoverIn} 20 + // Web-only 21 + onPointerLeave={onHoverOut} 22 + style={a.pointer}> 23 + <SubtleHover hover={hover} /> 24 + {children} 25 + </View> 26 + ) 27 + }
+1
src/screens/Messages/ConversationSettings/constants.ts
··· 1 + export const MEMBER_LIMIT = 50
+637
src/screens/Messages/ConversationSettings/index.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {type ChatBskyConvoDefs} from '@atproto/api' 4 + import {Trans, useLingui} from '@lingui/react/macro' 5 + import {StackActions, useNavigation} from '@react-navigation/native' 6 + 7 + import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' 8 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 9 + import { 10 + type CommonNavigatorParams, 11 + type NativeStackScreenProps, 12 + type NavigationProp, 13 + } from '#/lib/routes/types' 14 + import {logger} from '#/logger' 15 + import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' 16 + import {ConvoStatus} from '#/state/messages/convo/types' 17 + import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' 18 + import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 19 + import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' 20 + import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' 21 + import {useLockConvo} from '#/state/queries/messages/lock-conversation' 22 + import {useMuteConvo} from '#/state/queries/messages/mute-conversation' 23 + import {useSession} from '#/state/session' 24 + import {List} from '#/view/com/util/List' 25 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 26 + import {AvatarBubbles} from '#/components/AvatarBubbles' 27 + import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' 28 + import * as Dialog from '#/components/Dialog' 29 + import { 30 + type ConvoWithDetails, 31 + type GroupConvoMember, 32 + } from '#/components/dms/util' 33 + import {Error} from '#/components/Error' 34 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 35 + import { 36 + Bell2_Stroke2_Corner0_Rounded as BellIcon, 37 + Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, 38 + } from '#/components/icons/Bell2' 39 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 40 + import {type Props as SVGIconProps} from '#/components/icons/common' 41 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 42 + import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' 43 + import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 44 + import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 45 + import * as Layout from '#/components/Layout' 46 + import {Loader} from '#/components/Loader' 47 + import * as Prompt from '#/components/Prompt' 48 + import * as Toast from '#/components/Toast' 49 + import {Text} from '#/components/Typography' 50 + import {InviteLinkDialog} from '../components/InviteLinkDialog' 51 + import {AddMembersLink} from './AddMembersLink' 52 + import {Member, MemberPlaceholder} from './Member' 53 + import {MembersAndRequests} from './MembersAndRequests' 54 + import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts' 55 + 56 + const dateFormatter = new Intl.DateTimeFormat(undefined, { 57 + month: 'long', 58 + day: 'numeric', 59 + year: 'numeric', 60 + }) 61 + 62 + type Item = 63 + | {type: 'MEMBERS_AND_REQUESTS'; key: string} 64 + | {type: 'ADD_MEMBERS_LINK'; key: string} 65 + | { 66 + type: 'CHAT_MEMBER' 67 + key: string 68 + profile: GroupConvoMember 69 + status: 'owner' | 'standard' | 'invited' 70 + } 71 + | { 72 + type: 'CHAT_MEMBER_PLACEHOLDER' 73 + key: string 74 + } 75 + 76 + type Props = NativeStackScreenProps< 77 + CommonNavigatorParams, 78 + 'MessagesConversationSettings' 79 + > 80 + 81 + export function MessagesConversationSettingsScreen({route}: Props) { 82 + const {gtTablet} = useBreakpoints() 83 + 84 + const convoId = route.params.conversation 85 + 86 + return ( 87 + <Layout.Screen> 88 + <Layout.Header.Outer> 89 + <Layout.Header.BackButton /> 90 + <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 91 + <Layout.Header.TitleText> 92 + <Trans>Group chat settings</Trans> 93 + </Layout.Header.TitleText> 94 + </Layout.Header.Content> 95 + <Layout.Header.Slot /> 96 + </Layout.Header.Outer> 97 + <ConvoProvider key={convoId} convoId={convoId}> 98 + <SettingsInner /> 99 + </ConvoProvider> 100 + </Layout.Screen> 101 + ) 102 + } 103 + 104 + function SettingsInner() { 105 + const {t: l} = useLingui() 106 + const convoState = useConvo() 107 + const navigation = useNavigation<NavigationProp>() 108 + 109 + if (convoState.status === ConvoStatus.Error) { 110 + return ( 111 + <Error 112 + title={l`Something went wrong`} 113 + message={l`We couldn’t load this conversation’s settings`} 114 + onRetry={() => convoState.error.retry()} 115 + sideBorders={false} 116 + /> 117 + ) 118 + } 119 + 120 + if (!isConvoActive(convoState)) { 121 + return ( 122 + <Layout.Content> 123 + <View style={[a.align_center, a.justify_center, a.flex_1, a.py_4xl]}> 124 + <Loader size="xl" /> 125 + </View> 126 + </Layout.Content> 127 + ) 128 + } 129 + 130 + if (convoState.convo?.kind !== 'group') { 131 + return ( 132 + <Error 133 + title={l`Wrong kind of conversation`} 134 + message={l`This screen is only available for group conversations.`} 135 + onGoBack={() => { 136 + if (navigation.canGoBack()) { 137 + navigation.goBack() 138 + } else { 139 + navigation.replace('Messages', {animation: 'pop'}) 140 + } 141 + }} 142 + /> 143 + ) 144 + } 145 + 146 + return <GroupSettings convo={convoState.convo} /> 147 + } 148 + 149 + function keyExtractor(item: Item) { 150 + return item.key 151 + } 152 + 153 + function GroupSettings({ 154 + convo, 155 + }: { 156 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 157 + }) { 158 + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) 159 + const bottomBarOffset = useBottomBarOffset() 160 + 161 + const {currentAccount} = useSession() 162 + 163 + const primaryMember = convo?.primaryMember 164 + const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did 165 + 166 + const {data: memberListData = [], isPending} = useListConvoMembersQuery({ 167 + convoId: convo.view.id, 168 + placeholderData: convo?.members, 169 + }) 170 + 171 + // TODO Need this data in order to populate this array. -dsb 172 + const invites: string[] = [] 173 + 174 + const {data: joinRequestsData, hasNextPage: hasMoreRequests} = 175 + useListJoinRequestsQuery({ 176 + convoId: convo.view.id, 177 + enabled: isOwner, 178 + }) 179 + const requestCount = 180 + joinRequestsData?.pages.reduce( 181 + (sum, page) => sum + page.requests.length, 182 + 0, 183 + ) ?? 0 184 + 185 + const items: Item[] = [ 186 + { 187 + type: 'MEMBERS_AND_REQUESTS', 188 + key: 'members-and-requests', 189 + }, 190 + ...(isOwner 191 + ? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const] 192 + : []), 193 + ] 194 + if (isPending) { 195 + // should never be pending if we correctly set the query cache data 196 + Array.from({length: 5}).forEach((_, i) => 197 + items.push({ 198 + type: 'CHAT_MEMBER_PLACEHOLDER', 199 + key: `chat-member-placeholder-${i}`, 200 + }), 201 + ) 202 + } else { 203 + items.push( 204 + ...memberListData 205 + .sort((a, b) => { 206 + const aIsOwner = a.did === primaryMember?.did 207 + const bIsOwner = b.did === primaryMember?.did 208 + const aIsSelf = a.did === currentAccount?.did 209 + const bIsSelf = b.did === currentAccount?.did 210 + if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 211 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 212 + return 0 213 + }) 214 + .map( 215 + (profile): Item => ({ 216 + type: 'CHAT_MEMBER', 217 + key: profile.did, 218 + profile: profile as GroupConvoMember, 219 + status: 220 + primaryMember?.did === profile.did 221 + ? 'owner' 222 + : invites.includes(profile.did) 223 + ? 'invited' 224 + : 'standard', 225 + }), 226 + ), 227 + ) 228 + } 229 + 230 + function renderItem({item}: {item: Item}) { 231 + switch (item.type) { 232 + case 'MEMBERS_AND_REQUESTS': 233 + return ( 234 + <MembersAndRequests 235 + memberCount={convo.details.memberCount} 236 + requestCount={requestCount} 237 + hasMoreRequests={!!hasMoreRequests} 238 + isOwner={isOwner} 239 + /> 240 + ) 241 + case 'ADD_MEMBERS_LINK': 242 + return convo ? ( 243 + <AddMembersLink 244 + convo={convo} 245 + members={memberListData.map(profile => profile.did)} 246 + /> 247 + ) : null 248 + case 'CHAT_MEMBER': 249 + return convo ? ( 250 + <Member 251 + convo={convo} 252 + profile={item.profile} 253 + status={item.status} 254 + isOwner={isOwner} 255 + /> 256 + ) : null 257 + case 'CHAT_MEMBER_PLACEHOLDER': 258 + return <MemberPlaceholder /> 259 + default: 260 + return null 261 + } 262 + } 263 + 264 + return ( 265 + <List 266 + data={items} 267 + contentContainerStyle={{ 268 + paddingBottom: bottomBarOffset + a.pb_xl.paddingBottom, 269 + }} 270 + desktopFixedHeight 271 + initialNumToRender={initialNumToRender} 272 + keyExtractor={keyExtractor} 273 + ListHeaderComponent={ 274 + convo?.kind === 'group' ? ( 275 + <SettingsHeader convo={convo} isOwner={isOwner} /> 276 + ) : ( 277 + <SettingsHeaderPlaceholder /> 278 + ) 279 + } 280 + renderItem={renderItem} 281 + sideBorders={false} 282 + windowSize={11} 283 + /> 284 + ) 285 + } 286 + 287 + function SettingsHeader({ 288 + convo, 289 + isOwner, 290 + }: { 291 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 292 + isOwner: boolean 293 + }) { 294 + const t = useTheme() 295 + const {t: l} = useLingui() 296 + 297 + const navigation = useNavigation<NavigationProp>() 298 + 299 + const groupName = convo.details.name 300 + const [newGroupName, setNewGroupName] = useState(groupName) 301 + 302 + const lockStatus = convo.details.lockStatus 303 + 304 + // TODO Enable this once the feature is working end-to-end. -dsb 305 + // const {joinLink} = convo.details 306 + const isJoinLinkEnabled = false 307 + // const isJoinLinkEnabled = 308 + // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled') 309 + 310 + // TODO Enable this once the feature is working end-to-end. -dsb 311 + const isReportLinkEnabled = false 312 + 313 + const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { 314 + onError: e => { 315 + setNewGroupName(groupName) 316 + logger.error('Failed to edit group chat name', {message: e}) 317 + Toast.show(l`Failed to edit group chat name`, {type: 'error'}) 318 + }, 319 + }) 320 + 321 + const {mutate: muteConvo} = useMuteConvo(convo.view.id, { 322 + onSuccess: data => { 323 + if (data.convo.muted) { 324 + Toast.show(l({message: 'Group chat muted', context: 'toast'})) 325 + } else { 326 + Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) 327 + } 328 + }, 329 + onError: e => { 330 + logger.error('Failed to mute group chat', {message: e}) 331 + Toast.show(l`Failed to mute group chat`, {type: 'error'}) 332 + }, 333 + }) 334 + 335 + const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { 336 + onSuccess: () => { 337 + // Settings > Chat > Chat list 338 + navigation.dispatch(StackActions.pop(2)) 339 + }, 340 + onError: e => { 341 + logger.error('Failed to leave group chat', {message: e}) 342 + Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { 343 + type: 'error', 344 + }) 345 + }, 346 + }) 347 + 348 + const {mutate: lockConvo} = useLockConvo(convo.view.id, { 349 + onSuccess: data => { 350 + const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo 351 + if (kind.lockStatus === 'locked') { 352 + Toast.show(l({message: 'Group chat locked', context: 'toast'})) 353 + } else { 354 + Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) 355 + } 356 + }, 357 + onError: (e, {lock}) => { 358 + if (lock) { 359 + logger.error('Failed to lock group chat', {message: e}) 360 + Toast.show(l`Failed to lock group chat`, {type: 'error'}) 361 + } else { 362 + logger.error('Failed to unlock group chat', {message: e}) 363 + Toast.show(l`Failed to unlock group chat`, {type: 'error'}) 364 + } 365 + }, 366 + }) 367 + 368 + const inviteLinkDialog = Dialog.useDialogControl() 369 + const editNamePrompt = Prompt.usePromptControl() 370 + const lockChatPrompt = Prompt.usePromptControl() 371 + const leaveChatPrompt = Prompt.usePromptControl() 372 + 373 + const handleToggleMute = () => { 374 + muteConvo({mute: !convo.view.muted}) 375 + } 376 + 377 + // TODO Need to implement this when the backend is ready. -dsb 378 + const handleReportChat = () => {} 379 + 380 + const handlePromptName = () => { 381 + setNewGroupName(groupName) 382 + editNamePrompt.open() 383 + } 384 + 385 + const handleEditName = () => { 386 + editGroupName({name: newGroupName}) 387 + } 388 + 389 + const handleConfirmLock = () => { 390 + lockConvo({lock: true}) 391 + } 392 + 393 + const handleUnlock = () => { 394 + lockConvo({lock: false}) 395 + } 396 + 397 + // TODO The creation date doesn't exist yet. -dsb 398 + const showCreatedAt = false 399 + const createdAt = new Date() 400 + 401 + const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently' 402 + 403 + return ( 404 + <> 405 + <View 406 + style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 407 + <View style={[a.align_center, a.justify_center]}> 408 + <AvatarBubbles profiles={convo.members} /> 409 + </View> 410 + <Text 411 + style={[ 412 + a.text_2xl, 413 + a.font_bold, 414 + a.text_center, 415 + a.pt_lg, 416 + t.atoms.text, 417 + ]}> 418 + {groupName} 419 + </Text> 420 + {showCreatedAt ? ( 421 + <Text 422 + style={[ 423 + a.text_sm, 424 + a.text_center, 425 + a.pt_xs, 426 + a.px_xl, 427 + t.atoms.text_contrast_high, 428 + ]}> 429 + <Trans>Created {dateFormatter.format(createdAt)}</Trans> 430 + </Text> 431 + ) : null} 432 + <View 433 + style={[ 434 + a.flex_row, 435 + a.align_center, 436 + a.justify_center, 437 + a.gap_2xl, 438 + a.pt_2xl, 439 + ]}> 440 + <SettingsButton 441 + color={convo.view.muted ? 'negative_subtle' : 'secondary'} 442 + icon={convo.view.muted ? BellOffIcon : BellIcon} 443 + label={ 444 + convo.view.muted 445 + ? l`Unmute this group chat` 446 + : l`Mute this group chat` 447 + } 448 + text={convo.view.muted ? l`Muted` : l`Mute`} 449 + onPress={handleToggleMute} 450 + /> 451 + {isOwner ? ( 452 + <SettingsButton 453 + icon={EditIcon} 454 + label={l`Edit this group chat’s name`} 455 + text={l`Edit name`} 456 + onPress={handlePromptName} 457 + /> 458 + ) : null} 459 + {isJoinLinkEnabled ? ( 460 + <SettingsButton 461 + icon={ChainLinkIcon} 462 + label={ 463 + isOwner 464 + ? l`Create or modify an invite link for this group chat` 465 + : l`View the invite link for this group chat` 466 + } 467 + text={l`Invite link`} 468 + onPress={inviteLinkDialog.open} 469 + /> 470 + ) : null} 471 + {canLockGroupChat ? ( 472 + <SettingsButton 473 + color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'} 474 + icon={LockIcon} 475 + label={ 476 + lockStatus === 'locked' 477 + ? l`Unlock this group chat` 478 + : l`Lock this group chat` 479 + } 480 + text={lockStatus === 'locked' ? l`Locked` : l`Lock`} 481 + onPress={ 482 + lockStatus === 'locked' ? handleUnlock : lockChatPrompt.open 483 + } 484 + /> 485 + ) : null} 486 + {isOwner ? null : isReportLinkEnabled ? ( 487 + <SettingsButton 488 + color="secondary" 489 + icon={FlagIcon} 490 + label={l`Report this group chat`} 491 + text={l`Report`} 492 + onPress={handleReportChat} 493 + /> 494 + ) : null} 495 + {isOwner ? null : ( 496 + <SettingsButton 497 + color="secondary" 498 + icon={ArrowBoxLeftIcon} 499 + label={l`Leave this group chat`} 500 + text={l`Leave`} 501 + onPress={leaveChatPrompt.open} 502 + /> 503 + )} 504 + </View> 505 + </View> 506 + <EditNamePrompt 507 + control={editNamePrompt} 508 + value={newGroupName} 509 + onChangeText={setNewGroupName} 510 + onConfirm={handleEditName} 511 + /> 512 + <InviteLinkDialog 513 + convo={convo} 514 + control={inviteLinkDialog} 515 + isOwner={isOwner} 516 + /> 517 + <LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> 518 + <LeaveChatPrompt 519 + control={leaveChatPrompt} 520 + groupName={groupName} 521 + onConfirm={leaveConvo} 522 + /> 523 + </> 524 + ) 525 + } 526 + 527 + function SettingsHeaderPlaceholder() { 528 + const t = useTheme() 529 + 530 + return ( 531 + <View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 532 + <View style={[a.align_center, a.justify_center]}> 533 + <AvatarBubbles profiles={[]} /> 534 + </View> 535 + <Text 536 + style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}> 537 + 538 + </Text> 539 + <Text 540 + style={[ 541 + a.text_sm, 542 + a.text_center, 543 + a.pt_xs, 544 + a.px_xl, 545 + t.atoms.text_contrast_high, 546 + ]}> 547 + 548 + </Text> 549 + <View 550 + style={[ 551 + a.flex_row, 552 + a.align_center, 553 + a.justify_center, 554 + a.gap_2xl, 555 + a.pt_2xl, 556 + ]}> 557 + <SettingsButtonPlaceholder /> 558 + <SettingsButtonPlaceholder /> 559 + <SettingsButtonPlaceholder /> 560 + <SettingsButtonPlaceholder /> 561 + </View> 562 + </View> 563 + ) 564 + } 565 + 566 + function SettingsButton({ 567 + color = 'secondary', 568 + disabled, 569 + icon, 570 + label, 571 + text, 572 + onPress, 573 + }: { 574 + color?: ButtonColor 575 + disabled?: boolean 576 + icon: React.ComponentType<SVGIconProps> 577 + label: string 578 + text: string 579 + onPress: () => void 580 + }) { 581 + const t = useTheme() 582 + 583 + return ( 584 + <View style={[a.align_center]}> 585 + <Button 586 + color={color} 587 + disabled={disabled} 588 + size="large" 589 + shape="round" 590 + label={label} 591 + onPress={onPress} 592 + style={[ 593 + { 594 + width: 48, 595 + height: 48, 596 + }, 597 + ]}> 598 + <ButtonIcon icon={icon} size="md" /> 599 + </Button> 600 + <Text 601 + numberOfLines={1} 602 + style={[ 603 + a.text_xs, 604 + a.font_medium, 605 + a.text_center, 606 + a.pt_xs, 607 + t.atoms.text_contrast_medium, 608 + ]}> 609 + {text} 610 + </Text> 611 + </View> 612 + ) 613 + } 614 + 615 + function SettingsButtonPlaceholder() { 616 + const t = useTheme() 617 + const {t: l} = useLingui() 618 + 619 + return ( 620 + <View style={[a.align_center]}> 621 + <Button color="secondary" size="large" shape="round" label={l`Loading…`}> 622 + <ButtonIcon icon={EllipsisIcon} size="md" /> 623 + </Button> 624 + <Text 625 + numberOfLines={1} 626 + style={[ 627 + a.text_xs, 628 + a.font_medium, 629 + a.text_center, 630 + a.pt_xs, 631 + t.atoms.text, 632 + ]}> 633 + 634 + </Text> 635 + </View> 636 + ) 637 + }
+119
src/screens/Messages/ConversationSettings/prompts.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans, useLingui} from '@lingui/react/macro' 3 + 4 + import {atoms as a} from '#/alf' 5 + import type * as Dialog from '#/components/Dialog' 6 + import * as TextField from '#/components/forms/TextField' 7 + import * as Prompt from '#/components/Prompt' 8 + 9 + export function EditNamePrompt({ 10 + control, 11 + value, 12 + onChangeText, 13 + onConfirm, 14 + }: { 15 + control: Dialog.DialogOuterProps['control'] 16 + value: string 17 + onChangeText: (value: string) => void 18 + onConfirm: () => void 19 + }) { 20 + const {t: l} = useLingui() 21 + 22 + return ( 23 + <Prompt.Outer control={control}> 24 + <> 25 + <Prompt.Content> 26 + <Prompt.TitleText> 27 + <Trans>Edit group name</Trans> 28 + </Prompt.TitleText> 29 + <View style={[a.my_sm]}> 30 + <TextField.Root isInvalid={false}> 31 + <TextField.Input 32 + label={l`Edit group name`} 33 + placeholder={l`Group name`} 34 + value={value} 35 + onChangeText={onChangeText} 36 + returnKeyType="done" 37 + autoCapitalize="none" 38 + autoComplete="off" 39 + autoCorrect={false} 40 + autoFocus 41 + onSubmitEditing={onConfirm} 42 + /> 43 + </TextField.Root> 44 + </View> 45 + </Prompt.Content> 46 + <Prompt.Actions> 47 + <Prompt.Action cta={l`Save`} onPress={onConfirm} /> 48 + <Prompt.Cancel /> 49 + </Prompt.Actions> 50 + </> 51 + </Prompt.Outer> 52 + ) 53 + } 54 + 55 + export function LockChatPrompt({ 56 + control, 57 + onConfirm, 58 + }: { 59 + control: Dialog.DialogOuterProps['control'] 60 + onConfirm: () => void 61 + }) { 62 + const {t: l} = useLingui() 63 + 64 + return ( 65 + <Prompt.Basic 66 + control={control} 67 + title={l`Lock group chat?`} 68 + description={l`Members can still read chat history but can’t send new messages.`} 69 + confirmButtonCta={l`Lock group chat`} 70 + cancelButtonCta={l`Cancel`} 71 + onConfirm={onConfirm} 72 + /> 73 + ) 74 + } 75 + 76 + export function LeaveChatPrompt({ 77 + control, 78 + groupName, 79 + onConfirm, 80 + }: { 81 + control: Dialog.DialogOuterProps['control'] 82 + groupName: string 83 + onConfirm: () => void 84 + }) { 85 + const {t: l} = useLingui() 86 + 87 + return ( 88 + <Prompt.Basic 89 + control={control} 90 + title={l`Are you sure you want to leave ${groupName}?`} 91 + description={l`You won’t be able to rejoin unless you’re invited.`} 92 + confirmButtonCta={l`Leave group chat`} 93 + confirmButtonColor="negative" 94 + cancelButtonCta={l`Cancel`} 95 + onConfirm={onConfirm} 96 + /> 97 + ) 98 + } 99 + 100 + export function BlockMemberPrompt({ 101 + control, 102 + onConfirm, 103 + }: { 104 + control: Dialog.DialogOuterProps['control'] 105 + onConfirm: () => void 106 + }) { 107 + const {t: l} = useLingui() 108 + 109 + return ( 110 + <Prompt.Basic 111 + control={control} 112 + title={l`Block account?`} 113 + description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 114 + onConfirm={onConfirm} 115 + confirmButtonCta={l`Block`} 116 + confirmButtonColor="negative" 117 + /> 118 + ) 119 + }
+4 -1
src/screens/Messages/components/ChatListItem.tsx
··· 298 298 299 299 // System message 300 300 if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { 301 - const info = getSystemMessageInfo(convo.lastMessage.data, convo.members) 301 + const info = getSystemMessageInfo( 302 + convo.lastMessage.data, 303 + new Map(convo.members.map(m => [m.did, m])), 304 + ) 302 305 if (info) { 303 306 lastMessage = i18n._(info.message) 304 307 lastMessageSentAt = convo.lastMessage.sentAt
+7 -9
src/screens/Messages/components/ChatStatusInfo.tsx
··· 5 5 6 6 import {type ActiveConvoStates} from '#/state/messages/convo' 7 7 import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 - import {useSession} from '#/state/session' 9 8 import {atoms as a, useTheme} from '#/alf' 10 9 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 11 10 import {KnownFollowers} from '#/components/KnownFollowers' ··· 16 15 const t = useTheme() 17 16 const {_} = useLingui() 18 17 const moderationOpts = useModerationOpts() 19 - const {currentAccount} = useSession() 20 18 const leaveConvoControl = usePromptControl() 21 19 22 20 const onAcceptChat = useCallback(() => { 23 21 convoState.markConvoAccepted() 24 22 }, [convoState]) 25 23 26 - const otherUser = convoState.recipients.find( 27 - user => user.did !== currentAccount?.did, 28 - ) 24 + // either the other person, or the chat owner 25 + // if we ever allow someone other than the owner to invite people, this will need to change 26 + const otherUser = convoState.convo.primaryMember 29 27 30 28 if (!moderationOpts) { 31 29 return null ··· 44 42 {otherUser && ( 45 43 <RejectMenu 46 44 label={_(msg`Block or report`)} 47 - convo={convoState.convo} 45 + convo={convoState.convo.view} 48 46 profile={otherUser} 49 47 color="negative_subtle" 50 48 size="small" ··· 53 51 )} 54 52 <DeleteChatButton 55 53 label={_(msg`Delete`)} 56 - convo={convoState.convo} 54 + convo={convoState.convo.view} 57 55 color="secondary" 58 56 size="small" 59 57 currentScreen="conversation" 60 58 onPress={leaveConvoControl.open} 61 59 /> 62 60 <LeaveConvoPrompt 63 - convoId={convoState.convo.id} 61 + convoId={convoState.convo.view.id} 64 62 control={leaveConvoControl} 65 63 currentScreen="conversation" 66 64 hasMessages={false} ··· 69 67 <View style={[a.w_full, a.flex_row]}> 70 68 <AcceptChatButton 71 69 onAcceptConvo={onAcceptChat} 72 - convo={convoState.convo} 70 + convo={convoState.convo.view} 73 71 color="primary_subtle" 74 72 size="small" 75 73 currentScreen="conversation"
+90
src/screens/Messages/components/CopyTextButton.tsx
··· 1 + import {useCallback, useEffect, useState} from 'react' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 + import Animated, { 4 + FadeOutUp, 5 + useReducedMotion, 6 + ZoomIn, 7 + } from 'react-native-reanimated' 8 + import * as Clipboard from 'expo-clipboard' 9 + import {Trans} from '@lingui/react/macro' 10 + 11 + import {atoms as a, useTheme} from '#/alf' 12 + import {Button, ButtonIcon, type ButtonProps} from '#/components/Button' 13 + import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' 14 + import {Text} from '#/components/Typography' 15 + 16 + export function CopyTextButton({ 17 + children, 18 + disabled, 19 + style, 20 + value, 21 + onPress: onPressProp, 22 + ...props 23 + }: ButtonProps & {value: string}) { 24 + const t = useTheme() 25 + 26 + const [hasBeenCopied, setHasBeenCopied] = useState(false) 27 + 28 + const isReducedMotionEnabled = useReducedMotion() 29 + 30 + useEffect(() => { 31 + if (hasBeenCopied) { 32 + const timeout = setTimeout( 33 + () => setHasBeenCopied(false), 34 + isReducedMotionEnabled ? 2000 : 100, 35 + ) 36 + return () => clearTimeout(timeout) 37 + } 38 + }, [hasBeenCopied, isReducedMotionEnabled]) 39 + 40 + const onPress = useCallback( 41 + (evt: GestureResponderEvent) => { 42 + void Clipboard.setStringAsync(value) 43 + setHasBeenCopied(true) 44 + onPressProp?.(evt) 45 + }, 46 + [value, onPressProp], 47 + ) 48 + 49 + return ( 50 + <View style={[a.relative]}> 51 + {hasBeenCopied && ( 52 + <Animated.View 53 + entering={ZoomIn.duration(100)} 54 + exiting={FadeOutUp.duration(2000)} 55 + style={[ 56 + a.absolute, 57 + {bottom: '100%', right: 0}, 58 + a.justify_center, 59 + a.gap_sm, 60 + a.z_10, 61 + a.pb_sm, 62 + ]} 63 + pointerEvents="none"> 64 + <Text 65 + style={[ 66 + a.font_medium, 67 + a.text_right, 68 + a.text_sm, 69 + t.atoms.text_contrast_high, 70 + ]}> 71 + <Trans>Copied!</Trans> 72 + </Text> 73 + </Animated.View> 74 + )} 75 + <Button 76 + color="secondary" 77 + disabled={disabled} 78 + style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]} 79 + onPress={onPress} 80 + {...props}> 81 + {context => ( 82 + <View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}> 83 + {typeof children === 'function' ? children(context) : children} 84 + {disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />} 85 + </View> 86 + )} 87 + </Button> 88 + </View> 89 + ) 90 + }
+59
src/screens/Messages/components/EditTextButton.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/react/macro' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Button, type ButtonProps} from '#/components/Button' 6 + import {Text} from '#/components/Typography' 7 + 8 + export function EditTextButton({ 9 + children, 10 + style, 11 + onPress, 12 + ...props 13 + }: ButtonProps & {value: string}) { 14 + const t = useTheme() 15 + 16 + return ( 17 + <View style={[a.relative]}> 18 + <Button 19 + color="secondary" 20 + style={[ 21 + a.flex_1, 22 + a.justify_between, 23 + a.rounded_full, 24 + a.border, 25 + t.atoms.bg, 26 + t.atoms.border_contrast_low, 27 + style, 28 + ]} 29 + onPress={onPress} 30 + {...props}> 31 + {context => ( 32 + <View 33 + style={[ 34 + a.flex_1, 35 + a.flex_row, 36 + a.align_center, 37 + a.justify_between, 38 + a.px_md, 39 + a.py_sm, 40 + ]}> 41 + {typeof children === 'function' ? children(context) : children} 42 + <View 43 + style={[ 44 + a.ml_sm, 45 + a.rounded_full, 46 + t.atoms.bg_contrast_50, 47 + {paddingHorizontal: 10, paddingVertical: 8}, 48 + ]}> 49 + <Text 50 + style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}> 51 + <Trans>Edit</Trans> 52 + </Text> 53 + </View> 54 + </View> 55 + )} 56 + </Button> 57 + </View> 58 + ) 59 + }
+462
src/screens/Messages/components/InviteLinkDialog.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + 5 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 6 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 7 + import {shareUrl} from '#/lib/sharing' 8 + import {useCreateJoinLink} from '#/state/queries/messages/create-join-link' 9 + import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link' 10 + import {useEditJoinLink} from '#/state/queries/messages/edit-join-link' 11 + import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link' 12 + import {atoms as a, useTheme, web} from '#/alf' 13 + import { 14 + Button, 15 + ButtonIcon, 16 + ButtonText, 17 + StackedButton, 18 + } from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {type ConvoWithDetails} from '#/components/dms/util' 21 + import * as Toggle from '#/components/forms/Toggle' 22 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 23 + import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' 24 + import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink' 25 + import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' 26 + import {Loader} from '#/components/Loader' 27 + import * as Toast from '#/components/Toast' 28 + import {Text} from '#/components/Typography' 29 + import {IS_WEB} from '#/env' 30 + import {CopyTextButton} from './CopyTextButton' 31 + import {EditTextButton} from './EditTextButton' 32 + 33 + enum Step { 34 + INFO, 35 + GENERATE, 36 + MANAGE, 37 + } 38 + 39 + const timeFormatter = new Intl.DateTimeFormat(undefined, { 40 + hour: 'numeric', 41 + minute: 'numeric', 42 + }) 43 + const dateFormatter = new Intl.DateTimeFormat(undefined, { 44 + month: 'long', 45 + day: 'numeric', 46 + year: 'numeric', 47 + }) 48 + 49 + export function InviteLinkDialog({ 50 + convo, 51 + control, 52 + isOwner, 53 + }: { 54 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 55 + control: Dialog.DialogOuterProps['control'] 56 + isOwner: boolean 57 + }) { 58 + const t = useTheme() 59 + const {t: l} = useLingui() 60 + 61 + const ownerName = createSanitizedDisplayName(convo.primaryMember) 62 + 63 + const {joinLink} = convo.details 64 + const enabledStatus = joinLink?.enabledStatus 65 + 66 + const defaultStep = joinLink ? Step.MANAGE : Step.INFO 67 + const defaultWhoCanJoin = joinLink 68 + ? [ 69 + `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`, 70 + ] 71 + : ['anyone'] 72 + 73 + const [step, setStep] = useState<Step>(defaultStep) 74 + const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin) 75 + 76 + const {openComposer} = useOpenComposer() 77 + 78 + const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink( 79 + convo.view.id, 80 + { 81 + onSuccess: () => { 82 + setStep(Step.MANAGE) 83 + }, 84 + onError: () => { 85 + Toast.show(l`Failed to create invite link`, { 86 + type: 'error', 87 + }) 88 + }, 89 + }, 90 + ) 91 + const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink( 92 + convo.view.id, 93 + { 94 + onSuccess: () => { 95 + setStep(Step.MANAGE) 96 + }, 97 + onError: () => { 98 + Toast.show(l`Failed to edit invite link`, { 99 + type: 'error', 100 + }) 101 + }, 102 + }, 103 + ) 104 + const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink( 105 + convo.view.id, 106 + { 107 + onError: () => { 108 + Toast.show(l`Failed to disable invite link`, { 109 + type: 'error', 110 + }) 111 + }, 112 + }, 113 + ) 114 + const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink( 115 + convo.view.id, 116 + { 117 + onError: () => { 118 + Toast.show(l`Failed to enable invite link`, { 119 + type: 'error', 120 + }) 121 + }, 122 + }, 123 + ) 124 + const isSaving = isCreating || isEditing 125 + 126 + const whoCanJoinOptions = [ 127 + { 128 + name: 'anyone', 129 + owner: l`Anyone can join instantly`, 130 + member: l`Anyone can join instantly`, 131 + }, 132 + { 133 + name: 'anyone:requireApproval', 134 + owner: l`Anyone can request to join`, 135 + member: l`Anyone can request to join`, 136 + }, 137 + { 138 + name: 'followedByOwner', 139 + owner: l`People I follow can join instantly`, 140 + member: l`People ${ownerName} follows can join instantly`, 141 + }, 142 + { 143 + name: 'followedByOwner:requireApproval', 144 + owner: l`People I follow can request to join`, 145 + member: l`People ${ownerName} follows can request to join`, 146 + }, 147 + ] 148 + 149 + let content: React.ReactNode = null 150 + let header: string | null = null 151 + switch (step) { 152 + case Step.INFO: 153 + header = l`Invite link` 154 + content = ( 155 + <> 156 + <View> 157 + <Text style={[a.text_md, t.atoms.text]}> 158 + <Trans> 159 + An invite link lets people join this group chat without being 160 + added directly. You control who can use the link and whether 161 + they need your approval. You can disable the link at any time. 162 + </Trans> 163 + </Text> 164 + <Text style={[a.mt_lg, a.text_md, t.atoms.text]}> 165 + <Trans> 166 + Your name, avatar, and the name of the group chat will be 167 + visible to everyone. 168 + </Trans> 169 + </Text> 170 + </View> 171 + <View style={[a.mt_4xl]}> 172 + <Button 173 + label={l`Get started`} 174 + color="primary" 175 + size="large" 176 + onPress={() => { 177 + setStep(Step.GENERATE) 178 + }}> 179 + <ButtonText> 180 + <Trans>Get started</Trans> 181 + </ButtonText> 182 + <ButtonIcon icon={ArrowRightIcon} /> 183 + </Button> 184 + </View> 185 + </> 186 + ) 187 + break 188 + case Step.GENERATE: 189 + header = l`Generate invite link` 190 + content = ( 191 + <> 192 + <View> 193 + <Text style={[a.text_md, t.atoms.text]}> 194 + <Trans>Choose who can join this group chat and how.</Trans> 195 + </Text> 196 + </View> 197 + <View style={[a.mt_lg]}> 198 + <Toggle.Group 199 + label={l`Who can join this group chat and how`} 200 + type="radio" 201 + values={whoCanJoin} 202 + onChange={setWhoCanJoin}> 203 + <View style={[a.gap_sm]}> 204 + {whoCanJoinOptions.map(option => ( 205 + <Toggle.Item 206 + key={option.name} 207 + highlightRow={true} 208 + label={isOwner ? option.owner : option.member} 209 + name={option.name} 210 + style={[a.flex_1]}> 211 + {({selected}) => ( 212 + <TargetOption 213 + label={isOwner ? option.owner : option.member} 214 + selected={selected} 215 + /> 216 + )} 217 + </Toggle.Item> 218 + ))} 219 + </View> 220 + </Toggle.Group> 221 + </View> 222 + <View style={[a.mt_4xl]}> 223 + <Button 224 + label={l`Generate invite link`} 225 + color="primary" 226 + size="large" 227 + disabled={isSaving} 228 + onPress={() => { 229 + const parts = whoCanJoin[0].split(':') 230 + const joinRule = parts[0] 231 + const requireApproval = parts[1] === 'requireApproval' 232 + if (joinLink && enabledStatus === 'enabled') { 233 + editJoinLink({ 234 + joinRule, 235 + requireApproval, 236 + }) 237 + } else { 238 + createJoinLink({ 239 + joinRule, 240 + requireApproval, 241 + }) 242 + } 243 + }}> 244 + <ButtonText> 245 + {joinLink && enabledStatus === 'enabled' 246 + ? l`Update invite link` 247 + : l`Generate invite link`} 248 + </ButtonText> 249 + <ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} /> 250 + </Button> 251 + </View> 252 + </> 253 + ) 254 + break 255 + case Step.MANAGE: { 256 + const hasJoinLinkCode = joinLink && joinLink.code !== '' 257 + const joinLinkURI = hasJoinLinkCode 258 + ? `https://bsky.app/chat/${joinLink.code}` 259 + : 'https://bsky.app/chat' 260 + const createdAt = joinLink ? new Date(joinLink.createdAt) : null 261 + const currentOption = whoCanJoinOptions.find( 262 + o => o.name === whoCanJoin[0], 263 + ) 264 + const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner 265 + const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member 266 + header = 267 + enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled` 268 + content = ( 269 + <> 270 + <View style={[a.mt_lg]}> 271 + <CopyTextButton 272 + disabled={enabledStatus === 'disabled' || !hasJoinLinkCode} 273 + label={l`Invite link`} 274 + value={joinLinkURI}> 275 + <Text 276 + numberOfLines={1} 277 + style={[ 278 + a.mr_xs, 279 + a.text_md, 280 + enabledStatus === 'disabled' 281 + ? t.atoms.text_contrast_low 282 + : t.atoms.text, 283 + ]}> 284 + {joinLinkURI} 285 + </Text> 286 + </CopyTextButton> 287 + {createdAt ? ( 288 + <Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}> 289 + <Trans> 290 + Created {timeFormatter.format(createdAt)}{' '} 291 + {dateFormatter.format(createdAt)} 292 + </Trans> 293 + </Text> 294 + ) : null} 295 + </View> 296 + {enabledStatus === 'enabled' ? ( 297 + <View style={[a.mt_lg]}> 298 + {isOwner ? ( 299 + <EditTextButton 300 + label={l`Edit link settings`} 301 + value={ownerValue} 302 + onPress={() => setStep(Step.GENERATE)}> 303 + <Text 304 + numberOfLines={1} 305 + style={[ 306 + a.mr_xs, 307 + a.text_md, 308 + t.atoms.text, 309 + {maxWidth: '80%'}, 310 + ]}> 311 + {ownerValue} 312 + </Text> 313 + </EditTextButton> 314 + ) : ( 315 + <Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text> 316 + )} 317 + </View> 318 + ) : null} 319 + {enabledStatus === 'enabled' ? ( 320 + <View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}> 321 + {isOwner ? ( 322 + <StackedButton 323 + label={l`Disable`} 324 + icon={isDisabling ? Loader : ChainLinkBrokenIcon} 325 + color="negative_subtle" 326 + style={[a.flex_1, a.rounded_full]} 327 + disabled={isDisabling} 328 + onPress={() => { 329 + disableJoinLink() 330 + }}> 331 + <Trans>Disable</Trans> 332 + </StackedButton> 333 + ) : null} 334 + <StackedButton 335 + disabled={enabledStatus === 'disabled'} 336 + label={l`Post link`} 337 + icon={EditIcon} 338 + color="primary_subtle" 339 + style={[a.flex_1, a.rounded_full]} 340 + onPress={() => { 341 + control.close(() => { 342 + openComposer({ 343 + text: joinLinkURI, 344 + logContext: 'Other', 345 + }) 346 + }) 347 + }}> 348 + <Trans>Post link</Trans> 349 + </StackedButton> 350 + <StackedButton 351 + disabled={enabledStatus === 'disabled'} 352 + label={l`Share`} 353 + icon={ArrowShareRightIcon} 354 + color="primary_subtle" 355 + style={[a.flex_1, a.rounded_full]} 356 + onPress={() => { 357 + void shareUrl(joinLinkURI) 358 + }}> 359 + <Trans>Share</Trans> 360 + </StackedButton> 361 + </View> 362 + ) : ( 363 + <View style={[a.gap_md, a.mt_lg]}> 364 + <Button 365 + label={l`Re-enable invite link`} 366 + color="primary" 367 + size="large" 368 + disabled={isEnabling} 369 + onPress={() => { 370 + enableJoinLink() 371 + }}> 372 + <ButtonText> 373 + <Trans>Re-enable link</Trans> 374 + </ButtonText> 375 + {isEnabling && <ButtonIcon icon={Loader} />} 376 + </Button> 377 + <Button 378 + label={l`Generate new invite link`} 379 + color="secondary" 380 + size="large" 381 + onPress={() => setStep(Step.GENERATE)}> 382 + <ButtonText> 383 + <Trans>Generate new link</Trans> 384 + </ButtonText> 385 + </Button> 386 + </View> 387 + )} 388 + </> 389 + ) 390 + break 391 + } 392 + } 393 + 394 + if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) { 395 + header = l`Invite link` 396 + content = ( 397 + <> 398 + <View style={[a.mt_lg]}> 399 + <Text style={[a.text_sm, t.atoms.text]}> 400 + <Trans>There is no invite link for this group chat.</Trans> 401 + </Text> 402 + </View> 403 + <View style={[a.gap_md, a.mt_lg]}> 404 + <Button 405 + label={l`Close`} 406 + color="primary" 407 + size="large" 408 + onPress={() => control.close()}> 409 + <ButtonText> 410 + <Trans>Close</Trans> 411 + </ButtonText> 412 + </Button> 413 + </View> 414 + </> 415 + ) 416 + } 417 + 418 + return ( 419 + <Dialog.Outer 420 + control={control} 421 + onClose={() => { 422 + setStep(defaultStep) 423 + setWhoCanJoin(defaultWhoCanJoin) 424 + }}> 425 + <Dialog.Handle /> 426 + <Dialog.ScrollableInner 427 + header={ 428 + <View> 429 + <View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}> 430 + <Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}> 431 + {header} 432 + </Text> 433 + </View> 434 + <Dialog.Close /> 435 + </View> 436 + } 437 + label={l`Group chat invite link dialog`} 438 + style={web({maxWidth: 400})}> 439 + {content} 440 + </Dialog.ScrollableInner> 441 + </Dialog.Outer> 442 + ) 443 + } 444 + 445 + function TargetOption({label, selected}: {label: string; selected: boolean}) { 446 + const t = useTheme() 447 + 448 + return ( 449 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 450 + <Toggle.Radio /> 451 + <Toggle.LabelText 452 + style={[ 453 + a.font_normal, 454 + a.flex_1, 455 + a.leading_tight, 456 + selected ? t.atoms.text : t.atoms.text_contrast_high, 457 + ]}> 458 + {label} 459 + </Toggle.LabelText> 460 + </View> 461 + ) 462 + }
+7 -11
src/screens/Messages/components/MessagesList.tsx
··· 250 250 ) 251 251 252 252 const onStartReached = useCallback(() => { 253 - if (hasScrolled && prevContentHeight.current > layoutHeight.get()) { 254 - void convoState.fetchMessageHistory() 255 - } 256 - }, [convoState, hasScrolled, layoutHeight]) 253 + void convoState.fetchMessageHistory() 254 + }, [convoState]) 257 255 258 256 const onScroll = useCallback( 259 257 (e: ScrollEvent) => { ··· 376 374 return ( 377 375 <MessageItem 378 376 item={item} 379 - profile={convoState.convo.members.find( 380 - member => member.did === item.message.sender.did, 381 - )} 382 - isGroupChat={convoState.isGroup()} 377 + isGroupChat={convoState.convo.kind === 'group'} 383 378 /> 384 379 ) 385 380 } else if (item.type === 'deleted-message') { ··· 448 443 ListHeaderComponent={ 449 444 <> 450 445 <MaybeLoader isLoading={convoState.isFetchingHistory} /> 451 - {convoState.isGroup() && convoState.hasAllHistory ? ( 452 - <MessagesListInfoPanel convoState={convoState} /> 446 + {convoState.convo?.kind === 'group' && 447 + convoState.hasAllHistory ? ( 448 + <MessagesListInfoPanel convo={convoState.convo} /> 453 449 ) : null} 454 450 </> 455 451 } ··· 577 573 } 578 574 } 579 575 580 - if (convoState.convo.status === 'request' && !hasAcceptOverride) { 576 + if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { 581 577 return 'request' 582 578 } 583 579
+56 -29
src/screens/Messages/components/MessagesListInfoPanel.tsx
··· 1 1 import {View} from 'react-native' 2 2 import {Plural, Trans, useLingui} from '@lingui/react/macro' 3 3 4 - import {type ConvoState} from '#/state/messages/convo/types' 4 + import {logger} from '#/logger' 5 + import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' 5 6 import {useSession} from '#/state/session' 6 7 import {atoms as a, useTheme} from '#/alf' 7 8 import {AvatarBubbles} from '#/components/AvatarBubbles' 8 9 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 10 import * as Dialog from '#/components/Dialog' 10 11 import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 12 + import {type ConvoWithDetails} from '#/components/dms/util' 11 13 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 12 14 import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 15 + import * as Toast from '#/components/Toast' 13 16 import {Text} from '#/components/Typography' 17 + import {InviteLinkDialog} from './InviteLinkDialog' 14 18 15 - export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { 19 + export function MessagesListInfoPanel({ 20 + convo, 21 + }: { 22 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 23 + }) { 16 24 const t = useTheme() 17 25 const {t: l} = useLingui() 18 26 19 27 const addMembersControl = Dialog.useDialogControl() 28 + const inviteLinkControl = Dialog.useDialogControl() 20 29 21 30 const {currentAccount} = useSession() 22 31 23 - const isOwner = 24 - currentAccount?.did == null 25 - ? false 26 - : convoState.getPrimaryMember?.()?.did === currentAccount.did 27 - // TODO Get this from @api/atproto - dsb 28 - const isLinkEnabled = false 32 + const convoId = convo.view.id 33 + const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { 34 + onSuccess: () => { 35 + addMembersControl.close() 36 + }, 37 + onError: e => { 38 + logger.error('Failed to add group chat members', {message: e}) 39 + Toast.show(l`Failed to add members`, {type: 'error'}) 40 + }, 41 + }) 42 + 43 + // TODO Enable this once the feature is working end-to-end. -dsb 44 + // const joinLink = groupConvo?.details.joinLink 45 + const isJoinLinkEnabled = false 46 + // (isOwner && groupConvo) || 47 + // (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled') 29 48 30 - const groupName = convoState.getGroupInfo?.()?.name 49 + const isOwner = convo?.primaryMember.did === currentAccount?.did 31 50 32 - const members = (convoState?.convo?.members ?? []).filter( 51 + const members = (convo?.members ?? []).filter( 33 52 profile => profile.did !== currentAccount?.did, 34 53 ) 35 54 36 - let names: React.ReactNode | null = null 55 + let names: React.ReactNode = null 37 56 if (members.length === 1) { 38 57 names = <Trans>New chat with {members[0].displayName}</Trans> 39 - } 40 - if (members.length === 2) { 58 + } else if (members.length === 2) { 41 59 names = ( 42 60 <Trans> 43 61 New chat with {members[0].displayName} and {members[1].displayName} 44 62 </Trans> 45 63 ) 46 - } 47 - if (members.length > 2) { 64 + } else if (members.length > 2) { 65 + const memberCount = convo.details.memberCount - 2 48 66 names = ( 49 67 <Trans> 50 68 New chat with {members[0].displayName}, {members[1].displayName}, and{' '} 51 69 <Plural 52 - value={members.length - 2} 53 - one={`${members.length - 2} more`} 54 - other={`${members.length - 2} more`} 70 + value={memberCount} 71 + one={`${memberCount} more`} 72 + other={`${memberCount} more`} 55 73 /> 56 74 . 57 75 </Trans> 58 76 ) 59 77 } 60 78 61 - const showButtons = isOwner || isLinkEnabled 79 + const showButtons = isOwner || isJoinLinkEnabled 62 80 63 81 return ( 64 82 <> 65 83 <View style={[a.align_center, a.justify_center]}> 66 - <AvatarBubbles animate={true} profiles={members} /> 67 - {groupName ? ( 84 + <AvatarBubbles animate={true} profiles={convo?.members} /> 85 + {convo.details.name ? ( 68 86 <Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}> 69 - {groupName} 87 + {convo.details.name} 70 88 </Text> 71 89 ) : null} 72 90 {names ? ( ··· 102 120 </ButtonText> 103 121 </Button> 104 122 ) : null} 105 - {isOwner || isLinkEnabled ? ( 123 + {isJoinLinkEnabled ? ( 106 124 <Button 107 125 color="secondary" 108 126 size="small" 109 - label={l`Click here to view or create an invite link for this group chat`} 110 - onPress={() => {}}> 127 + label={ 128 + isOwner 129 + ? l`Click here to create or manage an invite link for this group chat` 130 + : l`Click here to view the invite link for this group chat` 131 + } 132 + onPress={inviteLinkControl.open}> 111 133 <ButtonIcon icon={ChainLinkIcon} /> 112 134 <ButtonText> 113 135 <Trans>Invite link</Trans> ··· 117 139 </View> 118 140 ) : null} 119 141 </View> 142 + <InviteLinkDialog 143 + isOwner={isOwner} 144 + convo={convo} 145 + control={inviteLinkControl} 146 + /> 120 147 <Dialog.Outer 121 148 control={addMembersControl} 122 149 testID="addChatMembersDialog" 123 150 nativeOptions={{fullHeight: true}}> 124 151 <Dialog.Handle /> 125 152 <AddMembersFlow 153 + members={members.map(profile => profile.did)} 126 154 title={l`Add people`} 127 - onAddMembers={(_dids: string[]) => { 128 - // TODO Add members here 129 - addMembersControl.close() 130 - }} 155 + onAddMembers={(members, profiles) => 156 + addGroupMembers({members, profiles}) 157 + } 131 158 /> 132 159 </Dialog.Outer> 133 160 </>
+2
src/state/cache/profile-shadow.ts
··· 11 11 import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' 12 12 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' 13 13 import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' 14 + import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members' 14 15 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' 15 16 import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' 16 17 import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' ··· 264 265 yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) 265 266 yield* findAllProfilesInNotifsQueryData(queryClient, did) 266 267 yield* findAllProfilesInContactMatchesQueryData(queryClient, did) 268 + yield* findAllProfilesInMessagesQueryData(queryClient, did) 267 269 }
+224 -192
src/state/messages/convo/agent.ts
··· 1 1 import { 2 2 type AtpAgent, 3 - ChatBskyActorDefs, 3 + type ChatBskyActorDefs, 4 4 ChatBskyConvoDefs, 5 5 type ChatBskyConvoGetLog, 6 6 type ChatBskyConvoSendMessage, 7 + type ChatBskyGroupDefs, 7 8 } from '@atproto/api' 8 9 import {XRPCError} from '@atproto/api' 9 10 import {EventEmitter} from 'eventemitter3' ··· 36 37 } from '#/state/messages/convo/types' 37 38 import {type MessagesEventBus} from '#/state/messages/events/agent' 38 39 import {type MessagesEventBusError} from '#/state/messages/events/types' 40 + import { 41 + type ConvoWithDetails, 42 + type GroupConvoMember, 43 + parseConvoView, 44 + } from '#/components/dms/util' 39 45 import {IS_NATIVE} from '#/env' 40 - import * as bsky from '#/types/bsky' 41 46 42 47 const logger = Logger.create(Logger.Context.ConversationAgent) 43 48 ··· 102 107 {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} 103 108 > = new Map() 104 109 private deletedMessages: Set<string> = new Set() 105 - private systemMessageProfiles: Map< 106 - string, 107 - ChatBskyActorDefs.ProfileViewBasic 108 - > = new Map() 110 + private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> = 111 + new Map() 109 112 110 113 private isProcessingPendingMessages = false 111 114 ··· 114 117 private emitter = new EventEmitter<{event: [ConvoEvent]}>() 115 118 116 119 convoId: string 117 - convo: ChatBskyConvoDefs.ConvoView | undefined 120 + convo: ConvoWithDetails | undefined 118 121 sender: ChatBskyActorDefs.ProfileViewBasic | undefined 119 122 recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined 120 123 snapshot: ConvoState | undefined ··· 130 133 this.setupPlaceholderData(params.placeholderData) 131 134 } 132 135 136 + this.setConvo = this.setConvo.bind(this) 133 137 this.subscribe = this.subscribe.bind(this) 134 138 this.getSnapshot = this.getSnapshot.bind(this) 135 139 this.sendMessage = this.sendMessage.bind(this) ··· 141 145 this.markConvoAccepted = this.markConvoAccepted.bind(this) 142 146 this.addReaction = this.addReaction.bind(this) 143 147 this.removeReaction = this.removeReaction.bind(this) 144 - this.isGroup = this.isGroup.bind(this) 145 - this.getGroupInfo = this.getGroupInfo.bind(this) 146 - this.getPrimaryMember = this.getPrimaryMember.bind(this) 147 148 this.updateGroupName = this.updateGroupName.bind(this) 149 + this.updateGroupMembers = this.updateGroupMembers.bind(this) 150 + this.updateJoinLink = this.updateJoinLink.bind(this) 151 + this.updateLockStatus = this.updateLockStatus.bind(this) 148 152 } 149 153 150 154 private commit() { ··· 172 176 } 173 177 174 178 private generateSnapshot(): ConvoState { 179 + const shared = { 180 + isFetchingHistory: this.isFetchingHistory, 181 + // Explicit null check since the value is initially undefined. 182 + hasAllHistory: this.oldestRev === null, 183 + } 184 + 185 + const methods = { 186 + deleteMessage: this.deleteMessage, 187 + sendMessage: this.sendMessage, 188 + fetchMessageHistory: this.fetchMessageHistory, 189 + markConvoAccepted: this.markConvoAccepted, 190 + addReaction: this.addReaction, 191 + removeReaction: this.removeReaction, 192 + } 193 + 194 + const emptyMethods = { 195 + deleteMessage: undefined, 196 + sendMessage: undefined, 197 + fetchMessageHistory: undefined, 198 + markConvoAccepted: undefined, 199 + addReaction: undefined, 200 + removeReaction: undefined, 201 + } 202 + 175 203 switch (this.status) { 176 204 case ConvoStatus.Initializing: { 177 205 return { ··· 179 207 items: [], 180 208 convo: this.convo, 181 209 error: undefined, 182 - sender: this.sender, 183 - recipients: this.recipients, 184 - isFetchingHistory: this.isFetchingHistory, 185 - // Explicit null check since the value is initially undefined. 186 - hasAllHistory: this.oldestRev === null, 187 - deleteMessage: undefined, 188 - sendMessage: undefined, 189 - fetchMessageHistory: undefined, 190 - markConvoAccepted: undefined, 191 - addReaction: undefined, 192 - removeReaction: undefined, 193 - isGroup: this.isGroup, 194 - getGroupInfo: this.getGroupInfo, 195 - getPrimaryMember: this.getPrimaryMember, 210 + ...shared, 211 + ...emptyMethods, 196 212 } 197 213 } 198 - case ConvoStatus.Disabled: 199 - case ConvoStatus.Suspended: 200 - case ConvoStatus.Backgrounded: 214 + case ConvoStatus.Disabled: { 215 + return { 216 + status: this.status, 217 + items: this.getItems(), 218 + convo: this.convo!, 219 + error: undefined, 220 + ...shared, 221 + ...methods, 222 + } 223 + } 224 + case ConvoStatus.Suspended: { 225 + return { 226 + status: this.status, 227 + items: this.getItems(), 228 + convo: this.convo!, 229 + error: undefined, 230 + ...shared, 231 + ...methods, 232 + } 233 + } 234 + case ConvoStatus.Backgrounded: { 235 + return { 236 + status: this.status, 237 + items: this.getItems(), 238 + convo: this.convo!, 239 + error: undefined, 240 + ...shared, 241 + ...methods, 242 + } 243 + } 201 244 case ConvoStatus.Ready: { 202 245 return { 203 246 status: this.status, 204 247 items: this.getItems(), 205 248 convo: this.convo!, 206 249 error: undefined, 207 - sender: this.sender!, 208 - recipients: this.recipients!, 209 - isFetchingHistory: this.isFetchingHistory, 210 - // Explicit null check since the value is initially undefined. 211 - hasAllHistory: this.oldestRev === null, 212 - deleteMessage: this.deleteMessage, 213 - sendMessage: this.sendMessage, 214 - fetchMessageHistory: this.fetchMessageHistory, 215 - markConvoAccepted: this.markConvoAccepted, 216 - addReaction: this.addReaction, 217 - removeReaction: this.removeReaction, 218 - isGroup: this.isGroup, 219 - getGroupInfo: this.getGroupInfo, 220 - getPrimaryMember: this.getPrimaryMember, 250 + ...shared, 251 + ...methods, 221 252 } 222 253 } 223 254 case ConvoStatus.Error: { ··· 226 257 items: [], 227 258 convo: undefined, 228 259 error: this.error!, 229 - sender: undefined, 230 - recipients: undefined, 231 260 isFetchingHistory: false, 232 261 hasAllHistory: false, 233 - deleteMessage: undefined, 234 - sendMessage: undefined, 235 - fetchMessageHistory: undefined, 236 - markConvoAccepted: undefined, 237 - addReaction: undefined, 238 - removeReaction: undefined, 239 - isGroup: undefined, 240 - getGroupInfo: undefined, 241 - getPrimaryMember: undefined, 262 + ...emptyMethods, 242 263 } 243 264 } 244 265 default: { ··· 247 268 items: [], 248 269 convo: this.convo, 249 270 error: undefined, 250 - sender: this.sender, 251 - recipients: this.recipients, 252 271 isFetchingHistory: false, 253 272 // Explicit null check since the value is initially undefined. 254 273 hasAllHistory: this.oldestRev === null, 255 - deleteMessage: undefined, 256 - sendMessage: undefined, 257 - fetchMessageHistory: undefined, 258 - markConvoAccepted: undefined, 259 - addReaction: undefined, 260 - removeReaction: undefined, 261 - isGroup: this.isGroup, 262 - getGroupInfo: this.getGroupInfo, 263 - getPrimaryMember: this.getPrimaryMember, 274 + ...emptyMethods, 264 275 } 265 276 } 266 277 } ··· 460 471 461 472 private reset() { 462 473 this.convo = undefined 463 - this.sender = undefined 464 - this.recipients = undefined 465 474 this.snapshot = undefined 466 475 467 476 this.status = ConvoStatus.Uninitialized ··· 473 482 this.newMessages = new Map() 474 483 this.pendingMessages = new Map() 475 484 this.deletedMessages = new Set() 476 - this.systemMessageProfiles = new Map() 485 + this.relatedProfiles = new Map() 477 486 478 487 this.pendingMessageFailure = null 479 488 this.fetchMessageHistoryError = undefined ··· 498 507 } 499 508 } 500 509 510 + private setConvo(convo: ChatBskyConvoDefs.ConvoView) { 511 + this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo 512 + if (this.convo) { 513 + for (const member of this.convo.members) { 514 + this.relatedProfiles.set(member.did, member) 515 + } 516 + } 517 + } 518 + 519 + private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) { 520 + if (this.convo) { 521 + this.convo = 522 + parseConvoView({...this.convo.view, ...convo}, this.senderUserDid) ?? 523 + this.convo 524 + for (const member of this.convo.members) { 525 + this.relatedProfiles.set(member.did, member) 526 + } 527 + } 528 + } 529 + 501 530 /** 502 531 * Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo, 503 532 * but this allows us to render the convo header immediately. ··· 505 534 private setupPlaceholderData( 506 535 data: NonNullable<ConvoParams['placeholderData']>, 507 536 ) { 508 - this.convo = data.convo 509 - this.sender = data.convo.members.find(m => m.did === this.senderUserDid) 510 - this.recipients = data.convo.members.filter( 511 - m => m.did !== this.senderUserDid, 512 - ) 537 + this.setConvo(data.convo) 513 538 } 514 539 515 540 private async setup() { 516 541 try { 517 - const {convo, sender, recipients} = await this.fetchConvo() 542 + const {convo} = await this.fetchConvo() 518 543 519 - this.convo = convo 520 - this.sender = sender 521 - this.recipients = recipients 544 + this.setConvo(convo) 522 545 523 546 /* 524 547 * Some validation prior to `Ready` status ··· 526 549 if (!this.convo) { 527 550 throw new Error('could not find convo') 528 551 } 529 - if (!this.sender) { 530 - throw new Error('could not find sender in convo') 531 - } 532 - if (!this.recipients) { 533 - throw new Error('could not find recipients in convo') 552 + 553 + const self = this.convo.members.find(m => m.did === this.senderUserDid) 554 + 555 + if (!self) { 556 + throw new Error('could not find self in convo') 534 557 } 535 558 536 - const userIsDisabled = Boolean(this.sender.chatDisabled) 559 + const userIsDisabled = Boolean(self.chatDisabled) 537 560 538 561 if (userIsDisabled) { 539 562 this.dispatch({event: ConvoDispatchEvent.Disable}) ··· 602 625 } 603 626 604 627 private pendingFetchConvo: 605 - | Promise<{ 606 - convo: ChatBskyConvoDefs.ConvoView 607 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 608 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 609 - }> 628 + | Promise<{convo: ChatBskyConvoDefs.ConvoView}> 610 629 | undefined 611 630 async fetchConvo() { 612 631 if (this.pendingFetchConvo) return this.pendingFetchConvo 613 632 633 + // non-blocking 634 + void this.fetchMemberList() 635 + 614 636 this.pendingFetchConvo = (async () => { 615 637 try { 616 638 const response = await networkRetry(2, () => { 617 - return this.agent.api.chat.bsky.convo.getConvo( 618 - { 619 - convoId: this.convoId, 620 - }, 639 + return this.agent.chat.bsky.convo.getConvo( 640 + {convoId: this.convoId}, 621 641 {headers: DM_SERVICE_HEADERS}, 622 642 ) 623 643 }) ··· 626 646 627 647 return { 628 648 convo, 629 - sender: convo.members.find(m => m.did === this.senderUserDid), 630 - recipients: convo.members.filter(m => m.did !== this.senderUserDid), 631 649 } 632 650 } finally { 633 651 this.pendingFetchConvo = undefined ··· 639 657 640 658 async refreshConvo() { 641 659 try { 642 - const {convo, sender, recipients} = await this.fetchConvo() 660 + void this.fetchMemberList() 661 + const {convo} = await this.fetchConvo() 643 662 // throw new Error('UNCOMMENT TO TEST REFRESH FAILURE') 644 - this.convo = convo || this.convo 645 - this.sender = sender || this.sender 646 - this.recipients = recipients || this.recipients 663 + this.setConvo(convo) 647 664 } catch (err) { 648 665 const e = err as Error 649 666 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { ··· 654 671 } 655 672 } 656 673 657 - private fetchMessageHistoryError: 658 - | { 659 - retry: () => void 674 + // purely for populating `this.relatedProfiles` - we do not pipe it 675 + // into the ConvoWithDetails. If you want to drive UI based on the member list, 676 + // use `useListConvoMembersQuery` 677 + // we shouldn't also block loading off of this - the UI should be resilient 678 + async fetchMemberList() { 679 + let cursor: string | undefined 680 + do { 681 + const result = await networkRetry(2, () => { 682 + return this.agent.chat.bsky.convo.getConvoMembers( 683 + { 684 + convoId: this.convoId, 685 + limit: 50, 686 + cursor, 687 + }, 688 + {headers: DM_SERVICE_HEADERS}, 689 + ) 690 + }) 691 + cursor = result.data.cursor 692 + 693 + for (const member of result.data.members) { 694 + this.relatedProfiles.set(member.did, member) 660 695 } 661 - | undefined 696 + } while (cursor) 697 + } 698 + 699 + private fetchMessageHistoryError: {retry: () => void} | undefined 662 700 async fetchMessageHistory() { 663 701 logger.debug('fetch message history', {}) 664 702 ··· 700 738 701 739 if (relatedProfiles) { 702 740 for (const profile of relatedProfiles) { 703 - this.systemMessageProfiles.set(profile.did, profile) 741 + this.relatedProfiles.set(profile.did, profile) 704 742 } 705 743 } 706 744 ··· 820 858 */ 821 859 this.latestRev = ev.rev 822 860 861 + if ('relatedProfiles' in ev && Array.isArray(ev.relatedProfiles)) { 862 + for (const profile of ev.relatedProfiles) { 863 + this.relatedProfiles.set(profile.did, profile) 864 + } 865 + } 866 + 823 867 if ( 824 868 ChatBskyConvoDefs.isLogCreateMessage(ev) && 825 869 ChatBskyConvoDefs.isMessageView(ev.message) ··· 872 916 const systemView = toSystemMessageView(ev) 873 917 if (systemView) { 874 918 this.newMessages.set(systemView.id, systemView) 875 - if ( 876 - 'relatedProfiles' in ev && 877 - Array.isArray(ev.relatedProfiles) 878 - ) { 879 - for (const profile of ev.relatedProfiles) { 880 - this.systemMessageProfiles.set(profile.did, profile) 881 - } 882 - } 883 919 needsCommit = true 884 920 } 885 921 } ··· 907 943 id: tempId, 908 944 message, 909 945 }) 910 - if (this.convo?.status === 'request') { 911 - this.convo = { 912 - ...this.convo, 946 + if (this.convo?.view.status === 'request') { 947 + this.updateConvo({ 913 948 status: 'accepted', 914 - } 949 + }) 915 950 } 916 951 this.commit() 917 952 ··· 921 956 } 922 957 923 958 markConvoAccepted() { 924 - if (this.convo) { 925 - this.convo = { 926 - ...this.convo, 927 - status: 'accepted', 928 - } 929 - } 959 + this.updateConvo({ 960 + status: 'accepted', 961 + }) 962 + 930 963 this.commit() 931 964 } 932 965 933 966 updateMuted(muted: boolean) { 934 - if (this.convo) { 935 - this.convo = { 936 - ...this.convo, 937 - muted, 938 - } 939 - } 967 + this.updateConvo({ 968 + muted, 969 + }) 970 + 940 971 this.commit() 941 972 } 942 973 943 974 updateGroupName(name: string) { 944 - if ( 945 - this.convo && 946 - bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 947 - this.convo.kind, 948 - ChatBskyConvoDefs.isGroupConvo, 949 - ) 950 - ) { 951 - this.convo = { 952 - ...this.convo, 953 - kind: { 954 - ...this.convo.kind, 955 - name, 956 - }, 957 - } 975 + if (this.convo?.kind !== 'group') { 976 + throw new Error('updateGroupName can only be called on group convo') 958 977 } 978 + 979 + this.updateConvo({ 980 + kind: { 981 + ...this.convo.details, 982 + name, 983 + }, 984 + }) 985 + 986 + this.commit() 987 + } 988 + 989 + updateGroupMembers(members: GroupConvoMember[], memberCount: number) { 990 + if (this.convo?.kind !== 'group') { 991 + throw new Error('updateGroupMembers can only be called on group convo') 992 + } 993 + 994 + this.updateConvo({ 995 + members, 996 + kind: { 997 + ...this.convo.details, 998 + memberCount, 999 + }, 1000 + }) 1001 + 1002 + this.commit() 1003 + } 1004 + 1005 + updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) { 1006 + if (this.convo?.kind !== 'group') { 1007 + throw new Error('updateJoinLink can only be called on group convo') 1008 + } 1009 + 1010 + this.updateConvo({ 1011 + kind: { 1012 + ...this.convo.details, 1013 + joinLink, 1014 + }, 1015 + }) 1016 + 1017 + this.commit() 1018 + } 1019 + 1020 + updateLockStatus(lockStatus: ChatBskyConvoDefs.ConvoLockStatus) { 1021 + if (this.convo?.kind !== 'group') { 1022 + throw new Error('updateLockStatus can only be called on group convo') 1023 + } 1024 + 1025 + this.updateConvo({ 1026 + kind: { 1027 + ...this.convo.details, 1028 + lockStatus, 1029 + }, 1030 + }) 1031 + 959 1032 this.commit() 960 1033 } 961 1034 ··· 980 1053 981 1054 const {id, message} = pendingMessage 982 1055 983 - const response = await this.agent.api.chat.bsky.convo.sendMessage( 1056 + const response = await this.agent.chat.bsky.convo.sendMessage( 984 1057 { 985 1058 convoId: this.convoId, 986 1059 message, ··· 1023 1096 this.emitter.emit('event', { 1024 1097 type: 'invalidate-block-state', 1025 1098 accountDids: [ 1026 - this.sender!.did, 1099 + this.senderUserDid, 1027 1100 ...this.recipients!.map(r => r.did), 1028 1101 ], 1029 1102 }) ··· 1075 1148 ) 1076 1149 1077 1150 try { 1078 - const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch( 1151 + const {data} = await this.agent.chat.bsky.convo.sendMessageBatch( 1079 1152 { 1080 1153 items: messageArray.map(({message}) => ({ 1081 1154 convoId: this.convoId, ··· 1117 1190 1118 1191 try { 1119 1192 await networkRetry(2, () => { 1120 - return this.agent.api.chat.bsky.convo.deleteMessageForSelf( 1193 + return this.agent.chat.bsky.convo.deleteMessageForSelf( 1121 1194 { 1122 1195 convoId: this.convoId, 1123 1196 messageId, ··· 1158 1231 type: 'message', 1159 1232 key: m.id, 1160 1233 message: m, 1234 + relatedProfiles: this.relatedProfiles, 1161 1235 nextMessage: null, 1162 1236 prevMessage: null, 1163 1237 }) ··· 1166 1240 type: 'deleted-message', 1167 1241 key: m.id, 1168 1242 message: m, 1243 + relatedProfiles: this.relatedProfiles, 1169 1244 nextMessage: null, 1170 1245 prevMessage: null, 1171 1246 }) ··· 1174 1249 type: 'system-message', 1175 1250 key: m.id, 1176 1251 message: m, 1177 - relatedProfiles: Array.from(this.systemMessageProfiles.values()), 1252 + relatedProfiles: this.relatedProfiles, 1178 1253 }) 1179 1254 } 1180 1255 }) ··· 1196 1271 type: 'message', 1197 1272 key: m.id, 1198 1273 message: m, 1274 + relatedProfiles: this.relatedProfiles, 1199 1275 nextMessage: null, 1200 1276 prevMessage: null, 1201 1277 }) ··· 1204 1280 type: 'deleted-message', 1205 1281 key: m.id, 1206 1282 message: m, 1283 + relatedProfiles: this.relatedProfiles, 1207 1284 nextMessage: null, 1208 1285 prevMessage: null, 1209 1286 }) ··· 1212 1289 type: 'system-message', 1213 1290 key: m.id, 1214 1291 message: m, 1215 - relatedProfiles: Array.from(this.systemMessageProfiles.values()), 1292 + relatedProfiles: this.relatedProfiles, 1216 1293 }) 1217 1294 } 1218 1295 }) ··· 1228 1305 id: nanoid(), 1229 1306 rev: '__fake__', 1230 1307 sentAt: new Date().toISOString(), 1231 - /* 1232 - * `getItems` is only run in "active" status states, where 1233 - * `this.sender` is defined 1234 - */ 1235 1308 sender: { 1236 1309 $type: 'chat.bsky.convo.defs#messageViewSender', 1237 - did: this.sender!.did, 1310 + did: this.senderUserDid, 1238 1311 }, 1239 1312 }, 1313 + relatedProfiles: this.relatedProfiles, 1240 1314 nextMessage: null, 1241 1315 prevMessage: null, 1242 1316 failed: this.pendingMessageFailure !== null, ··· 1446 1520 } catch (error) { 1447 1521 if (restore) restore() 1448 1522 throw error 1449 - } 1450 - } 1451 - 1452 - // Group utilities 1453 - 1454 - isGroup(): boolean | undefined { 1455 - if (!this.convo) return undefined 1456 - const info = this.getGroupInfo() 1457 - return !!info 1458 - } 1459 - 1460 - getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined { 1461 - if ( 1462 - this.convo && 1463 - bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 1464 - this.convo.kind, 1465 - ChatBskyConvoDefs.isGroupConvo, 1466 - ) 1467 - ) { 1468 - return this.convo.kind 1469 - } 1470 - return undefined 1471 - } 1472 - 1473 - getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined { 1474 - if (this.isGroup()) { 1475 - return this.convo?.members.find(m => { 1476 - if ( 1477 - bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( 1478 - m.kind, 1479 - ChatBskyActorDefs.isGroupConvoMember, 1480 - ) 1481 - ) { 1482 - return m.kind.role === 'owner' 1483 - } else { 1484 - throw new Error( 1485 - 'Expected a GroupConvoMember, got an unknown kind of member', 1486 - ) 1487 - } 1488 - }) 1489 - } else { 1490 - return this.recipients?.find(r => r.did !== this.senderUserDid) 1491 1523 } 1492 1524 } 1493 1525 }
+35 -7
src/state/messages/convo/index.tsx
··· 29 29 import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' 30 30 import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' 31 31 import {useAgent} from '#/state/session' 32 + import {type GroupConvoMember} from '#/components/dms/util' 32 33 33 34 export * from '#/state/messages/convo/util' 35 + 36 + function membersChanged( 37 + a: ChatBskyConvoDefs.ConvoView['members'], 38 + b: ChatBskyConvoDefs.ConvoView['members'], 39 + ) { 40 + if (a.length !== b.length) return true 41 + const aDids = new Set(a.map(m => m.did)) 42 + return b.some(m => !aDids.has(m.did)) 43 + } 34 44 35 45 const ChatContext = createContext<ConvoState | null>(null) 36 46 ChatContext.displayName = 'ChatContext' ··· 107 117 switch (event.type) { 108 118 case 'invalidate-block-state': { 109 119 for (const did of event.accountDids) { 110 - queryClient.invalidateQueries({ 120 + void queryClient.invalidateQueries({ 111 121 queryKey: createProfileQueryKey(did), 112 122 }) 113 123 } 114 - queryClient.invalidateQueries({ 124 + void queryClient.invalidateQueries({ 115 125 queryKey: [ListConvosQueryKeyRoot], 116 126 }) 117 127 } ··· 127 137 const data = event.query.state.data as 128 138 | ChatBskyConvoDefs.ConvoView 129 139 | undefined 130 - if (data && convo.convo && data.muted !== convo.convo.muted) { 140 + if (data && convo.convo && data.muted !== convo.convo.view.muted) { 131 141 convo.updateMuted(data.muted) 132 142 } 133 143 if ( 134 144 data && 135 - convo.convo && 136 145 ChatBskyConvoDefs.isGroupConvo(data.kind) && 137 - ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) && 138 - data.kind.name !== convo.convo.kind.name 146 + convo.convo?.kind === 'group' 139 147 ) { 140 - convo.updateGroupName(data.kind.name) 148 + if (data.kind.name !== convo.convo.details.name) { 149 + convo.updateGroupName(data.kind.name) 150 + } 151 + if (data.kind.joinLink !== convo.convo.details.joinLink) { 152 + convo.updateJoinLink(data.kind.joinLink) 153 + } 154 + if (data.kind.lockStatus !== convo.convo.details.lockStatus) { 155 + convo.updateLockStatus(data.kind.lockStatus) 156 + } 157 + } 158 + if ( 159 + data && 160 + ChatBskyConvoDefs.isGroupConvo(data.kind) && 161 + convo.convo?.kind === 'group' && 162 + (membersChanged(data.members, convo.convo.members) || 163 + data.kind.memberCount !== convo.convo.details.memberCount) 164 + ) { 165 + convo.updateGroupMembers( 166 + data.members as GroupConvoMember[], 167 + data.kind.memberCount, 168 + ) 141 169 } 142 170 } 143 171 })
+18 -67
src/state/messages/convo/types.ts
··· 6 6 } from '@atproto/api' 7 7 8 8 import {type MessagesEventBus} from '#/state/messages/events/agent' 9 + import {type ConvoWithDetails} from '#/components/dms/util' 9 10 10 11 export type ConvoParams = { 11 12 convoId: string ··· 58 59 } 59 60 60 61 export type ConvoDispatch = 61 - | { 62 - event: ConvoDispatchEvent.Init 63 - } 64 - | { 65 - event: ConvoDispatchEvent.Ready 66 - } 67 - | { 68 - event: ConvoDispatchEvent.Resume 69 - } 70 - | { 71 - event: ConvoDispatchEvent.Background 72 - } 73 - | { 74 - event: ConvoDispatchEvent.Suspend 75 - } 76 - | { 77 - event: ConvoDispatchEvent.Error 78 - payload: ConvoError 79 - } 80 - | { 81 - event: ConvoDispatchEvent.Disable 82 - } 62 + | {event: ConvoDispatchEvent.Init} 63 + | {event: ConvoDispatchEvent.Ready} 64 + | {event: ConvoDispatchEvent.Resume} 65 + | {event: ConvoDispatchEvent.Background} 66 + | {event: ConvoDispatchEvent.Suspend} 67 + | {event: ConvoDispatchEvent.Error; payload: ConvoError} 68 + | {event: ConvoDispatchEvent.Disable} 83 69 84 70 export type ConvoItem = 85 71 | { 86 72 type: 'message' 87 73 key: string 88 74 message: ChatBskyConvoDefs.MessageView 75 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 89 76 nextMessage: 90 77 | ChatBskyConvoDefs.MessageView 91 78 | ChatBskyConvoDefs.DeletedMessageView ··· 99 86 type: 'pending-message' 100 87 key: string 101 88 message: ChatBskyConvoDefs.MessageView 89 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 102 90 nextMessage: 103 91 | ChatBskyConvoDefs.MessageView 104 92 | ChatBskyConvoDefs.DeletedMessageView ··· 117 105 type: 'deleted-message' 118 106 key: string 119 107 message: ChatBskyConvoDefs.DeletedMessageView 108 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 120 109 nextMessage: 121 110 | ChatBskyConvoDefs.MessageView 122 111 | ChatBskyConvoDefs.DeletedMessageView ··· 130 119 type: 'system-message' 131 120 key: string 132 121 message: ChatBskyConvoDefs.SystemMessageView 133 - relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[] 122 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 134 123 } 135 124 | { 136 125 type: 'error' ··· 150 139 type MarkConvoAccepted = () => void 151 140 type AddReaction = (messageId: string, reaction: string) => Promise<void> 152 141 type RemoveReaction = (messageId: string, reaction: string) => Promise<void> 153 - type IsGroup = () => boolean | undefined 154 - type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined 155 - type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined 156 142 157 143 export type ConvoStateUninitialized = { 158 144 status: ConvoStatus.Uninitialized 159 145 items: [] 160 - convo: ChatBskyConvoDefs.ConvoView | undefined 146 + convo: ConvoWithDetails | undefined 161 147 error: undefined 162 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 163 - recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined 164 148 isFetchingHistory: false 165 149 hasAllHistory: boolean 166 150 deleteMessage: undefined ··· 169 153 markConvoAccepted: undefined 170 154 addReaction: undefined 171 155 removeReaction: undefined 172 - isGroup: IsGroup 173 - getGroupInfo: GetGroupInfo 174 - getPrimaryMember: GetPrimaryMember 175 156 } 176 157 export type ConvoStateInitializing = { 177 158 status: ConvoStatus.Initializing 178 159 items: [] 179 - convo: ChatBskyConvoDefs.ConvoView | undefined 160 + convo: ConvoWithDetails | undefined 180 161 error: undefined 181 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 182 - recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined 183 162 isFetchingHistory: boolean 184 163 hasAllHistory: boolean 185 164 deleteMessage: undefined ··· 188 167 markConvoAccepted: undefined 189 168 addReaction: undefined 190 169 removeReaction: undefined 191 - isGroup: IsGroup 192 - getGroupInfo: GetGroupInfo 193 - getPrimaryMember: GetPrimaryMember 194 170 } 195 171 export type ConvoStateReady = { 196 172 status: ConvoStatus.Ready 197 173 items: ConvoItem[] 198 - convo: ChatBskyConvoDefs.ConvoView 174 + convo: ConvoWithDetails 199 175 error: undefined 200 - sender: ChatBskyActorDefs.ProfileViewBasic 201 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 202 176 isFetchingHistory: boolean 203 177 hasAllHistory: boolean 204 178 deleteMessage: DeleteMessage ··· 207 181 markConvoAccepted: MarkConvoAccepted 208 182 addReaction: AddReaction 209 183 removeReaction: RemoveReaction 210 - isGroup: IsGroup 211 - getGroupInfo: GetGroupInfo 212 - getPrimaryMember: GetPrimaryMember 213 184 } 214 185 export type ConvoStateBackgrounded = { 215 186 status: ConvoStatus.Backgrounded 216 187 items: ConvoItem[] 217 - convo: ChatBskyConvoDefs.ConvoView 188 + convo: ConvoWithDetails 218 189 error: undefined 219 - sender: ChatBskyActorDefs.ProfileViewBasic 220 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 221 190 isFetchingHistory: boolean 222 191 hasAllHistory: boolean 223 192 deleteMessage: DeleteMessage ··· 226 195 markConvoAccepted: MarkConvoAccepted 227 196 addReaction: AddReaction 228 197 removeReaction: RemoveReaction 229 - isGroup: IsGroup 230 - getGroupInfo: GetGroupInfo 231 - getPrimaryMember: GetPrimaryMember 232 198 } 233 199 export type ConvoStateSuspended = { 234 200 status: ConvoStatus.Suspended 235 201 items: ConvoItem[] 236 - convo: ChatBskyConvoDefs.ConvoView 202 + convo: ConvoWithDetails 237 203 error: undefined 238 - sender: ChatBskyActorDefs.ProfileViewBasic 239 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 240 204 isFetchingHistory: boolean 241 205 hasAllHistory: boolean 242 206 deleteMessage: DeleteMessage ··· 245 209 markConvoAccepted: MarkConvoAccepted 246 210 addReaction: AddReaction 247 211 removeReaction: RemoveReaction 248 - isGroup: IsGroup 249 - getGroupInfo: GetGroupInfo 250 - getPrimaryMember: GetPrimaryMember 251 212 } 252 213 export type ConvoStateError = { 253 214 status: ConvoStatus.Error 254 215 items: [] 255 216 convo: undefined 256 217 error: ConvoError 257 - sender: undefined 258 - recipients: undefined 259 218 isFetchingHistory: false 260 219 hasAllHistory: false 261 220 deleteMessage: undefined ··· 264 223 markConvoAccepted: undefined 265 224 addReaction: undefined 266 225 removeReaction: undefined 267 - isGroup: undefined 268 - getGroupInfo: undefined 269 - getPrimaryMember: undefined 270 226 } 271 227 export type ConvoStateDisabled = { 272 228 status: ConvoStatus.Disabled 273 229 items: ConvoItem[] 274 - convo: ChatBskyConvoDefs.ConvoView 230 + convo: ConvoWithDetails 275 231 error: undefined 276 - sender: ChatBskyActorDefs.ProfileViewBasic 277 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 278 232 isFetchingHistory: boolean 279 233 hasAllHistory: boolean 280 234 deleteMessage: DeleteMessage ··· 283 237 markConvoAccepted: MarkConvoAccepted 284 238 addReaction: AddReaction 285 239 removeReaction: RemoveReaction 286 - isGroup: IsGroup 287 - getGroupInfo: GetGroupInfo 288 - getPrimaryMember: GetPrimaryMember 289 240 } 290 241 export type ConvoState = 291 242 | ConvoStateUninitialized
+176
src/state/queries/messages/add-group-members.ts
··· 1 + import { 2 + type ChatBskyActorDefs, 3 + ChatBskyConvoDefs, 4 + type ChatBskyConvoListConvos, 5 + type ChatBskyGroupAddMembers, 6 + } from '@atproto/api' 7 + import { 8 + type InfiniteData, 9 + useMutation, 10 + useQueryClient, 11 + } from '@tanstack/react-query' 12 + 13 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 14 + import {logger} from '#/logger' 15 + import {useProfileQuery} from '#/state/queries/profile' 16 + import {useAgent, useSession} from '#/state/session' 17 + import type * as bsky from '#/types/bsky' 18 + import {RQKEY as CONVO_KEY} from './conversation' 19 + import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 20 + import {listConvoMembersQueryKey} from './list-convo-members' 21 + 22 + export function useAddGroupMembers( 23 + convoId: string | undefined, 24 + { 25 + onSuccess, 26 + onError, 27 + }: { 28 + onSuccess?: (data: ChatBskyGroupAddMembers.OutputSchema) => void 29 + onError?: (error: Error) => void 30 + }, 31 + ) { 32 + const queryClient = useQueryClient() 33 + const agent = useAgent() 34 + const {currentAccount} = useSession() 35 + const {data: myProfile} = useProfileQuery({did: currentAccount?.did}) 36 + 37 + return useMutation({ 38 + mutationFn: async ({ 39 + members, 40 + }: { 41 + members: string[] 42 + profiles: bsky.profile.AnyProfileView[] 43 + }) => { 44 + if (!convoId) throw new Error('No convoId provided') 45 + const {data} = await agent.chat.bsky.group.addMembers( 46 + {convoId, members}, 47 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 48 + ) 49 + return data 50 + }, 51 + onMutate: ({profiles}) => { 52 + if (!convoId) return 53 + 54 + const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 55 + CONVO_KEY(convoId), 56 + ) 57 + const prevListEntries = queryClient.getQueriesData< 58 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 59 + >({queryKey: [CONVO_LIST_KEY]}) 60 + const prevMemberList = queryClient.getQueryData< 61 + ChatBskyActorDefs.ProfileViewBasic[] 62 + >(listConvoMembersQueryKey(convoId)) 63 + 64 + const addedBy: ChatBskyActorDefs.ProfileViewBasic | undefined = myProfile 65 + ? { 66 + ...myProfile, 67 + $type: 'chat.bsky.actor.defs#profileViewBasic', 68 + } 69 + : undefined 70 + 71 + const optimisticMembers: ChatBskyActorDefs.ProfileViewBasic[] = 72 + profiles.map(profile => ({ 73 + ...profile, 74 + $type: 'chat.bsky.actor.defs#profileViewBasic', 75 + kind: { 76 + $type: 'chat.bsky.actor.defs#groupConvoMember', 77 + role: 'standard', 78 + addedBy, 79 + }, 80 + })) 81 + 82 + queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 83 + CONVO_KEY(convoId), 84 + prev => { 85 + if (!prev) return 86 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev 87 + return { 88 + ...prev, 89 + members: [...prev.members, ...optimisticMembers], 90 + kind: { 91 + ...prev.kind, 92 + memberCount: prev.kind.memberCount + optimisticMembers.length, 93 + }, 94 + } 95 + }, 96 + ) 97 + 98 + queryClient.setQueriesData< 99 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 100 + >({queryKey: [CONVO_LIST_KEY]}, prev => { 101 + if (!prev?.pages) return 102 + return { 103 + ...prev, 104 + pages: prev.pages.map(page => ({ 105 + ...page, 106 + convos: page.convos.map(convo => { 107 + if (convo.id !== convoId) return convo 108 + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo 109 + return { 110 + ...convo, 111 + members: [...convo.members, ...optimisticMembers], 112 + kind: { 113 + ...convo.kind, 114 + memberCount: 115 + convo.kind.memberCount + optimisticMembers.length, 116 + }, 117 + } 118 + }), 119 + })), 120 + } 121 + }) 122 + 123 + queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>( 124 + listConvoMembersQueryKey(convoId), 125 + prev => { 126 + if (!prev) return 127 + return [...prev, ...optimisticMembers] 128 + }, 129 + ) 130 + 131 + return {prevConvo, prevListEntries, prevMemberList} 132 + }, 133 + onSuccess: data => { 134 + if (convoId) { 135 + queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 136 + CONVO_KEY(convoId), 137 + data.convo, 138 + ) 139 + 140 + queryClient.setQueriesData< 141 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 142 + >({queryKey: [CONVO_LIST_KEY]}, prev => { 143 + if (!prev?.pages) return 144 + return { 145 + ...prev, 146 + pages: prev.pages.map(page => ({ 147 + ...page, 148 + convos: page.convos.map(convo => 149 + convo.id === convoId ? data.convo : convo, 150 + ), 151 + })), 152 + } 153 + }) 154 + } 155 + onSuccess?.(data) 156 + }, 157 + onError: (e, _variables, context) => { 158 + logger.error(e) 159 + if (context?.prevConvo && convoId) { 160 + queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) 161 + } 162 + if (context?.prevListEntries) { 163 + for (const [key, data] of context.prevListEntries) { 164 + queryClient.setQueryData(key, data) 165 + } 166 + } 167 + if (context?.prevMemberList && convoId) { 168 + queryClient.setQueryData( 169 + listConvoMembersQueryKey(convoId), 170 + context.prevMemberList, 171 + ) 172 + } 173 + onError?.(e) 174 + }, 175 + }) 176 + }
+1 -1
src/state/queries/messages/conversation.ts
··· 16 16 RQKEY_ROOT as LIST_CONVOS_KEY, 17 17 } from './list-conversations' 18 18 19 - const RQKEY_ROOT = 'convo' 19 + export const RQKEY_ROOT = 'convo' 20 20 export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] 21 21 22 22 export function useConvoQuery({convoId}: {convoId: string}) {
+84
src/state/queries/messages/create-join-link.ts
··· 1 + import { 2 + ChatBskyConvoDefs, 3 + type ChatBskyGroupCreateJoinLink, 4 + type ChatBskyGroupDefs, 5 + } from '@atproto/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 9 + import {logger} from '#/logger' 10 + import {useAgent} from '#/state/session' 11 + import { 12 + rollbackConvoOptimistic, 13 + updateConvoOptimistic, 14 + } from './utils/convo-cache' 15 + 16 + export function useCreateJoinLink( 17 + convoId: string | undefined, 18 + { 19 + onSuccess, 20 + onError, 21 + }: { 22 + onSuccess?: (data: ChatBskyGroupCreateJoinLink.OutputSchema) => void 23 + onError?: (error: Error) => void 24 + }, 25 + ) { 26 + const queryClient = useQueryClient() 27 + const agent = useAgent() 28 + 29 + return useMutation({ 30 + mutationFn: async ({ 31 + joinRule, 32 + requireApproval, 33 + }: { 34 + joinRule: ChatBskyGroupDefs.JoinRule 35 + requireApproval: boolean 36 + }) => { 37 + if (!convoId) throw new Error('No convoId provided') 38 + const {data} = await agent.chat.bsky.group.createJoinLink( 39 + {convoId, joinRule, requireApproval}, 40 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 41 + ) 42 + return data 43 + }, 44 + onMutate: ({joinRule, requireApproval}) => { 45 + if (!convoId) return 46 + return updateConvoOptimistic(queryClient, convoId, prev => { 47 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 48 + return { 49 + ...prev, 50 + kind: { 51 + ...prev.kind, 52 + joinLink: { 53 + $type: 'chat.bsky.group.defs#joinLinkView', 54 + code: '', 55 + enabledStatus: 'enabled', 56 + joinRule, 57 + requireApproval, 58 + createdAt: new Date().toISOString(), 59 + }, 60 + }, 61 + } 62 + }) 63 + }, 64 + onSuccess: data => { 65 + if (convoId) { 66 + updateConvoOptimistic(queryClient, convoId, prev => { 67 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 68 + return { 69 + ...prev, 70 + kind: {...prev.kind, joinLink: data.joinLink}, 71 + } 72 + }) 73 + } 74 + onSuccess?.(data) 75 + }, 76 + onError: (e, _variables, context) => { 77 + logger.error(e) 78 + if (convoId && context) { 79 + rollbackConvoOptimistic(queryClient, convoId, context) 80 + } 81 + onError?.(e) 82 + }, 83 + }) 84 + }
+72
src/state/queries/messages/disable-join-link.ts
··· 1 + import { 2 + ChatBskyConvoDefs, 3 + type ChatBskyGroupDisableJoinLink, 4 + } from '@atproto/api' 5 + import {useMutation, useQueryClient} from '@tanstack/react-query' 6 + 7 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 8 + import {logger} from '#/logger' 9 + import {useAgent} from '#/state/session' 10 + import { 11 + rollbackConvoOptimistic, 12 + updateConvoOptimistic, 13 + } from './utils/convo-cache' 14 + 15 + export function useDisableJoinLink( 16 + convoId: string | undefined, 17 + { 18 + onSuccess, 19 + onError, 20 + }: { 21 + onSuccess?: (data: ChatBskyGroupDisableJoinLink.OutputSchema) => void 22 + onError?: (error: Error) => void 23 + }, 24 + ) { 25 + const queryClient = useQueryClient() 26 + const agent = useAgent() 27 + 28 + return useMutation({ 29 + mutationFn: async () => { 30 + if (!convoId) throw new Error('No convoId provided') 31 + const {data} = await agent.chat.bsky.group.disableJoinLink( 32 + {convoId}, 33 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 34 + ) 35 + return data 36 + }, 37 + onMutate: () => { 38 + if (!convoId) return 39 + return updateConvoOptimistic(queryClient, convoId, prev => { 40 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) { 41 + return undefined 42 + } 43 + return { 44 + ...prev, 45 + kind: { 46 + ...prev.kind, 47 + joinLink: {...prev.kind.joinLink, enabledStatus: 'disabled'}, 48 + }, 49 + } 50 + }) 51 + }, 52 + onSuccess: data => { 53 + if (convoId) { 54 + updateConvoOptimistic(queryClient, convoId, prev => { 55 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 56 + return { 57 + ...prev, 58 + kind: {...prev.kind, joinLink: data.joinLink}, 59 + } 60 + }) 61 + } 62 + onSuccess?.(data) 63 + }, 64 + onError: (e, _variables, context) => { 65 + logger.error(e) 66 + if (convoId && context) { 67 + rollbackConvoOptimistic(queryClient, convoId, context) 68 + } 69 + onError?.(e) 70 + }, 71 + }) 72 + }
+55
src/state/queries/messages/edit-group-chat-name.ts
··· 1 + import {ChatBskyConvoDefs, type ChatBskyGroupEditGroup} 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 { 8 + rollbackConvoOptimistic, 9 + updateConvoOptimistic, 10 + } from './utils/convo-cache' 11 + 12 + export function useEditGroupChatName( 13 + convoId: string | undefined, 14 + { 15 + onSuccess, 16 + onError, 17 + }: { 18 + onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void 19 + onError?: (error: Error) => void 20 + }, 21 + ) { 22 + const queryClient = useQueryClient() 23 + const agent = useAgent() 24 + 25 + return useMutation({ 26 + mutationFn: async ({name: groupName}: {name: string}) => { 27 + if (!convoId) throw new Error('No convoId provided') 28 + const {data} = await agent.chat.bsky.group.editGroup( 29 + {convoId, name: groupName}, 30 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 31 + ) 32 + return data 33 + }, 34 + onMutate: ({name: groupName}) => { 35 + if (!convoId) return 36 + return updateConvoOptimistic(queryClient, convoId, prev => { 37 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 38 + return { 39 + ...prev, 40 + kind: {...prev.kind, name: groupName}, 41 + } 42 + }) 43 + }, 44 + onSuccess: data => { 45 + onSuccess?.(data) 46 + }, 47 + onError: (e, _variables, context) => { 48 + logger.error(e) 49 + if (convoId && context) { 50 + rollbackConvoOptimistic(queryClient, convoId, context) 51 + } 52 + onError?.(e) 53 + }, 54 + }) 55 + }
+30 -21
src/state/queries/messages/edit-group-name.ts src/state/queries/messages/remove-from-group.ts
··· 1 1 import { 2 - ChatBskyConvoDefs, 2 + type ChatBskyActorDefs, 3 + type ChatBskyConvoDefs, 3 4 type ChatBskyConvoListConvos, 4 - type ChatBskyGroupEditGroup, 5 + type ChatBskyGroupRemoveMembers, 5 6 } from '@atproto/api' 6 7 import { 7 8 type InfiniteData, ··· 14 15 import {useAgent} from '#/state/session' 15 16 import {RQKEY as CONVO_KEY} from './conversation' 16 17 import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 18 + import {listConvoMembersQueryKey} from './list-convo-members' 17 19 18 - export function useEditGroupName( 20 + export function useRemoveFromGroupChat( 19 21 convoId: string | undefined, 20 22 { 21 23 onSuccess, 22 24 onError, 23 25 }: { 24 - onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void 26 + onSuccess?: (data: ChatBskyGroupRemoveMembers.OutputSchema) => void 25 27 onError?: (error: Error) => void 26 28 }, 27 29 ) { ··· 29 31 const agent = useAgent() 30 32 31 33 return useMutation({ 32 - mutationFn: async ({name: groupName}: {name: string}) => { 34 + mutationFn: async ({members}: {members: string[]}) => { 33 35 if (!convoId) throw new Error('No convoId provided') 34 - const {data} = await agent.chat.bsky.group.editGroup( 35 - {convoId, name: groupName}, 36 + const {data} = await agent.chat.bsky.group.removeMembers( 37 + {convoId, members}, 36 38 {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 37 39 ) 38 40 return data 39 41 }, 40 - onMutate: ({name: groupName}) => { 42 + onMutate: ({members}) => { 41 43 if (!convoId) return 42 44 43 45 const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( ··· 46 48 const prevListEntries = queryClient.getQueriesData< 47 49 InfiniteData<ChatBskyConvoListConvos.OutputSchema> 48 50 >({queryKey: [CONVO_LIST_KEY]}) 51 + const prevMemberList = queryClient.getQueryData< 52 + ChatBskyActorDefs.ProfileViewBasic[] 53 + >(listConvoMembersQueryKey(convoId)) 49 54 50 - // Update for a single chat thread 51 55 queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 52 56 CONVO_KEY(convoId), 53 57 prev => { 54 58 if (!prev) return 55 - if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev 56 59 return { 57 60 ...prev, 58 - kind: { 59 - ...prev.kind, 60 - name: groupName, 61 - }, 61 + members: prev.members.filter(m => !members.includes(m.did)), 62 62 } 63 63 }, 64 64 ) 65 65 66 - // Update for the chat list 67 66 queryClient.setQueriesData< 68 67 InfiniteData<ChatBskyConvoListConvos.OutputSchema> 69 68 >({queryKey: [CONVO_LIST_KEY]}, prev => { ··· 74 73 ...page, 75 74 convos: page.convos.map(convo => { 76 75 if (convo.id !== convoId) return convo 77 - if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo 78 76 return { 79 77 ...convo, 80 - kind: { 81 - ...convo.kind, 82 - name: groupName, 83 - }, 78 + members: convo.members.filter(m => !members.includes(m.did)), 84 79 } 85 80 }), 86 81 })), 87 82 } 88 83 }) 89 84 90 - return {prevConvo, prevListEntries} 85 + queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>( 86 + listConvoMembersQueryKey(convoId), 87 + prev => { 88 + if (!prev) return 89 + return prev.filter(m => !members.includes(m.did)) 90 + }, 91 + ) 92 + 93 + return {prevConvo, prevListEntries, prevMemberList} 91 94 }, 92 95 onSuccess: data => { 93 96 onSuccess?.(data) ··· 101 104 for (const [key, data] of context.prevListEntries) { 102 105 queryClient.setQueryData(key, data) 103 106 } 107 + } 108 + if (context?.prevMemberList && convoId) { 109 + queryClient.setQueryData( 110 + listConvoMembersQueryKey(convoId), 111 + context.prevMemberList, 112 + ) 104 113 } 105 114 onError?.(e) 106 115 },
+79
src/state/queries/messages/edit-join-link.ts
··· 1 + import { 2 + ChatBskyConvoDefs, 3 + type ChatBskyGroupDefs, 4 + type ChatBskyGroupEditJoinLink, 5 + } from '@atproto/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 9 + import {logger} from '#/logger' 10 + import {useAgent} from '#/state/session' 11 + import { 12 + rollbackConvoOptimistic, 13 + updateConvoOptimistic, 14 + } from './utils/convo-cache' 15 + 16 + export function useEditJoinLink( 17 + convoId: string | undefined, 18 + { 19 + onSuccess, 20 + onError, 21 + }: { 22 + onSuccess?: (data: ChatBskyGroupEditJoinLink.OutputSchema) => void 23 + onError?: (error: Error) => void 24 + }, 25 + ) { 26 + const queryClient = useQueryClient() 27 + const agent = useAgent() 28 + 29 + return useMutation({ 30 + mutationFn: async ({ 31 + joinRule, 32 + requireApproval, 33 + }: { 34 + joinRule: ChatBskyGroupDefs.JoinRule 35 + requireApproval: boolean 36 + }) => { 37 + if (!convoId) throw new Error('No convoId provided') 38 + const {data} = await agent.chat.bsky.group.editJoinLink( 39 + {convoId, joinRule, requireApproval}, 40 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 41 + ) 42 + return data 43 + }, 44 + onMutate: ({joinRule, requireApproval}) => { 45 + if (!convoId) return 46 + return updateConvoOptimistic(queryClient, convoId, prev => { 47 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) { 48 + return undefined 49 + } 50 + return { 51 + ...prev, 52 + kind: { 53 + ...prev.kind, 54 + joinLink: {...prev.kind.joinLink, joinRule, requireApproval}, 55 + }, 56 + } 57 + }) 58 + }, 59 + onSuccess: data => { 60 + if (convoId) { 61 + updateConvoOptimistic(queryClient, convoId, prev => { 62 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 63 + return { 64 + ...prev, 65 + kind: {...prev.kind, joinLink: data.joinLink}, 66 + } 67 + }) 68 + } 69 + onSuccess?.(data) 70 + }, 71 + onError: (e, _variables, context) => { 72 + logger.error(e) 73 + if (convoId && context) { 74 + rollbackConvoOptimistic(queryClient, convoId, context) 75 + } 76 + onError?.(e) 77 + }, 78 + }) 79 + }
+69
src/state/queries/messages/enable-join-link.ts
··· 1 + import {ChatBskyConvoDefs, type ChatBskyGroupEnableJoinLink} 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 { 8 + rollbackConvoOptimistic, 9 + updateConvoOptimistic, 10 + } from './utils/convo-cache' 11 + 12 + export function useEnableJoinLink( 13 + convoId: string | undefined, 14 + { 15 + onSuccess, 16 + onError, 17 + }: { 18 + onSuccess?: (data: ChatBskyGroupEnableJoinLink.OutputSchema) => void 19 + onError?: (error: Error) => void 20 + }, 21 + ) { 22 + const queryClient = useQueryClient() 23 + const agent = useAgent() 24 + 25 + return useMutation({ 26 + mutationFn: async () => { 27 + if (!convoId) throw new Error('No convoId provided') 28 + const {data} = await agent.chat.bsky.group.enableJoinLink( 29 + {convoId}, 30 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 31 + ) 32 + return data 33 + }, 34 + onMutate: () => { 35 + if (!convoId) return 36 + return updateConvoOptimistic(queryClient, convoId, prev => { 37 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) { 38 + return undefined 39 + } 40 + return { 41 + ...prev, 42 + kind: { 43 + ...prev.kind, 44 + joinLink: {...prev.kind.joinLink, enabledStatus: 'enabled'}, 45 + }, 46 + } 47 + }) 48 + }, 49 + onSuccess: data => { 50 + if (convoId) { 51 + updateConvoOptimistic(queryClient, convoId, prev => { 52 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 53 + return { 54 + ...prev, 55 + kind: {...prev.kind, joinLink: data.joinLink}, 56 + } 57 + }) 58 + } 59 + onSuccess?.(data) 60 + }, 61 + onError: (e, _variables, context) => { 62 + logger.error(e) 63 + if (convoId && context) { 64 + rollbackConvoOptimistic(queryClient, convoId, context) 65 + } 66 + onError?.(e) 67 + }, 68 + }) 69 + }
+5 -1
src/state/queries/messages/get-convo-availability.ts
··· 7 7 const RQKEY_ROOT = 'convo-availability' 8 8 export const RQKEY = (did: string) => [RQKEY_ROOT, did] 9 9 10 - export function useGetConvoAvailabilityQuery(did: string) { 10 + export function useGetConvoAvailabilityQuery( 11 + did: string, 12 + {enabled = true}: {enabled?: boolean} = {}, 13 + ) { 11 14 const agent = useAgent() 12 15 13 16 return useQuery({ ··· 21 24 return data 22 25 }, 23 26 staleTime: STALE.INFINITY, 27 + enabled, 24 28 }) 25 29 }
+2 -2
src/state/queries/messages/leave-conversation.ts
··· 71 71 return {prevPages} 72 72 }, 73 73 onSuccess: data => { 74 - queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 74 + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 75 75 onSuccess?.(data) 76 76 }, 77 77 onError: (error, _, context) => { ··· 89 89 } 90 90 }, 91 91 ) 92 - queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 92 + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 93 93 onError?.(error) 94 94 }, 95 95 })
+2 -2
src/state/queries/messages/list-conversations.tsx
··· 105 105 106 106 const debouncedRefetch = useMemo(() => { 107 107 const refetchAndInvalidate = () => { 108 - refetch() 109 - queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) 108 + void refetch() 109 + void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) 110 110 } 111 111 return throttle(refetchAndInvalidate, 500, { 112 112 leading: true,
+122
src/state/queries/messages/list-convo-members.ts
··· 1 + import {useEffect} from 'react' 2 + import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 3 + import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 4 + 5 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 6 + import {useMessagesEventBus} from '#/state/messages/events' 7 + import {STALE} from '#/state/queries' 8 + import {createQueryKey} from '#/state/queries/util' 9 + import {useAgent} from '#/state/session' 10 + import * as bsky from '#/types/bsky' 11 + 12 + const RQKEY_ROOT = 'listConvoMembers' 13 + export const listConvoMembersQueryKey = (convoId: string) => 14 + createQueryKey(RQKEY_ROOT, {convoId}) 15 + 16 + // group chat size is 50, so should fetch the whole list in one go 17 + const LIMIT = 50 18 + 19 + export function useListConvoMembersQuery({ 20 + convoId, 21 + placeholderData, 22 + }: { 23 + convoId: string 24 + placeholderData?: ChatBskyActorDefs.ProfileViewBasic[] 25 + }) { 26 + const agent = useAgent() 27 + const queryClient = useQueryClient() 28 + const messagesBus = useMessagesEventBus() 29 + 30 + useEffect(() => { 31 + const unsub = messagesBus.on( 32 + ev => { 33 + if (ev.type !== 'logs') return 34 + 35 + function mutateList( 36 + fn: ( 37 + update: ChatBskyActorDefs.ProfileViewBasic[], 38 + ) => ChatBskyActorDefs.ProfileViewBasic[], 39 + ) { 40 + queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>( 41 + listConvoMembersQueryKey(convoId), 42 + old => { 43 + if (!old) return // query doesn't exist yet, skip 44 + return fn(old) 45 + }, 46 + ) 47 + } 48 + 49 + for (const log of ev.logs) { 50 + if (ChatBskyConvoDefs.isLogAddMember(log)) { 51 + const data = log.message.data 52 + if ( 53 + bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataAddMember>( 54 + data, 55 + ChatBskyConvoDefs.isSystemMessageDataAddMember, 56 + ) 57 + ) { 58 + const newMember = log.relatedProfiles.find( 59 + r => r.did === data.member.did, 60 + ) 61 + if (newMember) { 62 + mutateList(list => list.concat(newMember)) 63 + } 64 + } 65 + } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) { 66 + const data = log.message.data 67 + if ( 68 + bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataRemoveMember>( 69 + data, 70 + ChatBskyConvoDefs.isSystemMessageDataRemoveMember, 71 + ) 72 + ) { 73 + mutateList(list => list.filter(m => m.did !== data.member.did)) 74 + } 75 + } 76 + } 77 + }, 78 + {convoId}, 79 + ) 80 + return () => unsub() 81 + }, [convoId, messagesBus, queryClient]) 82 + 83 + return useQuery({ 84 + queryKey: listConvoMembersQueryKey(convoId), 85 + queryFn: async () => { 86 + const members = [] 87 + let cursor 88 + 89 + do { 90 + const {data} = await agent.chat.bsky.convo.getConvoMembers( 91 + {convoId, cursor, limit: LIMIT}, 92 + {headers: DM_SERVICE_HEADERS}, 93 + ) 94 + members.push(...data.members) 95 + cursor = data.cursor 96 + } while (cursor) 97 + 98 + return members 99 + }, 100 + staleTime: STALE.MINUTES.THIRTY, 101 + placeholderData, 102 + }) 103 + } 104 + 105 + export function* findAllProfilesInQueryData( 106 + queryClient: QueryClient, 107 + did: string, 108 + ): Generator<ChatBskyActorDefs.ProfileViewBasic, void> { 109 + const queryDatas = queryClient.getQueriesData< 110 + ChatBskyActorDefs.ProfileViewBasic[] 111 + >({ 112 + queryKey: [RQKEY_ROOT], 113 + }) 114 + for (const [_queryKey, queryData] of queryDatas) { 115 + if (!queryData) continue 116 + for (const member of queryData) { 117 + if (member.did === did) { 118 + yield member 119 + } 120 + } 121 + } 122 + }
+64
src/state/queries/messages/lock-conversation.ts
··· 1 + import {ChatBskyConvoDefs, type ChatBskyConvoLockConvo} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 5 + import {useAgent} from '#/state/session' 6 + import { 7 + rollbackConvoOptimistic, 8 + updateConvoOptimistic, 9 + } from './utils/convo-cache' 10 + 11 + export function useLockConvo( 12 + convoId: string | undefined, 13 + { 14 + onSuccess, 15 + onError, 16 + }: { 17 + onSuccess?: (data: ChatBskyConvoLockConvo.OutputSchema) => void 18 + onError?: (error: Error, variables: {lock: boolean}) => void 19 + }, 20 + ) { 21 + const queryClient = useQueryClient() 22 + const agent = useAgent() 23 + 24 + return useMutation({ 25 + mutationFn: async ({lock}: {lock: boolean}) => { 26 + if (!convoId) throw new Error('No convoId provided') 27 + if (lock) { 28 + const {data} = await agent.chat.bsky.convo.lockConvo( 29 + {convoId}, 30 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 31 + ) 32 + return data 33 + } else { 34 + const {data} = await agent.chat.bsky.convo.unlockConvo( 35 + {convoId}, 36 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 37 + ) 38 + return data 39 + } 40 + }, 41 + onMutate: ({lock}) => { 42 + if (!convoId) return 43 + return updateConvoOptimistic(queryClient, convoId, prev => { 44 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 45 + return { 46 + ...prev, 47 + kind: { 48 + ...prev.kind, 49 + lockStatus: lock ? 'locked' : 'unlocked', 50 + }, 51 + } 52 + }) 53 + }, 54 + onSuccess: data => { 55 + onSuccess?.(data) 56 + }, 57 + onError: (e, variables, context) => { 58 + if (convoId && context) { 59 + rollbackConvoOptimistic(queryClient, convoId, context) 60 + } 61 + onError?.(e, variables) 62 + }, 63 + }) 64 + }
+12 -60
src/state/queries/messages/mute-conversation.ts
··· 1 - import { 2 - type ChatBskyConvoDefs, 3 - type ChatBskyConvoListConvos, 4 - type ChatBskyConvoMuteConvo, 5 - } from '@atproto/api' 6 - import { 7 - type InfiniteData, 8 - useMutation, 9 - useQueryClient, 10 - } from '@tanstack/react-query' 1 + import {type ChatBskyConvoMuteConvo} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 11 3 12 4 import {DM_SERVICE_HEADERS} from '#/lib/constants' 13 5 import {useAgent} from '#/state/session' 14 - import {RQKEY as CONVO_KEY} from './conversation' 15 - import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 6 + import { 7 + rollbackConvoOptimistic, 8 + updateConvoOptimistic, 9 + } from './utils/convo-cache' 16 10 17 11 export function useMuteConvo( 18 12 convoId: string | undefined, ··· 46 40 }, 47 41 onMutate: ({mute}) => { 48 42 if (!convoId) return 49 - 50 - const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 51 - CONVO_KEY(convoId), 52 - ) 53 - const prevListEntries = queryClient.getQueriesData< 54 - InfiniteData<ChatBskyConvoListConvos.OutputSchema> 55 - >({queryKey: [CONVO_LIST_KEY]}) 56 - 57 - // Update for a single chat thread 58 - queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 59 - CONVO_KEY(convoId), 60 - prev => { 61 - if (!prev) return 62 - return { 63 - ...prev, 64 - muted: mute, 65 - } 66 - }, 67 - ) 68 - 69 - // Update for the chat list 70 - queryClient.setQueriesData< 71 - InfiniteData<ChatBskyConvoListConvos.OutputSchema> 72 - >({queryKey: [CONVO_LIST_KEY]}, prev => { 73 - if (!prev?.pages) return 74 - return { 75 - ...prev, 76 - pages: prev.pages.map(page => ({ 77 - ...page, 78 - convos: page.convos.map(convo => { 79 - if (convo.id !== convoId) return convo 80 - return { 81 - ...convo, 82 - muted: mute, 83 - } 84 - }), 85 - })), 86 - } 87 - }) 88 - 89 - return {prevConvo, prevListEntries} 43 + return updateConvoOptimistic(queryClient, convoId, prev => ({ 44 + ...prev, 45 + muted: mute, 46 + })) 90 47 }, 91 48 onSuccess: data => { 92 49 onSuccess?.(data) 93 50 }, 94 51 onError: (e, _variables, context) => { 95 - if (context?.prevConvo && convoId) { 96 - queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) 97 - } 98 - if (context?.prevListEntries) { 99 - for (const [key, data] of context.prevListEntries) { 100 - queryClient.setQueryData(key, data) 101 - } 52 + if (convoId && context) { 53 + rollbackConvoOptimistic(queryClient, convoId, context) 102 54 } 103 55 onError?.(e) 104 56 },
+87
src/state/queries/messages/utils/convo-cache.ts
··· 1 + import { 2 + type ChatBskyConvoDefs, 3 + type ChatBskyConvoListConvos, 4 + } from '@atproto/api' 5 + import { 6 + type InfiniteData, 7 + type QueryClient, 8 + type QueryKey, 9 + } from '@tanstack/react-query' 10 + 11 + import {RQKEY as CONVO_KEY} from '../conversation' 12 + import {RQKEY_ROOT as CONVO_LIST_KEY} from '../list-conversations' 13 + 14 + type ConvoUpdater = ( 15 + prev: ChatBskyConvoDefs.ConvoView, 16 + ) => ChatBskyConvoDefs.ConvoView | undefined 17 + 18 + export type ConvoCacheSnapshot = { 19 + prevConvo: ChatBskyConvoDefs.ConvoView | undefined 20 + prevListEntries: Array< 21 + [QueryKey, InfiniteData<ChatBskyConvoListConvos.OutputSchema> | undefined] 22 + > 23 + } 24 + 25 + /** 26 + * Writes an optimistic update to a convo across both the single-convo and 27 + * convo-list caches. The updater receives the current ConvoView and returns 28 + * the next one - return undefined to bail out (e.g. when the convo's kind 29 + * doesn't match what the mutation requires). Returns a snapshot that can be 30 + * passed to `rollbackConvoOptimistic`. 31 + */ 32 + export function updateConvoOptimistic( 33 + queryClient: QueryClient, 34 + convoId: string, 35 + updater: ConvoUpdater, 36 + ): ConvoCacheSnapshot { 37 + const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 38 + CONVO_KEY(convoId), 39 + ) 40 + const prevListEntries = queryClient.getQueriesData< 41 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 42 + >({queryKey: [CONVO_LIST_KEY]}) 43 + 44 + queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 45 + CONVO_KEY(convoId), 46 + prev => { 47 + if (!prev) return 48 + const next = updater(prev) 49 + return next ?? prev 50 + }, 51 + ) 52 + 53 + queryClient.setQueriesData< 54 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 55 + >({queryKey: [CONVO_LIST_KEY]}, prev => { 56 + if (!prev?.pages) return 57 + return { 58 + ...prev, 59 + pages: prev.pages.map(page => ({ 60 + ...page, 61 + convos: page.convos.map(convo => { 62 + if (convo.id !== convoId) return convo 63 + const next = updater(convo) 64 + return next ?? convo 65 + }), 66 + })), 67 + } 68 + }) 69 + 70 + return {prevConvo, prevListEntries} 71 + } 72 + 73 + /** 74 + * Restores the caches to the state captured by `updateConvoOptimistic`. 75 + */ 76 + export function rollbackConvoOptimistic( 77 + queryClient: QueryClient, 78 + convoId: string, 79 + snapshot: ConvoCacheSnapshot, 80 + ) { 81 + if (snapshot.prevConvo) { 82 + queryClient.setQueryData(CONVO_KEY(convoId), snapshot.prevConvo) 83 + } 84 + for (const [key, data] of snapshot.prevListEntries) { 85 + queryClient.setQueryData(key, data) 86 + } 87 + }