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 = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large'
size?: 'small' | 'medium' | 'large' | number
}
export function AvatarBubbles({
@@ -28,8 +28,22 @@ export function AvatarBubbles({
}: Props) {
const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1
const containerSize =
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 initialValue = animate ? 0 : 1
@@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
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 {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, tokens, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button} from '#/components/Button'
import {useDialogContext} from '#/components/Dialog'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function RecentChats({
postUri,
@@ -60,23 +60,24 @@ export function RecentChats({
showsHorizontalScrollIndicator={false}
nestedScrollEnabled>
{convos && convos.length > 0 ? (
convos.map(convo => {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
convos.map(c => {
const convo = parseConvoView(c, currentAccount?.did)
if (!convo) return null
if (
!otherMember ||
otherMember.handle === 'missing.invalid' ||
convo.muted
)
(convo.kind === 'direct' &&
convo.primaryMember.handle === 'missing.invalid') ||
convo.view.muted
) {
return null
}
return (
<RecentChatItem
key={convo.id}
profile={otherMember}
onPress={() => onSelectChat(convo.id)}
key={convo.view.id}
convo={convo}
onPress={() => onSelectChat(convo.view.id)}
moderationOpts={moderationOpts}
/>
)
@@ -99,26 +100,33 @@ export function RecentChats({
const WIDTH = 80
function RecentChatItem({
profile: profileUnshadowed,
onPress,
moderationOpts,
convo,
}: {
profile: bsky.profile.AnyProfileView
onPress: () => void
moderationOpts: ModerationOpts
convo: ConvoWithDetails
}) {
const {_} = useLingui()
const t = useTheme()
const profile = useProfileShadow(profileUnshadowed)
const primaryProfile = useProfileShadow(convo.primaryMember)
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const moderation = moderateProfile(primaryProfile, moderationOpts)
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
primaryProfile,
true,
moderation.ui('displayName'),
)
if (isBlockedOrBlocking(profile) || isMuted(profile)) {
if (
convo.kind === 'direct' &&
(isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile))
) {
return null
}
@@ -133,12 +141,16 @@ function RecentChatItem({
a.justify_start,
a.align_center,
]}>
<UserAvatar
avatar={profile.avatar}
size={WIDTH - 8}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
/>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
) : (
<UserAvatar
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]}>
<Text
emoji
@@ -146,7 +158,13 @@ function RecentChatItem({
numberOfLines={1}>
{name}
</Text>
<ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} />
{convo.kind === 'direct' && (
<ProfileBadges
profile={primaryProfile}
size="xs"
style={[a.pl_2xs]}
/>
)}
</View>
</Button>
)
+184 -33
View File
@@ -8,11 +8,9 @@ import {
} from 'react'
import {TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Plural, Trans, useLingui} 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 {useModerationOpts} from '#/state/preferences/moderation-opts'
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 {Button, ButtonIcon} from '#/components/Button'
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 {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
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 {IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {AvatarBubbles} from '../AvatarBubbles'
import {Error} from '../Error'
import {ProfileBadges} from '../ProfileBadges'
export type ProfileItem = {
type: 'profile'
@@ -38,6 +43,12 @@ export type ProfileItem = {
profile: bsky.profile.AnyProfileView
}
type ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
type EmptyItem = {
type: 'empty'
key: string
@@ -54,7 +65,12 @@ type ErrorItem = {
key: string
}
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
type Item =
| ProfileItem
| ExistingChatItem
| EmptyItem
| PlaceholderItem
| ErrorItem
export function SearchablePeopleList({
title,
@@ -72,12 +88,14 @@ export function SearchablePeopleList({
onSelectChat?: undefined
}
| {
onSelectChat: (did: string) => void
onSelectChat: (
chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string},
) => void
renderProfileCard?: undefined
}
)) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
@@ -105,7 +123,7 @@ export function SearchablePeopleList({
_items.push({
type: '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) {
if (results?.length) {
@@ -139,20 +157,27 @@ export function SearchablePeopleList({
const usedDids = new Set()
for (const page of convos.pages) {
for (const convo of page.convos) {
const profiles = convo.members.filter(
m => m.did !== currentAccount?.did,
)
for (const convoView of page.convos) {
const convo = parseConvoView(convoView, currentAccount?.did)
for (const profile of profiles) {
if (usedDids.has(profile.did)) continue
if (!convo) 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({
type: 'profile',
key: profile.did,
profile,
type: 'existingChat',
key: convo.view.id,
convo: convo,
})
}
}
@@ -209,7 +234,7 @@ export function SearchablePeopleList({
return _items
}, [
_,
l,
searchText,
results,
isError,
@@ -221,12 +246,27 @@ export function SearchablePeopleList({
])
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(
({item}: {item: Item}) => {
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': {
if (renderProfileCard) {
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
@@ -236,7 +276,7 @@ export function SearchablePeopleList({
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
onPress={did => onSelectChat({kind: 'user', did})}
/>
)
}
@@ -247,11 +287,14 @@ export function SearchablePeopleList({
case 'empty': {
return <Empty key={item.key} message={item.message} />
}
case 'error': {
return <Error key={item.key} message={l`Failed to load profiles`} />
}
default:
return null
}
},
[moderationOpts, onSelectChat, renderProfileCard],
[moderationOpts, onSelectChat, renderProfileCard, l],
)
useLayoutEffect(() => {
@@ -293,7 +336,7 @@ export function SearchablePeopleList({
</Text>
{IS_WEB ? (
<Button
label={_(msg`Close`)}
label={l`Close`}
size="small"
shape="round"
variant={IS_WEB ? 'ghost' : 'solid'}
@@ -328,7 +371,7 @@ export function SearchablePeopleList({
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
_,
l,
title,
searchText,
control,
@@ -364,12 +407,13 @@ function DefaultProfileCard({
onPress: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
const displayName = createSanitizedDisplayName(
profile,
true,
moderation.ui('displayName'),
)
@@ -380,7 +424,7 @@ function DefaultProfileCard({
return (
<Button
disabled={!enabled}
label={_(msg`Start chat with ${displayName}`)}
label={l`Start chat with ${displayName}`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<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() {
const t = useTheme()
@@ -488,7 +639,7 @@ function SearchInput({
inputRef: React.RefObject<TextInput | null>
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {
state: hovered,
onIn: onMouseEnter,
@@ -512,7 +663,7 @@ function SearchInput({
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={_(msg`Search`)}
placeholder={l`Search`}
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
@@ -532,8 +683,8 @@ function SearchInput({
autoComplete="off"
autoCapitalize="none"
autoFocus
accessibilityLabel={_(msg`Search profiles`)}
accessibilityHint={_(msg`Searches for profiles`)}
accessibilityLabel={l`Search profiles`}
accessibilityHint={l`Searches for profiles`}
/>
</View>
)
+16 -1
View File
@@ -77,6 +77,15 @@ export function NewChat({
[control, createGroupChat],
)
const onSelectExistingChat = useCallback(
(chatId: string) => {
control.close(() => {
onNewChat(chatId)
})
},
[control, onNewChat],
)
const onPress = useCallback(() => {
control.open()
}, [control])
@@ -112,7 +121,13 @@ export function NewChat({
) : (
<SearchablePeopleList
title={l`Start a new chat`}
onSelectChat={onCreateChat}
onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
} else {
onSelectExistingChat(chat.id)
}
}}
sortByMessageDeclaration
/>
)}
@@ -53,6 +53,13 @@ function SendViaChatDialogInner({
},
})
const onSelectExistingChat = useCallback(
(chatId: string) => {
control.close(() => onSelectChat(chatId))
},
[control, onSelectChat],
)
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
@@ -63,7 +70,13 @@ function SendViaChatDialogInner({
return (
<SearchablePeopleList
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
} else {
onSelectExistingChat(chat.id)
}
}}
showRecentConvos
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 type * as bsky from '#/types/bsky'
import {logger} from '#/logger'
import * as bsky from '#/types/bsky'
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) {
@@ -54,3 +55,99 @@ export function hasReachedReactionLimit(
)
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 {
AppBskyEmbedRecord,
ChatBskyActorDefs,
ChatBskyConvoDefs,
moderateProfile,
type ModerationDecision,
@@ -38,6 +37,7 @@ import {AvatarBubbles} from '#/components/AvatarBubbles'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
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 {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen'
import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash'
@@ -49,7 +49,7 @@ import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
export const ChatListItemPortal = createPortalGroup()
@@ -60,7 +60,7 @@ export const ChatListItemPortal = createPortalGroup()
*/
export function ChatListItem({
convo,
convo: convoView,
showMenu = true,
children,
}: {
@@ -75,83 +75,47 @@ export function ChatListItem({
return null
}
if (
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
const owner = convo.members.find(r => {
if (
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
r.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
) {
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')
const convo = parseConvoView(convoView, currentAccount?.did)
switch (convo?.kind) {
case 'direct': {
return (
<DirectChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</DirectChatItem>
)
}
return (
<GroupChatItem
convo={convo}
groupOwner={owner}
groupInfo={convo.kind}
moderationOpts={moderationOpts}
showMenu={showMenu}
/>
)
} else if (
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
convo.kind,
ChatBskyConvoDefs.isDirectConvo,
)
) {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherMember) {
case 'group': {
return (
<GroupChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}
/>
)
}
default: {
return null
}
return (
<DirectChatItem
convo={convo}
profile={otherMember}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</DirectChatItem>
)
} else {
return null
}
}
function DirectChatItem({
convo,
profile: profileUnshadowed,
moderationOpts,
showMenu,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: bsky.profile.AnyProfileView
convo: Extract<ConvoWithDetails, {kind: 'direct'}>
moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const profile = useProfileShadow(convo.primaryMember)
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
@@ -165,7 +129,7 @@ function DirectChatItem({
return (
<BaseChatItem
convo={convo}
convo={convo.view}
avatar={
<PreviewableUserAvatar
profile={profile}
@@ -176,7 +140,9 @@ function DirectChatItem({
primaryProfile={profile}
primaryProfileModeration={moderation}
title={displayName}
subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)}
subtitle={
isDeletedAccount ? undefined : sanitizeHandle(profile.handle, '@')
}
accessibilityHint={
!isDeletedAccount
? l`Go to conversation with ${profile.handle}`
@@ -200,32 +166,28 @@ function DirectChatItem({
function GroupChatItem({
convo,
groupOwner: groupOwnerUnshadowed,
groupInfo,
moderationOpts,
showMenu,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
groupOwner: bsky.profile.AnyProfileView
groupInfo: ChatBskyConvoDefs.GroupConvo
convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const groupOwner = useProfileShadow(groupOwnerUnshadowed)
const groupOwner = useProfileShadow(convo.primaryMember)
const moderation = useMemo(
() => moderateProfile(groupOwner, moderationOpts),
[groupOwner, moderationOpts],
)
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat`
const chatName = convo.details.name
return (
<BaseChatItem
convo={convo}
convo={convo.view}
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
@@ -507,7 +469,7 @@ function BaseChatItem({
label={title}
accessibilityHint={accessibilityHint}
accessibilityActions={
IS_NATIVE
showMenu && IS_NATIVE
? [
{
name: 'magicTap',
@@ -521,8 +483,8 @@ function BaseChatItem({
: undefined
}
onPress={onPress}
onLongPress={IS_NATIVE ? onLongPress : undefined}
onAccessibilityAction={onLongPress}>
onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined}
onAccessibilityAction={showMenu ? onLongPress : undefined}>
{({hovered, pressed, focused}) => (
<View
style={[
@@ -5,31 +5,34 @@ import {Trans} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, tokens} from '#/alf'
import {parseConvoView} from '#/components/dms/util'
import {KnownFollowers} from '#/components/KnownFollowers'
import {Text} from '#/components/Typography'
import {ChatListItem, ChatListItemPortal} from './ChatListItem'
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 moderationOpts = useModerationOpts()
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
const convo = parseConvoView(convoView, currentAccount?.did)
if (!otherUser || !moderationOpts) {
if (!convo || !moderationOpts) {
return null
}
const isDeletedAccount = otherUser.handle === 'missing.invalid'
const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid'
return (
<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]}>
<KnownFollowers
profile={otherUser}
profile={convo.primaryMember}
moderationOpts={moderationOpts}
minimal
showIfEmpty
@@ -59,17 +62,17 @@ export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
]}>
{!isDeletedAccount ? (
<>
<AcceptChatButton convo={convo} currentScreen="list" />
<AcceptChatButton convo={convo.view} currentScreen="list" />
<RejectMenu
convo={convo}
profile={otherUser}
convo={convo.view}
profile={convo.primaryMember}
showDeleteConvo
currentScreen="list"
/>
</>
) : (
<>
<DeleteChatButton convo={convo} currentScreen="list" />
<DeleteChatButton convo={convo.view} currentScreen="list" />
<View style={a.flex_1} />
</>
)}
@@ -24,17 +24,20 @@ export const RQKEY_ROOT = 'convo-list'
export const RQKEY = (
status: 'accepted' | 'request' | 'all',
readState: 'all' | 'unread' = 'all',
) => [RQKEY_ROOT, status, readState]
kind: 'all' | 'group' | 'direct' = 'all',
) => [RQKEY_ROOT, status, readState, kind]
type RQPageParam = string | undefined
export function useListConvosQuery({
enabled,
status,
readState = 'all',
kind = 'all',
}: {
enabled?: boolean
status?: 'request' | 'accepted'
readState?: 'all' | 'unread'
kind?: 'all' | 'group' | 'direct'
} = {}) {
const agent = useAgent()
@@ -47,6 +50,7 @@ export function useListConvosQuery({
limit: 20,
cursor: pageParam,
readState: readState === 'unread' ? 'unread' : undefined,
kind: kind === 'all' ? undefined : kind,
status,
},
{headers: DM_SERVICE_HEADERS},