Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Support group chats across other surfaces (#10266)

authored by

Samuel Newman and committed by
GitHub
f51602b3 cc8f2288

+436 -161
+17 -3
src/components/AvatarBubbles.tsx
··· 18 18 type Props = { 19 19 animate?: boolean 20 20 profiles: bsky.profile.AnyProfileView[] 21 - size?: 'small' | 'medium' | 'large' 21 + size?: 'small' | 'medium' | 'large' | number 22 22 } 23 23 24 24 export function AvatarBubbles({ ··· 28 28 }: Props) { 29 29 const {currentAccount} = useSession() 30 30 const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) 31 - const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120 32 - const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1 31 + const containerSize = 32 + typeof size === 'number' 33 + ? size 34 + : size === 'small' 35 + ? 40 36 + : size === 'medium' 37 + ? 56 38 + : 120 39 + const scale = 40 + typeof size === 'number' 41 + ? size / 120 42 + : size === 'small' 43 + ? 40 / 120 44 + : size === 'medium' 45 + ? 56 / 120 46 + : 1 33 47 const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 34 48 35 49 const initialValue = animate ? 0 : 1
+48 -30
src/components/PostControls/ShareMenu/RecentChats.tsx
··· 6 6 import {useNavigation} from '@react-navigation/native' 7 7 8 8 import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 9 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 9 10 import {type NavigationProp} from '#/lib/routes/types' 10 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 11 - import {sanitizeHandle} from '#/lib/strings/handles' 12 11 import {useProfileShadow} from '#/state/cache/profile-shadow' 13 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 14 13 import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 15 14 import {useSession} from '#/state/session' 16 15 import {UserAvatar} from '#/view/com/util/UserAvatar' 17 16 import {atoms as a, tokens, useTheme} from '#/alf' 17 + import {AvatarBubbles} from '#/components/AvatarBubbles' 18 18 import {Button} from '#/components/Button' 19 19 import {useDialogContext} from '#/components/Dialog' 20 + import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' 20 21 import {ProfileBadges} from '#/components/ProfileBadges' 21 22 import {Text} from '#/components/Typography' 22 23 import {useAnalytics} from '#/analytics' 23 - import type * as bsky from '#/types/bsky' 24 24 25 25 export function RecentChats({ 26 26 postUri, ··· 60 60 showsHorizontalScrollIndicator={false} 61 61 nestedScrollEnabled> 62 62 {convos && convos.length > 0 ? ( 63 - convos.map(convo => { 64 - const otherMember = convo.members.find( 65 - member => member.did !== currentAccount?.did, 66 - ) 63 + convos.map(c => { 64 + const convo = parseConvoView(c, currentAccount?.did) 65 + 66 + if (!convo) return null 67 67 68 68 if ( 69 - !otherMember || 70 - otherMember.handle === 'missing.invalid' || 71 - convo.muted 72 - ) 69 + (convo.kind === 'direct' && 70 + convo.primaryMember.handle === 'missing.invalid') || 71 + convo.view.muted 72 + ) { 73 73 return null 74 + } 74 75 75 76 return ( 76 77 <RecentChatItem 77 - key={convo.id} 78 - profile={otherMember} 79 - onPress={() => onSelectChat(convo.id)} 78 + key={convo.view.id} 79 + convo={convo} 80 + onPress={() => onSelectChat(convo.view.id)} 80 81 moderationOpts={moderationOpts} 81 82 /> 82 83 ) ··· 99 100 const WIDTH = 80 100 101 101 102 function RecentChatItem({ 102 - profile: profileUnshadowed, 103 103 onPress, 104 104 moderationOpts, 105 + convo, 105 106 }: { 106 - profile: bsky.profile.AnyProfileView 107 107 onPress: () => void 108 108 moderationOpts: ModerationOpts 109 + convo: ConvoWithDetails 109 110 }) { 110 111 const {_} = useLingui() 111 112 const t = useTheme() 112 113 113 - const profile = useProfileShadow(profileUnshadowed) 114 + const primaryProfile = useProfileShadow(convo.primaryMember) 114 115 115 - const moderation = moderateProfile(profile, moderationOpts) 116 - const name = sanitizeDisplayName( 117 - profile.displayName || sanitizeHandle(profile.handle), 118 - moderation.ui('displayName'), 119 - ) 116 + const moderation = moderateProfile(primaryProfile, moderationOpts) 117 + const name = 118 + convo.kind === 'group' 119 + ? convo.details.name 120 + : createSanitizedDisplayName( 121 + primaryProfile, 122 + true, 123 + moderation.ui('displayName'), 124 + ) 120 125 121 - if (isBlockedOrBlocking(profile) || isMuted(profile)) { 126 + if ( 127 + convo.kind === 'direct' && 128 + (isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile)) 129 + ) { 122 130 return null 123 131 } 124 132 ··· 133 141 a.justify_start, 134 142 a.align_center, 135 143 ]}> 136 - <UserAvatar 137 - avatar={profile.avatar} 138 - size={WIDTH - 8} 139 - type={profile.associated?.labeler ? 'labeler' : 'user'} 140 - moderation={moderation.ui('avatar')} 141 - /> 144 + {convo.kind === 'group' ? ( 145 + <AvatarBubbles profiles={convo.members} size={WIDTH - 8} /> 146 + ) : ( 147 + <UserAvatar 148 + avatar={primaryProfile.avatar} 149 + size={WIDTH - 8} 150 + type={primaryProfile.associated?.labeler ? 'labeler' : 'user'} 151 + moderation={moderation.ui('avatar')} 152 + /> 153 + )} 142 154 <View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}> 143 155 <Text 144 156 emoji ··· 146 158 numberOfLines={1}> 147 159 {name} 148 160 </Text> 149 - <ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} /> 161 + {convo.kind === 'direct' && ( 162 + <ProfileBadges 163 + profile={primaryProfile} 164 + size="xs" 165 + style={[a.pl_2xs]} 166 + /> 167 + )} 150 168 </View> 151 169 </Button> 152 170 )
+184 -33
src/components/dialogs/SearchablePeopleList.tsx
··· 8 8 } from 'react' 9 9 import {TextInput, View} from 'react-native' 10 10 import {moderateProfile, type ModerationOpts} from '@atproto/api' 11 - import {msg} from '@lingui/core/macro' 12 - import {useLingui} from '@lingui/react' 13 - import {Trans} from '@lingui/react/macro' 11 + import {Plural, Trans, useLingui} from '@lingui/react/macro' 14 12 15 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 16 14 import {sanitizeHandle} from '#/lib/strings/handles' 17 15 import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 16 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' ··· 23 21 import {android, atoms as a, native, useTheme, web} from '#/alf' 24 22 import {Button, ButtonIcon} from '#/components/Button' 25 23 import * as Dialog from '#/components/Dialog' 26 - import {canBeMessaged} from '#/components/dms/util' 24 + import { 25 + canBeMessaged, 26 + type ConvoWithDetails, 27 + parseConvoView, 28 + } from '#/components/dms/util' 27 29 import {useInteractionState} from '#/components/hooks/useInteractionState' 28 30 import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 29 31 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' ··· 31 33 import {Text} from '#/components/Typography' 32 34 import {IS_WEB} from '#/env' 33 35 import type * as bsky from '#/types/bsky' 36 + import {AvatarBubbles} from '../AvatarBubbles' 37 + import {Error} from '../Error' 38 + import {ProfileBadges} from '../ProfileBadges' 34 39 35 40 export type ProfileItem = { 36 41 type: 'profile' 37 42 key: string 38 43 profile: bsky.profile.AnyProfileView 44 + } 45 + 46 + type ExistingChatItem = { 47 + type: 'existingChat' 48 + key: string 49 + convo: ConvoWithDetails 39 50 } 40 51 41 52 type EmptyItem = { ··· 54 65 key: string 55 66 } 56 67 57 - type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem 68 + type Item = 69 + | ProfileItem 70 + | ExistingChatItem 71 + | EmptyItem 72 + | PlaceholderItem 73 + | ErrorItem 58 74 59 75 export function SearchablePeopleList({ 60 76 title, ··· 72 88 onSelectChat?: undefined 73 89 } 74 90 | { 75 - onSelectChat: (did: string) => void 91 + onSelectChat: ( 92 + chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string}, 93 + ) => void 76 94 renderProfileCard?: undefined 77 95 } 78 96 )) { 79 97 const t = useTheme() 80 - const {_} = useLingui() 98 + const {t: l} = useLingui() 81 99 const moderationOpts = useModerationOpts() 82 100 const control = Dialog.useDialogContext() 83 101 const [headerHeight, setHeaderHeight] = useState(0) ··· 105 123 _items.push({ 106 124 type: 'empty', 107 125 key: 'empty', 108 - message: _(msg`We're having network issues, try again`), 126 + message: l`We're having network issues, try again`, 109 127 }) 110 128 } else if (searchText.length) { 111 129 if (results?.length) { ··· 139 157 const usedDids = new Set() 140 158 141 159 for (const page of convos.pages) { 142 - for (const convo of page.convos) { 143 - const profiles = convo.members.filter( 144 - m => m.did !== currentAccount?.did, 145 - ) 160 + for (const convoView of page.convos) { 161 + const convo = parseConvoView(convoView, currentAccount?.did) 162 + 163 + if (!convo) continue 146 164 147 - for (const profile of profiles) { 148 - if (usedDids.has(profile.did)) continue 165 + if (convo.kind === 'group') { 166 + _items.push({ 167 + type: 'existingChat', 168 + key: convo.view.id, 169 + convo, 170 + }) 171 + } else { 172 + if (convo.primaryMember.handle === 'missing.invalid') continue 173 + if (usedDids.has(convo.primaryMember.did)) continue 149 174 150 - usedDids.add(profile.did) 175 + usedDids.add(convo.primaryMember.did) 151 176 152 177 _items.push({ 153 - type: 'profile', 154 - key: profile.did, 155 - profile, 178 + type: 'existingChat', 179 + key: convo.view.id, 180 + convo: convo, 156 181 }) 157 182 } 158 183 } ··· 209 234 210 235 return _items 211 236 }, [ 212 - _, 237 + l, 213 238 searchText, 214 239 results, 215 240 isError, ··· 221 246 ]) 222 247 223 248 if (searchText && !isFetching && !items.length && !isError) { 224 - items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) 249 + items.push({type: 'empty', key: 'empty', message: l`No results`}) 225 250 } 226 251 227 252 const renderItems = useCallback( 228 253 ({item}: {item: Item}) => { 229 254 switch (item.type) { 255 + case 'existingChat': { 256 + if (renderProfileCard) { 257 + // should be unreachable 258 + return null 259 + } else { 260 + return ( 261 + <ExistingChatCard 262 + key={item.key} 263 + convo={item.convo} 264 + moderationOpts={moderationOpts!} 265 + onPress={id => onSelectChat({kind: 'convo', id})} 266 + /> 267 + ) 268 + } 269 + } 230 270 case 'profile': { 231 271 if (renderProfileCard) { 232 272 return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> ··· 236 276 key={item.key} 237 277 profile={item.profile} 238 278 moderationOpts={moderationOpts!} 239 - onPress={onSelectChat} 279 + onPress={did => onSelectChat({kind: 'user', did})} 240 280 /> 241 281 ) 242 282 } ··· 247 287 case 'empty': { 248 288 return <Empty key={item.key} message={item.message} /> 249 289 } 290 + case 'error': { 291 + return <Error key={item.key} message={l`Failed to load profiles`} /> 292 + } 250 293 default: 251 294 return null 252 295 } 253 296 }, 254 - [moderationOpts, onSelectChat, renderProfileCard], 297 + [moderationOpts, onSelectChat, renderProfileCard, l], 255 298 ) 256 299 257 300 useLayoutEffect(() => { ··· 293 336 </Text> 294 337 {IS_WEB ? ( 295 338 <Button 296 - label={_(msg`Close`)} 339 + label={l`Close`} 297 340 size="small" 298 341 shape="round" 299 342 variant={IS_WEB ? 'ghost' : 'solid'} ··· 328 371 t.atoms.border_contrast_low, 329 372 t.atoms.bg, 330 373 t.atoms.text_contrast_high, 331 - _, 374 + l, 332 375 title, 333 376 searchText, 334 377 control, ··· 364 407 onPress: (did: string) => void 365 408 }) { 366 409 const t = useTheme() 367 - const {_} = useLingui() 410 + const {t: l} = useLingui() 368 411 const enabled = canBeMessaged(profile) 369 412 const moderation = moderateProfile(profile, moderationOpts) 370 413 const handle = sanitizeHandle(profile.handle, '@') 371 - const displayName = sanitizeDisplayName( 372 - profile.displayName || sanitizeHandle(profile.handle), 414 + const displayName = createSanitizedDisplayName( 415 + profile, 416 + true, 373 417 moderation.ui('displayName'), 374 418 ) 375 419 ··· 380 424 return ( 381 425 <Button 382 426 disabled={!enabled} 383 - label={_(msg`Start chat with ${displayName}`)} 427 + label={l`Start chat with ${displayName}`} 384 428 onPress={handleOnPress}> 385 429 {({hovered, pressed, focused}) => ( 386 430 <View ··· 422 466 ) 423 467 } 424 468 469 + function ExistingChatCard({ 470 + convo, 471 + moderationOpts, 472 + onPress, 473 + }: { 474 + convo: ConvoWithDetails 475 + moderationOpts: ModerationOpts 476 + onPress: (convoId: string) => void 477 + }) { 478 + const t = useTheme() 479 + const {t: l} = useLingui() 480 + const enabled = 481 + convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true 482 + const moderation = moderateProfile(convo.primaryMember, moderationOpts) 483 + const name = 484 + convo.kind === 'group' 485 + ? convo.details.name 486 + : createSanitizedDisplayName( 487 + convo.primaryMember, 488 + true, 489 + moderation.ui('displayName'), 490 + ) 491 + 492 + const handleOnPress = useCallback(() => { 493 + onPress(convo.view.id) 494 + }, [onPress, convo.view.id]) 495 + 496 + return ( 497 + <Button 498 + disabled={!enabled} 499 + label={l`Select chat "${name}"`} 500 + onPress={handleOnPress}> 501 + {({hovered, pressed, focused}) => ( 502 + <View 503 + style={[ 504 + a.flex_1, 505 + a.py_sm, 506 + a.px_lg, 507 + !enabled 508 + ? {opacity: 0.5} 509 + : pressed || focused || hovered 510 + ? t.atoms.bg_contrast_25 511 + : t.atoms.bg, 512 + ]}> 513 + <ProfileCard.Header> 514 + {convo.kind === 'group' ? ( 515 + <AvatarBubbles profiles={convo.members} size="small" /> 516 + ) : ( 517 + <ProfileCard.Avatar 518 + profile={convo.primaryMember} 519 + moderationOpts={moderationOpts} 520 + disabledPreview 521 + /> 522 + )} 523 + <View style={[a.flex_1]}> 524 + <View style={[a.flex_row, a.align_center, a.max_w_full]}> 525 + <Text 526 + emoji 527 + style={[ 528 + a.text_md, 529 + a.font_semi_bold, 530 + a.leading_snug, 531 + a.self_start, 532 + a.flex_shrink, 533 + ]} 534 + numberOfLines={1}> 535 + {name} 536 + </Text> 537 + {convo.kind === 'direct' && ( 538 + <ProfileBadges 539 + profile={convo.primaryMember} 540 + size="md" 541 + style={[a.pl_xs]} 542 + /> 543 + )} 544 + </View> 545 + {convo.kind === 'direct' ? ( 546 + <ProfileCard.Handle profile={convo.primaryMember} /> 547 + ) : ( 548 + <> 549 + {enabled ? ( 550 + <Text 551 + style={[a.leading_snug, t.atoms.text_contrast_medium]} 552 + numberOfLines={2}> 553 + <Plural 554 + value={convo.members.length} 555 + one="# member" 556 + other="# members" 557 + /> 558 + </Text> 559 + ) : ( 560 + <Text 561 + style={[a.leading_snug, t.atoms.text_contrast_high]} 562 + numberOfLines={2}> 563 + <Trans>Group is locked</Trans> 564 + </Text> 565 + )} 566 + </> 567 + )} 568 + </View> 569 + </ProfileCard.Header> 570 + </View> 571 + )} 572 + </Button> 573 + ) 574 + } 575 + 425 576 function ProfileCardSkeleton() { 426 577 const t = useTheme() 427 578 ··· 488 639 inputRef: React.RefObject<TextInput | null> 489 640 }) { 490 641 const t = useTheme() 491 - const {_} = useLingui() 642 + const {t: l} = useLingui() 492 643 const { 493 644 state: hovered, 494 645 onIn: onMouseEnter, ··· 512 663 <TextInput 513 664 // @ts-ignore bottom sheet input types issue — esb 514 665 ref={inputRef} 515 - placeholder={_(msg`Search`)} 666 + placeholder={l`Search`} 516 667 value={value} 517 668 onChangeText={onChangeText} 518 669 onFocus={onFocus} ··· 532 683 autoComplete="off" 533 684 autoCapitalize="none" 534 685 autoFocus 535 - accessibilityLabel={_(msg`Search profiles`)} 536 - accessibilityHint={_(msg`Searches for profiles`)} 686 + accessibilityLabel={l`Search profiles`} 687 + accessibilityHint={l`Searches for profiles`} 537 688 /> 538 689 </View> 539 690 )
+16 -1
src/components/dms/dialogs/NewChatDialog.tsx
··· 77 77 [control, createGroupChat], 78 78 ) 79 79 80 + const onSelectExistingChat = useCallback( 81 + (chatId: string) => { 82 + control.close(() => { 83 + onNewChat(chatId) 84 + }) 85 + }, 86 + [control, onNewChat], 87 + ) 88 + 80 89 const onPress = useCallback(() => { 81 90 control.open() 82 91 }, [control]) ··· 112 121 ) : ( 113 122 <SearchablePeopleList 114 123 title={l`Start a new chat`} 115 - onSelectChat={onCreateChat} 124 + onSelectChat={chat => { 125 + if (chat.kind === 'user') { 126 + onCreateChat(chat.did) 127 + } else { 128 + onSelectExistingChat(chat.id) 129 + } 130 + }} 116 131 sortByMessageDeclaration 117 132 /> 118 133 )}
+14 -1
src/components/dms/dialogs/ShareViaChatDialog.tsx
··· 53 53 }, 54 54 }) 55 55 56 + const onSelectExistingChat = useCallback( 57 + (chatId: string) => { 58 + control.close(() => onSelectChat(chatId)) 59 + }, 60 + [control, onSelectChat], 61 + ) 62 + 56 63 const onCreateChat = useCallback( 57 64 (did: string) => { 58 65 control.close(() => createChat([did])) ··· 63 70 return ( 64 71 <SearchablePeopleList 65 72 title={_(msg`Send post to...`)} 66 - onSelectChat={onCreateChat} 73 + onSelectChat={chat => { 74 + if (chat.kind === 'user') { 75 + onCreateChat(chat.did) 76 + } else { 77 + onSelectExistingChat(chat.id) 78 + } 79 + }} 67 80 showRecentConvos 68 81 sortByMessageDeclaration 69 82 />
-1
src/components/dms/dialogs/TextInput.tsx
··· 1 - export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
-1
src/components/dms/dialogs/TextInput.web.tsx
··· 1 - export {TextInput} from 'react-native'
+99 -2
src/components/dms/util.ts
··· 1 - import {type ChatBskyConvoDefs} from '@atproto/api' 1 + import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 2 2 3 3 import {EMOJI_REACTION_LIMIT} from '#/lib/constants' 4 - import type * as bsky from '#/types/bsky' 4 + import {logger} from '#/logger' 5 + import * as bsky from '#/types/bsky' 5 6 6 7 export function canBeMessaged(profile: bsky.profile.AnyProfileView) { 7 8 switch (profile.associated?.chat?.allowIncoming) { ··· 54 55 ) 55 56 return myReactions.length >= EMOJI_REACTION_LIMIT 56 57 } 58 + 59 + type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & { 60 + // can be missing if account deleted 61 + kind?: $Typed<ChatBskyActorDefs.GroupConvoMember> 62 + } 63 + 64 + type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & { 65 + kind: $Typed<ChatBskyActorDefs.DirectConvoMember> 66 + } 67 + 68 + export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( 69 + | { 70 + kind: 'group' 71 + details: ChatBskyConvoDefs.GroupConvo 72 + primaryMember: GroupConvoMember // the owner 73 + members: Array<GroupConvoMember> 74 + } 75 + | { 76 + kind: 'direct' 77 + details: ChatBskyConvoDefs.DirectConvo 78 + primaryMember: DirectConvoMember // the other user 79 + members: Array<DirectConvoMember> 80 + } 81 + ) 82 + 83 + /** 84 + * Converts a raw convoView into something easier to use (i.e. extracts chat owner) 85 + * and enforces the correct type for convo members. 86 + */ 87 + export function parseConvoView( 88 + convoView: ChatBskyConvoDefs.ConvoView, 89 + ownDid: string | undefined, 90 + ): ConvoWithDetails | null { 91 + if ( 92 + bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 93 + convoView.kind, 94 + ChatBskyConvoDefs.isGroupConvo, 95 + ) 96 + ) { 97 + let owner: GroupConvoMember | undefined = undefined 98 + 99 + for (const member of convoView.members) { 100 + if ( 101 + bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( 102 + member.kind, 103 + ChatBskyActorDefs.isGroupConvoMember, 104 + ) 105 + ) { 106 + if (member.kind.role === 'owner') { 107 + // have to do a type assertion here 108 + // this works: {...member, kind: member.kind} 109 + // however that's creating a new object for no good reason 110 + owner = member as GroupConvoMember 111 + } 112 + } else { 113 + throw new Error( 114 + 'Expected a GroupConvoMember, got an unknown kind of member', 115 + ) 116 + } 117 + } 118 + 119 + if (!owner) { 120 + throw new Error('No owner found in group convo') 121 + } 122 + 123 + return { 124 + view: convoView, 125 + kind: 'group', 126 + details: convoView.kind, 127 + primaryMember: owner, 128 + members: convoView.members as Array<GroupConvoMember>, 129 + } 130 + } else if ( 131 + bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>( 132 + convoView.kind, 133 + ChatBskyConvoDefs.isDirectConvo, 134 + ) 135 + ) { 136 + const otherUser = convoView.members.find(m => m.did !== ownDid) 137 + 138 + if (!otherUser) { 139 + throw new Error('No other user found in direct convo') 140 + } 141 + 142 + return { 143 + view: convoView, 144 + kind: 'direct', 145 + details: convoView.kind, 146 + primaryMember: otherUser as DirectConvoMember, 147 + members: convoView.members as Array<DirectConvoMember>, 148 + } 149 + } else { 150 + logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind)) 151 + return null 152 + } 153 + }
+38 -76
src/screens/Messages/components/ChatListItem.tsx
··· 2 2 import {type GestureResponderEvent, View} from 'react-native' 3 3 import { 4 4 AppBskyEmbedRecord, 5 - ChatBskyActorDefs, 6 5 ChatBskyConvoDefs, 7 6 moderateProfile, 8 7 type ModerationDecision, ··· 38 37 import {useDialogControl} from '#/components/Dialog' 39 38 import {ConvoMenu} from '#/components/dms/ConvoMenu' 40 39 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 40 + import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' 41 41 import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' 42 42 import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' 43 43 import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash' ··· 49 49 import {Text} from '#/components/Typography' 50 50 import {useAnalytics} from '#/analytics' 51 51 import {IS_NATIVE} from '#/env' 52 - import * as bsky from '#/types/bsky' 52 + import type * as bsky from '#/types/bsky' 53 53 54 54 export const ChatListItemPortal = createPortalGroup() 55 55 ··· 60 60 */ 61 61 62 62 export function ChatListItem({ 63 - convo, 63 + convo: convoView, 64 64 showMenu = true, 65 65 children, 66 66 }: { ··· 75 75 return null 76 76 } 77 77 78 - if ( 79 - bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 80 - convo.kind, 81 - ChatBskyConvoDefs.isGroupConvo, 82 - ) 83 - ) { 84 - const owner = convo.members.find(r => { 85 - if ( 86 - bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( 87 - r.kind, 88 - ChatBskyActorDefs.isGroupConvoMember, 89 - ) 90 - ) { 91 - return r.kind.role === 'owner' 92 - } else { 93 - throw new Error( 94 - 'Expected a GroupConvoMember, got an unknown kind of member', 95 - ) 96 - } 97 - }) 98 - if (!owner) { 99 - // TODO: Determine if this is the right thing to do here. Throwing here so that 100 - // if it turns out to be wrong it'll be very visible 101 - throw new Error('Could not find the group owner in the group members') 102 - } 78 + const convo = parseConvoView(convoView, currentAccount?.did) 103 79 104 - return ( 105 - <GroupChatItem 106 - convo={convo} 107 - groupOwner={owner} 108 - groupInfo={convo.kind} 109 - moderationOpts={moderationOpts} 110 - showMenu={showMenu} 111 - /> 112 - ) 113 - } else if ( 114 - bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>( 115 - convo.kind, 116 - ChatBskyConvoDefs.isDirectConvo, 117 - ) 118 - ) { 119 - const otherMember = convo.members.find( 120 - member => member.did !== currentAccount?.did, 121 - ) 122 - 123 - if (!otherMember) { 80 + switch (convo?.kind) { 81 + case 'direct': { 82 + return ( 83 + <DirectChatItem 84 + convo={convo} 85 + moderationOpts={moderationOpts} 86 + showMenu={showMenu}> 87 + {children} 88 + </DirectChatItem> 89 + ) 90 + } 91 + case 'group': { 92 + return ( 93 + <GroupChatItem 94 + convo={convo} 95 + moderationOpts={moderationOpts} 96 + showMenu={showMenu} 97 + /> 98 + ) 99 + } 100 + default: { 124 101 return null 125 102 } 126 - return ( 127 - <DirectChatItem 128 - convo={convo} 129 - profile={otherMember} 130 - moderationOpts={moderationOpts} 131 - showMenu={showMenu}> 132 - {children} 133 - </DirectChatItem> 134 - ) 135 - } else { 136 - return null 137 103 } 138 104 } 139 105 140 106 function DirectChatItem({ 141 107 convo, 142 - profile: profileUnshadowed, 143 108 moderationOpts, 144 109 showMenu, 145 110 children, 146 111 }: { 147 - convo: ChatBskyConvoDefs.ConvoView 148 - profile: bsky.profile.AnyProfileView 112 + convo: Extract<ConvoWithDetails, {kind: 'direct'}> 149 113 moderationOpts: ModerationOpts 150 114 showMenu?: boolean 151 115 children?: React.ReactNode 152 116 }) { 153 117 const {t: l} = useLingui() 154 - const profile = useProfileShadow(profileUnshadowed) 118 + const profile = useProfileShadow(convo.primaryMember) 155 119 156 120 const moderation = useMemo( 157 121 () => moderateProfile(profile, moderationOpts), ··· 165 129 166 130 return ( 167 131 <BaseChatItem 168 - convo={convo} 132 + convo={convo.view} 169 133 avatar={ 170 134 <PreviewableUserAvatar 171 135 profile={profile} ··· 176 140 primaryProfile={profile} 177 141 primaryProfileModeration={moderation} 178 142 title={displayName} 179 - subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)} 143 + subtitle={ 144 + isDeletedAccount ? undefined : sanitizeHandle(profile.handle, '@') 145 + } 180 146 accessibilityHint={ 181 147 !isDeletedAccount 182 148 ? l`Go to conversation with ${profile.handle}` ··· 200 166 201 167 function GroupChatItem({ 202 168 convo, 203 - groupOwner: groupOwnerUnshadowed, 204 - groupInfo, 205 169 moderationOpts, 206 170 showMenu, 207 171 children, 208 172 }: { 209 - convo: ChatBskyConvoDefs.ConvoView 210 - groupOwner: bsky.profile.AnyProfileView 211 - groupInfo: ChatBskyConvoDefs.GroupConvo 173 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 212 174 moderationOpts: ModerationOpts 213 175 showMenu?: boolean 214 176 children?: React.ReactNode 215 177 }) { 216 178 const {t: l} = useLingui() 217 - const groupOwner = useProfileShadow(groupOwnerUnshadowed) 179 + const groupOwner = useProfileShadow(convo.primaryMember) 218 180 219 181 const moderation = useMemo( 220 182 () => moderateProfile(groupOwner, moderationOpts), 221 183 [groupOwner, moderationOpts], 222 184 ) 223 185 224 - const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` 186 + const chatName = convo.details.name 225 187 226 188 return ( 227 189 <BaseChatItem 228 - convo={convo} 190 + convo={convo.view} 229 191 avatar={<AvatarBubbles profiles={convo.members} size="medium" />} 230 192 title={chatName} 231 193 accessibilityHint={l`Go to the group chat named "${chatName}"`} ··· 507 469 label={title} 508 470 accessibilityHint={accessibilityHint} 509 471 accessibilityActions={ 510 - IS_NATIVE 472 + showMenu && IS_NATIVE 511 473 ? [ 512 474 { 513 475 name: 'magicTap', ··· 521 483 : undefined 522 484 } 523 485 onPress={onPress} 524 - onLongPress={IS_NATIVE ? onLongPress : undefined} 525 - onAccessibilityAction={onLongPress}> 486 + onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined} 487 + onAccessibilityAction={showMenu ? onLongPress : undefined}> 526 488 {({hovered, pressed, focused}) => ( 527 489 <View 528 490 style={[
+15 -12
src/screens/Messages/components/RequestListItem.tsx
··· 5 5 import {useModerationOpts} from '#/state/preferences/moderation-opts' 6 6 import {useSession} from '#/state/session' 7 7 import {atoms as a, tokens} from '#/alf' 8 + import {parseConvoView} from '#/components/dms/util' 8 9 import {KnownFollowers} from '#/components/KnownFollowers' 9 10 import {Text} from '#/components/Typography' 10 11 import {ChatListItem, ChatListItemPortal} from './ChatListItem' 11 12 import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' 12 13 13 - export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { 14 + export function RequestListItem({ 15 + convo: convoView, 16 + }: { 17 + convo: ChatBskyConvoDefs.ConvoView 18 + }) { 14 19 const {currentAccount} = useSession() 15 20 const moderationOpts = useModerationOpts() 16 21 17 - const otherUser = convo.members.find( 18 - member => member.did !== currentAccount?.did, 19 - ) 22 + const convo = parseConvoView(convoView, currentAccount?.did) 20 23 21 - if (!otherUser || !moderationOpts) { 24 + if (!convo || !moderationOpts) { 22 25 return null 23 26 } 24 27 25 - const isDeletedAccount = otherUser.handle === 'missing.invalid' 28 + const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid' 26 29 27 30 return ( 28 31 <View style={[a.relative, a.flex_1]}> 29 - <ChatListItem convo={convo} showMenu={false}> 32 + <ChatListItem convo={convo.view} showMenu={false}> 30 33 <View style={[a.pt_xs, a.pb_2xs]}> 31 34 <KnownFollowers 32 - profile={otherUser} 35 + profile={convo.primaryMember} 33 36 moderationOpts={moderationOpts} 34 37 minimal 35 38 showIfEmpty ··· 59 62 ]}> 60 63 {!isDeletedAccount ? ( 61 64 <> 62 - <AcceptChatButton convo={convo} currentScreen="list" /> 65 + <AcceptChatButton convo={convo.view} currentScreen="list" /> 63 66 <RejectMenu 64 - convo={convo} 65 - profile={otherUser} 67 + convo={convo.view} 68 + profile={convo.primaryMember} 66 69 showDeleteConvo 67 70 currentScreen="list" 68 71 /> 69 72 </> 70 73 ) : ( 71 74 <> 72 - <DeleteChatButton convo={convo} currentScreen="list" /> 75 + <DeleteChatButton convo={convo.view} currentScreen="list" /> 73 76 <View style={a.flex_1} /> 74 77 </> 75 78 )}
+5 -1
src/state/queries/messages/list-conversations.tsx
··· 24 24 export const RQKEY = ( 25 25 status: 'accepted' | 'request' | 'all', 26 26 readState: 'all' | 'unread' = 'all', 27 - ) => [RQKEY_ROOT, status, readState] 27 + kind: 'all' | 'group' | 'direct' = 'all', 28 + ) => [RQKEY_ROOT, status, readState, kind] 28 29 type RQPageParam = string | undefined 29 30 30 31 export function useListConvosQuery({ 31 32 enabled, 32 33 status, 33 34 readState = 'all', 35 + kind = 'all', 34 36 }: { 35 37 enabled?: boolean 36 38 status?: 'request' | 'accepted' 37 39 readState?: 'all' | 'unread' 40 + kind?: 'all' | 'group' | 'direct' 38 41 } = {}) { 39 42 const agent = useAgent() 40 43 ··· 47 50 limit: 20, 48 51 cursor: pageParam, 49 52 readState: readState === 'unread' ? 'unread' : undefined, 53 + kind: kind === 'all' ? undefined : kind, 50 54 status, 51 55 }, 52 56 {headers: DM_SERVICE_HEADERS},