Create screen for managing chat requests (#10635)

This commit is contained in:
DS Boyce
2026-06-02 13:28:06 -07:00
committed by GitHub
parent 75490c3cdf
commit 73dae02df3
8 changed files with 645 additions and 9 deletions
@@ -1,26 +1,31 @@
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {atoms as a, useTheme} from '#/alf'
import {InlineLinkText} from '#/components/Link'
import {type ConvoWithDetails} from '#/components/dms/util'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MembersAndRequests({
memberCount,
memberLimit,
convo,
requestCount,
hasMoreRequests,
isOwner,
}: {
memberCount: number
memberLimit: number
convo: Extract<ConvoWithDetails, {kind: 'group'}>
requestCount: number
hasMoreRequests: boolean
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const memberCount = convo.details.memberCount
const memberLimit = convo.details.memberLimit
return (
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
@@ -42,8 +47,11 @@ export function MembersAndRequests({
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#">
{...createStaticClick(() => {
navigation.navigate('MessagesJoinRequests', {
conversation: convo.view.id,
})
})}>
{hasMoreRequests
? l({
message: `${requestCount}+ requests`,
@@ -247,8 +247,7 @@ function GroupSettings({
case 'MEMBERS_AND_REQUESTS':
return (
<MembersAndRequests
memberCount={convo.details.memberCount}
memberLimit={convo.details.memberLimit}
convo={convo}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
+491
View File
@@ -0,0 +1,491 @@
import {useState} from 'react'
import {View} from 'react-native'
import {
ChatBskyGroupApproveJoinRequest,
type ChatBskyGroupListJoinRequests,
ChatBskyGroupRejectJoinRequest,
} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type InfiniteData, useQueryClient} from '@tanstack/react-query'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinRequestMutation} from '#/state/queries/messages/join-requests'
import {
createListJoinRequestsQueryKey,
useListJoinRequestsQuery,
} from '#/state/queries/messages/list-join-requests'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util'
import {Error} from '#/components/Error'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {KnownFollowers} from '#/components/KnownFollowers'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from './components/InviteLinkDialog'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesJoinRequests'
>
export function MessagesJoinRequestsScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
<AgeRestrictedScreen
screenTitle={l`Requests to join`}
infoText={aaCopy.chatsInfoText}>
<MessagesJoinRequestsScreenInner {...props} />
</AgeRestrictedScreen>
)
}
function MessagesJoinRequestsScreenInner({route}: Props) {
const convoId = route.params.conversation
return (
<Layout.Screen>
<ConvoProvider key={convoId} convoId={convoId}>
<JoinRequestsInner />
</ConvoProvider>
</Layout.Screen>
)
}
function JoinRequestsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Header />
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations join requests`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</>
)
}
if (!convoState.convo) {
return (
<>
<Header />
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</>
)
}
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 <JoinRequestsList convo={convoState.convo} />
}
function JoinRequestsList({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const bottomBarOffset = useBottomBarOffset()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient()
const inviteLinkControl = Dialog.useDialogControl()
const getRemainingRequestCount = () => {
const data = queryClient.getQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(createListJoinRequestsQueryKey({convoId: convo.view.id}))
return data?.pages.reduce((sum, page) => sum + page.requests.length, 0) ?? 0
}
const [isPTRing, setIsPTRing] = useState(false)
const [footerHeight, setFooterHeight] = useState(0)
const owner = convo.primaryMember
const isOwner = !!owner && owner.did === currentAccount?.did
const {
data: joinRequestsData,
isPending,
isError,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
refetch,
} = useListJoinRequestsQuery({
convoId: convo.view.id,
})
const items =
joinRequestsData?.pages.flatMap(page =>
page.requests.map(request => request.requestedBy),
) ?? []
const requestCount =
joinRequestsData?.pages.reduce(
(sum, page) => sum + page.requests.length,
0,
) ?? 0
const {mutate: approveJoinRequest, isPending: isApprovePending} =
useJoinRequestMutation('approve', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request approved.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to accept join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can accept join requests.`
} else if (
error instanceof
ChatBskyGroupApproveJoinRequest.MemberLimitReachedError
) {
errorMessage = l`The member limit has been reached.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const {mutate: rejectJoinRequest, isPending: isRejectPending} =
useJoinRequestMutation('reject', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request ignored.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to ignore join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can ignore join requests.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const isMutating = isApprovePending || isRejectPending
const renderItem = ({item}: {item: bsky.profile.AnyProfileView}) => {
if (!moderationOpts) return null
return (
<View style={[a.relative, a.flex_1, a.p_lg]}>
<View style={[a.flex_row, a.align_start, a.gap_md]}>
<ProfileCard.Link profile={item}>
<ProfileCard.Avatar
profile={item}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
</ProfileCard.Link>
<View>
<ProfileCard.Name profile={item} moderationOpts={moderationOpts} />
<ProfileCard.Handle profile={item} />
<View style={[a.mt_xs]}>
<KnownFollowers
profile={item}
moderationOpts={moderationOpts}
minimal
showIfEmpty
/>
</View>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.mt_md]}>
<AcceptButton
disabled={isMutating}
onPress={() => approveJoinRequest({member: item.did})}
/>
<RejectButton
disabled={isMutating}
onPress={() => rejectJoinRequest({member: item.did})}
/>
</View>
</View>
</View>
</View>
)
}
const footer = (
<View
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
style={[
a.absolute,
a.left_0,
a.right_0,
{bottom: 0},
a.px_xl,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_low,
{
paddingTop: a.py_lg.paddingTop,
paddingBottom: a.py_lg.paddingBottom + bottomBarOffset,
},
]}>
<Button
label={l`Edit invite link`}
size="large"
color="primary"
onPress={() => inviteLinkControl.open()}
style={[a.w_full]}>
<ButtonText>
<Trans context="button">Edit invite link</Trans>
</ButtonText>
</Button>
</View>
)
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more join requests', {message: err})
}
}
const onRefresh = async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh group chat requests', {message: err})
}
setIsPTRing(false)
}
if (isError) {
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.gap_sm,
a.p_lg,
]}>
<ErrorIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
<Text
style={[
a.leading_snug,
a.text_center,
a.px_lg,
a.text_md,
t.atoms.text_contrast_high,
]}>
<Trans>Unable to fetch join requests.</Trans>
</Text>
<Button
color="primary"
label={l`Press to retry`}
onPress={() => void onRefresh()}
disabled={isPTRing}
size="large"
style={[a.mt_md]}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={isPTRing ? Loader : RetryIcon} />
</Button>
</View>
</>
)
}
const showFooter = isOwner
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<List
data={items}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
renderItem={renderItem}
ListEmptyComponent={
isPending ? (
<View
style={[a.flex_1, a.align_center, a.justify_center, a.py_4xl]}>
<Loader size="xl" />
</View>
) : null
}
contentContainerStyle={
showFooter ? {paddingBottom: footerHeight} : undefined
}
scrollIndicatorInsets={showFooter ? {bottom: footerHeight} : undefined}
refreshing={isPTRing}
onEndReached={() => void onEndReached()}
onRefresh={() => void onRefresh()}
keyboardDismissMode="on-drag"
sideBorders={false}
desktopFixedHeight
/>
{showFooter ? footer : null}
{owner && moderationOpts && (
<InviteLinkDialog
convo={convo}
control={inviteLinkControl}
owner={owner}
isOwner={isOwner}
moderationOpts={moderationOpts}
/>
)}
</>
)
}
function Header({
count,
hasMoreRequests,
}: {
count?: number
hasMoreRequests?: boolean
}) {
const {t: l} = useLingui()
return (
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
{count === undefined ? (
<Trans>Requests to join</Trans>
) : hasMoreRequests ? (
l({
message: `${count}+ requests to join`,
comment:
'Displayed when there are more requests to join a group chat than have been loaded',
})
) : (
<Plural
value={count}
zero="No requests to join"
one="# request to join"
other="# requests to join"
/>
)}
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
)
}
function AcceptButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const {t: l} = useLingui()
return (
<Button
label={l`Accept join request`}
size="small"
color="primary"
disabled={disabled}
onPress={onPress}>
<ButtonText>
<Trans comment="Accept a request to join a chat" context="button">
Accept
</Trans>
</ButtonText>
</Button>
)
}
function RejectButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const {t: l} = useLingui()
return (
<Button
label={l`Ignore join request`}
size="small"
color="secondary"
disabled={disabled}
onPress={onPress}>
<ButtonText>
<Trans comment="Ignore a request to join a chat" context="button">
Ignore
</Trans>
</ButtonText>
</Button>
)
}