Add full member list to settings

This commit is contained in:
Samuel Newman
2026-04-24 15:38:57 +03:00
parent 263474a0b9
commit 180008c7d5
5 changed files with 152 additions and 47 deletions
@@ -11,9 +11,11 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {type ConvoWithDetails} from '#/components/dms/util' import {
type ConvoWithDetails,
type GroupConvoMember,
} from '#/components/dms/util'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {MemberMenu} from './MemberMenu' import {MemberMenu} from './MemberMenu'
import {StatusBadge} from './StatusBadge' import {StatusBadge} from './StatusBadge'
import {SubtleHoverWrapper} from './SubtleHoverWrapper' import {SubtleHoverWrapper} from './SubtleHoverWrapper'
@@ -25,7 +27,7 @@ export function Member({
isOwner, isOwner,
}: { }: {
convo: ConvoWithDetails convo: ConvoWithDetails
profile: bsky.profile.AnyProfileView profile: GroupConvoMember
status: 'owner' | 'standard' | 'invited' status: 'owner' | 'standard' | 'invited'
isOwner: boolean isOwner: boolean
}) { }) {
@@ -36,12 +38,10 @@ export function Member({
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(profileUnshadowed)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = moderationOpts
? moderateProfile(profile, moderationOpts)
: undefined
// TODO Render a skeleton here. -dsb // TODO Render a skeleton here. -dsb
if (!moderation) return null if (!moderationOpts) return null
const moderation = moderateProfile(profile, moderationOpts)
const isDeletedAccount = profile.handle === 'missing.invalid' const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount const displayName = isDeletedAccount
@@ -66,6 +66,14 @@ export function Member({
) )
} }
const joinedReason = profile.kind?.addedBy
? l`Added by ${createSanitizedDisplayName(
profile.kind.addedBy,
true,
moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'),
)}`
: `Added by invite link`
return ( return (
<SubtleHoverWrapper> <SubtleHoverWrapper>
<View style={[a.flex_row, a.align_center, a.justify_between, a.mx_xl]}> <View style={[a.flex_row, a.align_center, a.justify_between, a.mx_xl]}>
@@ -94,6 +102,14 @@ export function Member({
]}> ]}>
{sanitizeHandle(profile.handle, '@')} {sanitizeHandle(profile.handle, '@')}
</Text> </Text>
<Text
style={[
a.text_xs,
{color: t.palette.contrast_500},
web(a.pt_2xs),
]}>
{joinedReason}
</Text>
</View> </View>
</Pressable> </Pressable>
<View>{statusBadge}</View> <View>{statusBadge}</View>
@@ -11,10 +11,11 @@ import {
type NavigationProp, type NavigationProp,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ConvoProvider, useConvo} from '#/state/messages/convo' import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types' import {ConvoStatus} from '#/state/messages/convo/types'
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
import {useMuteConvo} from '#/state/queries/messages/mute-conversation' import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -23,7 +24,10 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles' import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util' import {
type ConvoWithDetails,
type GroupConvoMember,
} from '#/components/dms/util'
import {Error} from '#/components/Error' import {Error} from '#/components/Error'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import { import {
@@ -37,11 +41,10 @@ import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/Ed
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from '../components/InviteLinkDialog' import {InviteLinkDialog} from '../components/InviteLinkDialog'
import {AddMembersLink} from './AddMembersLink' import {AddMembersLink} from './AddMembersLink'
import {ROW_SPACING} from './constants' import {ROW_SPACING} from './constants'
@@ -56,17 +59,14 @@ const dateFormatter = new Intl.DateTimeFormat(undefined, {
}) })
type Item = type Item =
| { | {type: 'MEMBERS_AND_REQUESTS'}
type: 'MEMBERS_AND_REQUESTS' | {type: 'ADD_MEMBERS_LINK'}
}
| {
type: 'ADD_MEMBERS_LINK'
}
| { | {
type: 'CHAT_MEMBER' type: 'CHAT_MEMBER'
profile: bsky.profile.AnyProfileView profile: GroupConvoMember
status: 'owner' | 'standard' | 'invited' status: 'owner' | 'standard' | 'invited'
} }
| {type: 'LOADING'}
type Props = NativeStackScreenProps< type Props = NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
@@ -90,36 +90,84 @@ export function MessagesConversationSettingsScreen({route}: Props) {
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
<ConvoProvider key={convoId} convoId={convoId}> <ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner convoId={convoId} /> <SettingsInner />
</ConvoProvider> </ConvoProvider>
</Layout.Screen> </Layout.Screen>
) )
} }
function SettingsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<Error
title={l`Something went wrong`}
message={l`We couldn’t load this conversation’s settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
)
}
if (!isConvoActive(convoState)) {
return (
<Layout.Content
contentContainerStyle={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</Layout.Content>
)
}
if (convoState.convo?.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
message={l`This screen is only available for group conversations.`}
onGoBack={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}}
/>
)
}
return <GroupSettings convo={convoState.convo} />
}
function keyExtractor(item: Item) { function keyExtractor(item: Item) {
return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type
} }
function SettingsInner({convoId}: {convoId: string}) { function GroupSettings({
const {t: l} = useLingui() convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
const bottomBarOffset = useBottomBarOffset() const bottomBarOffset = useBottomBarOffset()
const convoState = useConvo()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const convo = convoState.convo
const primaryMember = convo?.primaryMember const primaryMember = convo?.primaryMember
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] const {data: memberListData = [], isPending} = useListConvoMembersQuery({
convoId: convo.view.id,
placeholderData: convo?.members,
})
// TODO Need this data in order to populate this array. -dsb // TODO Need this data in order to populate this array. -dsb
const invites: string[] = [] const invites: string[] = []
const {data: joinRequestsData, hasNextPage: hasMoreRequests} = const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
useListJoinRequestsQuery({ useListJoinRequestsQuery({
convoId, convoId: convo.view.id,
enabled: isOwner, enabled: isOwner,
}) })
const requestCount = const requestCount =
@@ -133,7 +181,7 @@ function SettingsInner({convoId}: {convoId: string}) {
type: 'MEMBERS_AND_REQUESTS', type: 'MEMBERS_AND_REQUESTS',
}, },
...(isOwner ? [{type: 'ADD_MEMBERS_LINK'} as const] : []), ...(isOwner ? [{type: 'ADD_MEMBERS_LINK'} as const] : []),
...[...data] ...[...memberListData]
.sort((a, b) => { .sort((a, b) => {
const aIsOwner = a.did === primaryMember?.did const aIsOwner = a.did === primaryMember?.did
const bIsOwner = b.did === primaryMember?.did const bIsOwner = b.did === primaryMember?.did
@@ -146,7 +194,7 @@ function SettingsInner({convoId}: {convoId: string}) {
.map( .map(
(profile): Item => ({ (profile): Item => ({
type: 'CHAT_MEMBER', type: 'CHAT_MEMBER',
profile, profile: profile as GroupConvoMember,
status: status:
primaryMember?.did === profile.did primaryMember?.did === profile.did
? 'owner' ? 'owner'
@@ -155,6 +203,7 @@ function SettingsInner({convoId}: {convoId: string}) {
: 'standard', : 'standard',
}), }),
), ),
...(isPending ? [{type: 'LOADING' as const}] : []),
] ]
function renderItem({item}: {item: Item}) { function renderItem({item}: {item: Item}) {
@@ -162,7 +211,7 @@ function SettingsInner({convoId}: {convoId: string}) {
case 'MEMBERS_AND_REQUESTS': case 'MEMBERS_AND_REQUESTS':
return ( return (
<MembersAndRequests <MembersAndRequests
memberCount={data.length} memberCount={convo.details.memberCount}
requestCount={requestCount} requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests} hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner} isOwner={isOwner}
@@ -172,7 +221,7 @@ function SettingsInner({convoId}: {convoId: string}) {
return convo ? ( return convo ? (
<AddMembersLink <AddMembersLink
convo={convo} convo={convo}
members={data.map(profile => profile.did)} members={memberListData.map(profile => profile.did)}
/> />
) : null ) : null
case 'CHAT_MEMBER': case 'CHAT_MEMBER':
@@ -184,22 +233,17 @@ function SettingsInner({convoId}: {convoId: string}) {
isOwner={isOwner} isOwner={isOwner}
/> />
) : null ) : null
case 'LOADING':
return (
<View style={[a.w_full, a.align_center, a.py_2xl]}>
<Loader size="lg" />
</View>
)
default: default:
return null return null
} }
} }
if (convoState.status === ConvoStatus.Error) {
return (
<Error
title={l`Something went wrong`}
message={l`We couldn’t load this conversation’s settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
)
}
return ( return (
<List <List
data={items} data={items}
@@ -220,9 +264,6 @@ function SettingsInner({convoId}: {convoId: string}) {
renderItem={renderItem} renderItem={renderItem}
sideBorders={false} sideBorders={false}
windowSize={11} windowSize={11}
// TODO Paginate on relatedProfiles. -dsb
onEndReached={() => {}}
onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
/> />
) )
} }
+1 -1
View File
@@ -133,7 +133,7 @@ export function ConvoProvider({
const [root, id] = getConvoKey(convoId) const [root, id] = getConvoKey(convoId)
return queryClient.getQueryCache().subscribe(event => { return queryClient.getQueryCache().subscribe(event => {
const queryKey = event.query.queryKey as string[] const queryKey = event.query.queryKey as string[]
if (queryKey[0] === root && queryKey[1] === id) { if (queryKey[0] === root && queryKey[1] === id && !queryKey[2]) {
const data = event.query.state.data as const data = event.query.state.data as
| ChatBskyConvoDefs.ConvoView | ChatBskyConvoDefs.ConvoView
| undefined | undefined
+1 -1
View File
@@ -16,7 +16,7 @@ import {
RQKEY_ROOT as LIST_CONVOS_KEY, RQKEY_ROOT as LIST_CONVOS_KEY,
} from './list-conversations' } from './list-conversations'
const RQKEY_ROOT = 'convo' export const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery({convoId}: {convoId: string}) { export function useConvoQuery({convoId}: {convoId: string}) {
@@ -0,0 +1,48 @@
import {type ChatBskyActorDefs} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
import {RQKEY_ROOT as GET_CONVOS_KEY} from './conversation'
export const RQKEY_SEGMENT = 'members'
export const RQKEY = (convoId: string) => [
GET_CONVOS_KEY,
convoId,
RQKEY_SEGMENT,
]
// group chat size is 50, so should fetch the whole list in one go
const LIMIT = 50
export function useListConvoMembersQuery({
convoId,
placeholderData,
}: {
convoId: string
placeholderData?: ChatBskyActorDefs.ProfileViewBasic[]
}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(convoId),
queryFn: async () => {
const members = []
let cursor
do {
const {data} = await agent.chat.bsky.convo.getConvoMembers(
{convoId, cursor, limit: LIMIT},
{headers: DM_SERVICE_HEADERS},
)
members.push(...data.members)
cursor = data.cursor
} while (cursor)
return members
},
staleTime: STALE.MINUTES.THIRTY,
placeholderData,
})
}