Support group chats across other surfaces (#10266)

This commit is contained in:
Samuel Newman
2026-04-16 09:55:30 -07:00
committed by GitHub
parent cc8f22887f
commit f51602b3fe
11 changed files with 436 additions and 161 deletions
+17 -3
View File
@@ -18,7 +18,7 @@ import type * as bsky from '#/types/bsky'
type Props = { type Props = {
animate?: boolean animate?: boolean
profiles: bsky.profile.AnyProfileView[] profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large' | number
} }
export function AvatarBubbles({ export function AvatarBubbles({
@@ -28,8 +28,22 @@ export function AvatarBubbles({
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120 const containerSize =
const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1 typeof size === 'number'
? size
: size === 'small'
? 40
: size === 'medium'
? 56
: 120
const scale =
typeof size === 'number'
? size / 120
: size === 'small'
? 40 / 120
: size === 'medium'
? 56 / 120
: 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
const initialValue = animate ? 0 : 1 const initialValue = animate ? 0 : 1
@@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, tokens, useTheme} from '#/alf' import {atoms as a, tokens, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function RecentChats({ export function RecentChats({
postUri, postUri,
@@ -60,23 +60,24 @@ export function RecentChats({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
nestedScrollEnabled> nestedScrollEnabled>
{convos && convos.length > 0 ? ( {convos && convos.length > 0 ? (
convos.map(convo => { convos.map(c => {
const otherMember = convo.members.find( const convo = parseConvoView(c, currentAccount?.did)
member => member.did !== currentAccount?.did,
) if (!convo) return null
if ( if (
!otherMember || (convo.kind === 'direct' &&
otherMember.handle === 'missing.invalid' || convo.primaryMember.handle === 'missing.invalid') ||
convo.muted convo.view.muted
) ) {
return null return null
}
return ( return (
<RecentChatItem <RecentChatItem
key={convo.id} key={convo.view.id}
profile={otherMember} convo={convo}
onPress={() => onSelectChat(convo.id)} onPress={() => onSelectChat(convo.view.id)}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
) )
@@ -99,26 +100,33 @@ export function RecentChats({
const WIDTH = 80 const WIDTH = 80
function RecentChatItem({ function RecentChatItem({
profile: profileUnshadowed,
onPress, onPress,
moderationOpts, moderationOpts,
convo,
}: { }: {
profile: bsky.profile.AnyProfileView
onPress: () => void onPress: () => void
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
convo: ConvoWithDetails
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const profile = useProfileShadow(profileUnshadowed) const primaryProfile = useProfileShadow(convo.primaryMember)
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(primaryProfile, moderationOpts)
const name = sanitizeDisplayName( const name =
profile.displayName || sanitizeHandle(profile.handle), convo.kind === 'group'
moderation.ui('displayName'), ? convo.details.name
) : createSanitizedDisplayName(
primaryProfile,
true,
moderation.ui('displayName'),
)
if (isBlockedOrBlocking(profile) || isMuted(profile)) { if (
convo.kind === 'direct' &&
(isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile))
) {
return null return null
} }
@@ -133,12 +141,16 @@ function RecentChatItem({
a.justify_start, a.justify_start,
a.align_center, a.align_center,
]}> ]}>
<UserAvatar {convo.kind === 'group' ? (
avatar={profile.avatar} <AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
size={WIDTH - 8} ) : (
type={profile.associated?.labeler ? 'labeler' : 'user'} <UserAvatar
moderation={moderation.ui('avatar')} avatar={primaryProfile.avatar}
/> size={WIDTH - 8}
type={primaryProfile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
/>
)}
<View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}> <View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}>
<Text <Text
emoji emoji
@@ -146,7 +158,13 @@ function RecentChatItem({
numberOfLines={1}> numberOfLines={1}>
{name} {name}
</Text> </Text>
<ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} /> {convo.kind === 'direct' && (
<ProfileBadges
profile={primaryProfile}
size="xs"
style={[a.pl_2xs]}
/>
)}
</View> </View>
</Button> </Button>
) )
+184 -33
View File
@@ -8,11 +8,9 @@ import {
} from 'react' } from 'react'
import {TextInput, View} from 'react-native' import {TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api' import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
@@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf' import {android, atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util' import {
canBeMessaged,
type ConvoWithDetails,
parseConvoView,
} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {AvatarBubbles} from '../AvatarBubbles'
import {Error} from '../Error'
import {ProfileBadges} from '../ProfileBadges'
export type ProfileItem = { export type ProfileItem = {
type: 'profile' type: 'profile'
@@ -38,6 +43,12 @@ export type ProfileItem = {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
} }
type ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
type EmptyItem = { type EmptyItem = {
type: 'empty' type: 'empty'
key: string key: string
@@ -54,7 +65,12 @@ type ErrorItem = {
key: string key: string
} }
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem type Item =
| ProfileItem
| ExistingChatItem
| EmptyItem
| PlaceholderItem
| ErrorItem
export function SearchablePeopleList({ export function SearchablePeopleList({
title, title,
@@ -72,12 +88,14 @@ export function SearchablePeopleList({
onSelectChat?: undefined onSelectChat?: undefined
} }
| { | {
onSelectChat: (did: string) => void onSelectChat: (
chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string},
) => void
renderProfileCard?: undefined renderProfileCard?: undefined
} }
)) { )) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0) const [headerHeight, setHeaderHeight] = useState(0)
@@ -105,7 +123,7 @@ export function SearchablePeopleList({
_items.push({ _items.push({
type: 'empty', type: 'empty',
key: 'empty', key: 'empty',
message: _(msg`We're having network issues, try again`), message: l`We're having network issues, try again`,
}) })
} else if (searchText.length) { } else if (searchText.length) {
if (results?.length) { if (results?.length) {
@@ -139,20 +157,27 @@ export function SearchablePeopleList({
const usedDids = new Set() const usedDids = new Set()
for (const page of convos.pages) { for (const page of convos.pages) {
for (const convo of page.convos) { for (const convoView of page.convos) {
const profiles = convo.members.filter( const convo = parseConvoView(convoView, currentAccount?.did)
m => m.did !== currentAccount?.did,
)
for (const profile of profiles) { if (!convo) continue
if (usedDids.has(profile.did)) continue
usedDids.add(profile.did) if (convo.kind === 'group') {
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
} else {
if (convo.primaryMember.handle === 'missing.invalid') continue
if (usedDids.has(convo.primaryMember.did)) continue
usedDids.add(convo.primaryMember.did)
_items.push({ _items.push({
type: 'profile', type: 'existingChat',
key: profile.did, key: convo.view.id,
profile, convo: convo,
}) })
} }
} }
@@ -209,7 +234,7 @@ export function SearchablePeopleList({
return _items return _items
}, [ }, [
_, l,
searchText, searchText,
results, results,
isError, isError,
@@ -221,12 +246,27 @@ export function SearchablePeopleList({
]) ])
if (searchText && !isFetching && !items.length && !isError) { if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) items.push({type: 'empty', key: 'empty', message: l`No results`})
} }
const renderItems = useCallback( const renderItems = useCallback(
({item}: {item: Item}) => { ({item}: {item: Item}) => {
switch (item.type) { switch (item.type) {
case 'existingChat': {
if (renderProfileCard) {
// should be unreachable
return null
} else {
return (
<ExistingChatCard
key={item.key}
convo={item.convo}
moderationOpts={moderationOpts!}
onPress={id => onSelectChat({kind: 'convo', id})}
/>
)
}
}
case 'profile': { case 'profile': {
if (renderProfileCard) { if (renderProfileCard) {
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
@@ -236,7 +276,7 @@ export function SearchablePeopleList({
key={item.key} key={item.key}
profile={item.profile} profile={item.profile}
moderationOpts={moderationOpts!} moderationOpts={moderationOpts!}
onPress={onSelectChat} onPress={did => onSelectChat({kind: 'user', did})}
/> />
) )
} }
@@ -247,11 +287,14 @@ export function SearchablePeopleList({
case 'empty': { case 'empty': {
return <Empty key={item.key} message={item.message} /> return <Empty key={item.key} message={item.message} />
} }
case 'error': {
return <Error key={item.key} message={l`Failed to load profiles`} />
}
default: default:
return null return null
} }
}, },
[moderationOpts, onSelectChat, renderProfileCard], [moderationOpts, onSelectChat, renderProfileCard, l],
) )
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -293,7 +336,7 @@ export function SearchablePeopleList({
</Text> </Text>
{IS_WEB ? ( {IS_WEB ? (
<Button <Button
label={_(msg`Close`)} label={l`Close`}
size="small" size="small"
shape="round" shape="round"
variant={IS_WEB ? 'ghost' : 'solid'} variant={IS_WEB ? 'ghost' : 'solid'}
@@ -328,7 +371,7 @@ export function SearchablePeopleList({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
t.atoms.bg, t.atoms.bg,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
_, l,
title, title,
searchText, searchText,
control, control,
@@ -364,12 +407,13 @@ function DefaultProfileCard({
onPress: (did: string) => void onPress: (did: string) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const enabled = canBeMessaged(profile) const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@') const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName( const displayName = createSanitizedDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile,
true,
moderation.ui('displayName'), moderation.ui('displayName'),
) )
@@ -380,7 +424,7 @@ function DefaultProfileCard({
return ( return (
<Button <Button
disabled={!enabled} disabled={!enabled}
label={_(msg`Start chat with ${displayName}`)} label={l`Start chat with ${displayName}`}
onPress={handleOnPress}> onPress={handleOnPress}>
{({hovered, pressed, focused}) => ( {({hovered, pressed, focused}) => (
<View <View
@@ -422,6 +466,113 @@ function DefaultProfileCard({
) )
} }
function ExistingChatCard({
convo,
moderationOpts,
onPress,
}: {
convo: ConvoWithDetails
moderationOpts: ModerationOpts
onPress: (convoId: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const enabled =
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
const moderation = moderateProfile(convo.primaryMember, moderationOpts)
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
convo.primaryMember,
true,
moderation.ui('displayName'),
)
const handleOnPress = useCallback(() => {
onPress(convo.view.id)
}, [onPress, convo.view.id])
return (
<Button
disabled={!enabled}
label={l`Select chat "${name}"`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_1,
a.py_sm,
a.px_lg,
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size="small" />
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
moderationOpts={moderationOpts}
disabledPreview
/>
)}
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
{convo.kind === 'direct' && (
<ProfileBadges
profile={convo.primaryMember}
size="md"
style={[a.pl_xs]}
/>
)}
</View>
{convo.kind === 'direct' ? (
<ProfileCard.Handle profile={convo.primaryMember} />
) : (
<>
{enabled ? (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={2}>
<Plural
value={convo.members.length}
one="# member"
other="# members"
/>
</Text>
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>Group is locked</Trans>
</Text>
)}
</>
)}
</View>
</ProfileCard.Header>
</View>
)}
</Button>
)
}
function ProfileCardSkeleton() { function ProfileCardSkeleton() {
const t = useTheme() const t = useTheme()
@@ -488,7 +639,7 @@ function SearchInput({
inputRef: React.RefObject<TextInput | null> inputRef: React.RefObject<TextInput | null>
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const { const {
state: hovered, state: hovered,
onIn: onMouseEnter, onIn: onMouseEnter,
@@ -512,7 +663,7 @@ function SearchInput({
<TextInput <TextInput
// @ts-ignore bottom sheet input types issue — esb // @ts-ignore bottom sheet input types issue — esb
ref={inputRef} ref={inputRef}
placeholder={_(msg`Search`)} placeholder={l`Search`}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
onFocus={onFocus} onFocus={onFocus}
@@ -532,8 +683,8 @@ function SearchInput({
autoComplete="off" autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
autoFocus autoFocus
accessibilityLabel={_(msg`Search profiles`)} accessibilityLabel={l`Search profiles`}
accessibilityHint={_(msg`Searches for profiles`)} accessibilityHint={l`Searches for profiles`}
/> />
</View> </View>
) )
+16 -1
View File
@@ -77,6 +77,15 @@ export function NewChat({
[control, createGroupChat], [control, createGroupChat],
) )
const onSelectExistingChat = useCallback(
(chatId: string) => {
control.close(() => {
onNewChat(chatId)
})
},
[control, onNewChat],
)
const onPress = useCallback(() => { const onPress = useCallback(() => {
control.open() control.open()
}, [control]) }, [control])
@@ -112,7 +121,13 @@ export function NewChat({
) : ( ) : (
<SearchablePeopleList <SearchablePeopleList
title={l`Start a new chat`} title={l`Start a new chat`}
onSelectChat={onCreateChat} onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
} else {
onSelectExistingChat(chat.id)
}
}}
sortByMessageDeclaration sortByMessageDeclaration
/> />
)} )}
@@ -53,6 +53,13 @@ function SendViaChatDialogInner({
}, },
}) })
const onSelectExistingChat = useCallback(
(chatId: string) => {
control.close(() => onSelectChat(chatId))
},
[control, onSelectChat],
)
const onCreateChat = useCallback( const onCreateChat = useCallback(
(did: string) => { (did: string) => {
control.close(() => createChat([did])) control.close(() => createChat([did]))
@@ -63,7 +70,13 @@ function SendViaChatDialogInner({
return ( return (
<SearchablePeopleList <SearchablePeopleList
title={_(msg`Send post to...`)} title={_(msg`Send post to...`)}
onSelectChat={onCreateChat} onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
} else {
onSelectExistingChat(chat.id)
}
}}
showRecentConvos showRecentConvos
sortByMessageDeclaration sortByMessageDeclaration
/> />
-1
View File
@@ -1 +0,0 @@
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
@@ -1 +0,0 @@
export {TextInput} from 'react-native'
+99 -2
View File
@@ -1,7 +1,8 @@
import {type ChatBskyConvoDefs} from '@atproto/api' import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants' import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import type * as bsky from '#/types/bsky' import {logger} from '#/logger'
import * as bsky from '#/types/bsky'
export function canBeMessaged(profile: bsky.profile.AnyProfileView) { export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) { switch (profile.associated?.chat?.allowIncoming) {
@@ -54,3 +55,99 @@ export function hasReachedReactionLimit(
) )
return myReactions.length >= EMOJI_REACTION_LIMIT return myReactions.length >= EMOJI_REACTION_LIMIT
} }
type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
// can be missing if account deleted
kind?: $Typed<ChatBskyActorDefs.GroupConvoMember>
}
type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
kind: $Typed<ChatBskyActorDefs.DirectConvoMember>
}
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
| {
kind: 'group'
details: ChatBskyConvoDefs.GroupConvo
primaryMember: GroupConvoMember // the owner
members: Array<GroupConvoMember>
}
| {
kind: 'direct'
details: ChatBskyConvoDefs.DirectConvo
primaryMember: DirectConvoMember // the other user
members: Array<DirectConvoMember>
}
)
/**
* Converts a raw convoView into something easier to use (i.e. extracts chat owner)
* and enforces the correct type for convo members.
*/
export function parseConvoView(
convoView: ChatBskyConvoDefs.ConvoView,
ownDid: string | undefined,
): ConvoWithDetails | null {
if (
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
convoView.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
let owner: GroupConvoMember | undefined = undefined
for (const member of convoView.members) {
if (
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
member.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
) {
if (member.kind.role === 'owner') {
// have to do a type assertion here
// this works: {...member, kind: member.kind}
// however that's creating a new object for no good reason
owner = member as GroupConvoMember
}
} else {
throw new Error(
'Expected a GroupConvoMember, got an unknown kind of member',
)
}
}
if (!owner) {
throw new Error('No owner found in group convo')
}
return {
view: convoView,
kind: 'group',
details: convoView.kind,
primaryMember: owner,
members: convoView.members as Array<GroupConvoMember>,
}
} else if (
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
convoView.kind,
ChatBskyConvoDefs.isDirectConvo,
)
) {
const otherUser = convoView.members.find(m => m.did !== ownDid)
if (!otherUser) {
throw new Error('No other user found in direct convo')
}
return {
view: convoView,
kind: 'direct',
details: convoView.kind,
primaryMember: otherUser as DirectConvoMember,
members: convoView.members as Array<DirectConvoMember>,
}
} else {
logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind))
return null
}
}
@@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native' import {type GestureResponderEvent, View} from 'react-native'
import { import {
AppBskyEmbedRecord, AppBskyEmbedRecord,
ChatBskyActorDefs,
ChatBskyConvoDefs, ChatBskyConvoDefs,
moderateProfile, moderateProfile,
type ModerationDecision, type ModerationDecision,
@@ -38,6 +37,7 @@ import {AvatarBubbles} from '#/components/AvatarBubbles'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu' import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen'
import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash'
@@ -49,7 +49,7 @@ import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export const ChatListItemPortal = createPortalGroup() export const ChatListItemPortal = createPortalGroup()
@@ -60,7 +60,7 @@ export const ChatListItemPortal = createPortalGroup()
*/ */
export function ChatListItem({ export function ChatListItem({
convo, convo: convoView,
showMenu = true, showMenu = true,
children, children,
}: { }: {
@@ -75,83 +75,47 @@ export function ChatListItem({
return null return null
} }
if ( const convo = parseConvoView(convoView, currentAccount?.did)
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
convo.kind, switch (convo?.kind) {
ChatBskyConvoDefs.isGroupConvo, case 'direct': {
) return (
) { <DirectChatItem
const owner = convo.members.find(r => { convo={convo}
if ( moderationOpts={moderationOpts}
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( showMenu={showMenu}>
r.kind, {children}
ChatBskyActorDefs.isGroupConvoMember, </DirectChatItem>
) )
) {
return r.kind.role === 'owner'
} else {
throw new Error(
'Expected a GroupConvoMember, got an unknown kind of member',
)
}
})
if (!owner) {
// TODO: Determine if this is the right thing to do here. Throwing here so that
// if it turns out to be wrong it'll be very visible
throw new Error('Could not find the group owner in the group members')
} }
case 'group': {
return ( return (
<GroupChatItem <GroupChatItem
convo={convo} convo={convo}
groupOwner={owner} moderationOpts={moderationOpts}
groupInfo={convo.kind} showMenu={showMenu}
moderationOpts={moderationOpts} />
showMenu={showMenu} )
/> }
) default: {
} else if (
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
convo.kind,
ChatBskyConvoDefs.isDirectConvo,
)
) {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherMember) {
return null return null
} }
return (
<DirectChatItem
convo={convo}
profile={otherMember}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</DirectChatItem>
)
} else {
return null
} }
} }
function DirectChatItem({ function DirectChatItem({
convo, convo,
profile: profileUnshadowed,
moderationOpts, moderationOpts,
showMenu, showMenu,
children, children,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: Extract<ConvoWithDetails, {kind: 'direct'}>
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
showMenu?: boolean showMenu?: boolean
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(convo.primaryMember)
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
@@ -165,7 +129,7 @@ function DirectChatItem({
return ( return (
<BaseChatItem <BaseChatItem
convo={convo} convo={convo.view}
avatar={ avatar={
<PreviewableUserAvatar <PreviewableUserAvatar
profile={profile} profile={profile}
@@ -176,7 +140,9 @@ function DirectChatItem({
primaryProfile={profile} primaryProfile={profile}
primaryProfileModeration={moderation} primaryProfileModeration={moderation}
title={displayName} title={displayName}
subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)} subtitle={
isDeletedAccount ? undefined : sanitizeHandle(profile.handle, '@')
}
accessibilityHint={ accessibilityHint={
!isDeletedAccount !isDeletedAccount
? l`Go to conversation with ${profile.handle}` ? l`Go to conversation with ${profile.handle}`
@@ -200,32 +166,28 @@ function DirectChatItem({
function GroupChatItem({ function GroupChatItem({
convo, convo,
groupOwner: groupOwnerUnshadowed,
groupInfo,
moderationOpts, moderationOpts,
showMenu, showMenu,
children, children,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: Extract<ConvoWithDetails, {kind: 'group'}>
groupOwner: bsky.profile.AnyProfileView
groupInfo: ChatBskyConvoDefs.GroupConvo
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
showMenu?: boolean showMenu?: boolean
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const groupOwner = useProfileShadow(groupOwnerUnshadowed) const groupOwner = useProfileShadow(convo.primaryMember)
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(groupOwner, moderationOpts), () => moderateProfile(groupOwner, moderationOpts),
[groupOwner, moderationOpts], [groupOwner, moderationOpts],
) )
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat` const chatName = convo.details.name
return ( return (
<BaseChatItem <BaseChatItem
convo={convo} convo={convo.view}
avatar={<AvatarBubbles profiles={convo.members} size="medium" />} avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
title={chatName} title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`} accessibilityHint={l`Go to the group chat named "${chatName}"`}
@@ -507,7 +469,7 @@ function BaseChatItem({
label={title} label={title}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
accessibilityActions={ accessibilityActions={
IS_NATIVE showMenu && IS_NATIVE
? [ ? [
{ {
name: 'magicTap', name: 'magicTap',
@@ -521,8 +483,8 @@ function BaseChatItem({
: undefined : undefined
} }
onPress={onPress} onPress={onPress}
onLongPress={IS_NATIVE ? onLongPress : undefined} onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined}
onAccessibilityAction={onLongPress}> onAccessibilityAction={showMenu ? onLongPress : undefined}>
{({hovered, pressed, focused}) => ( {({hovered, pressed, focused}) => (
<View <View
style={[ style={[
@@ -5,31 +5,34 @@ import {Trans} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, tokens} from '#/alf' import {atoms as a, tokens} from '#/alf'
import {parseConvoView} from '#/components/dms/util'
import {KnownFollowers} from '#/components/KnownFollowers' import {KnownFollowers} from '#/components/KnownFollowers'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ChatListItem, ChatListItemPortal} from './ChatListItem' import {ChatListItem, ChatListItemPortal} from './ChatListItem'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { export function RequestListItem({
convo: convoView,
}: {
convo: ChatBskyConvoDefs.ConvoView
}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const otherUser = convo.members.find( const convo = parseConvoView(convoView, currentAccount?.did)
member => member.did !== currentAccount?.did,
)
if (!otherUser || !moderationOpts) { if (!convo || !moderationOpts) {
return null return null
} }
const isDeletedAccount = otherUser.handle === 'missing.invalid' const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid'
return ( return (
<View style={[a.relative, a.flex_1]}> <View style={[a.relative, a.flex_1]}>
<ChatListItem convo={convo} showMenu={false}> <ChatListItem convo={convo.view} showMenu={false}>
<View style={[a.pt_xs, a.pb_2xs]}> <View style={[a.pt_xs, a.pb_2xs]}>
<KnownFollowers <KnownFollowers
profile={otherUser} profile={convo.primaryMember}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
minimal minimal
showIfEmpty showIfEmpty
@@ -59,17 +62,17 @@ export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
]}> ]}>
{!isDeletedAccount ? ( {!isDeletedAccount ? (
<> <>
<AcceptChatButton convo={convo} currentScreen="list" /> <AcceptChatButton convo={convo.view} currentScreen="list" />
<RejectMenu <RejectMenu
convo={convo} convo={convo.view}
profile={otherUser} profile={convo.primaryMember}
showDeleteConvo showDeleteConvo
currentScreen="list" currentScreen="list"
/> />
</> </>
) : ( ) : (
<> <>
<DeleteChatButton convo={convo} currentScreen="list" /> <DeleteChatButton convo={convo.view} currentScreen="list" />
<View style={a.flex_1} /> <View style={a.flex_1} />
</> </>
)} )}
@@ -24,17 +24,20 @@ export const RQKEY_ROOT = 'convo-list'
export const RQKEY = ( export const RQKEY = (
status: 'accepted' | 'request' | 'all', status: 'accepted' | 'request' | 'all',
readState: 'all' | 'unread' = 'all', readState: 'all' | 'unread' = 'all',
) => [RQKEY_ROOT, status, readState] kind: 'all' | 'group' | 'direct' = 'all',
) => [RQKEY_ROOT, status, readState, kind]
type RQPageParam = string | undefined type RQPageParam = string | undefined
export function useListConvosQuery({ export function useListConvosQuery({
enabled, enabled,
status, status,
readState = 'all', readState = 'all',
kind = 'all',
}: { }: {
enabled?: boolean enabled?: boolean
status?: 'request' | 'accepted' status?: 'request' | 'accepted'
readState?: 'all' | 'unread' readState?: 'all' | 'unread'
kind?: 'all' | 'group' | 'direct'
} = {}) { } = {}) {
const agent = useAgent() const agent = useAgent()
@@ -47,6 +50,7 @@ export function useListConvosQuery({
limit: 20, limit: 20,
cursor: pageParam, cursor: pageParam,
readState: readState === 'unread' ? 'unread' : undefined, readState: readState === 'unread' ? 'unread' : undefined,
kind: kind === 'all' ? undefined : kind,
status, status,
}, },
{headers: DM_SERVICE_HEADERS}, {headers: DM_SERVICE_HEADERS},