diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 4a4ba2fde3..cc5119e8d4 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -339,6 +339,7 @@ func serve(cctx *cli.Context) error { e.GET("/messages/inbox", server.WebGeneric) e.GET("/messages/:conversation", server.WebGeneric) e.GET("/messages/:conversation/settings", server.WebGeneric) + e.GET("/messages/:conversation/requests", server.WebGeneric) // profile endpoints; only first populates info e.GET("/profile/:handleOrDID", server.WebProfile) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 1348a975ea..efd1125295 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -82,6 +82,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' +import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' @@ -488,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => MessagesConversationSettingsScreen} options={{title: title(msg`Group chat settings`), requireAuth: true}} /> + MessagesJoinRequestsScreen} + options={{title: title(msg`Requests to join`), requireAuth: true}} + /> MessagesSettingsScreen} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 3c30b740da..22bb50572a 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -64,6 +64,7 @@ export type CommonNavigatorParams = { Topic: {topic: string} MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesConversationSettings: {conversation: string} + MessagesJoinRequests: {conversation: string} MessagesSettings: undefined MessagesInbox: undefined NotificationsActivityList: {posts: string} diff --git a/src/routes.ts b/src/routes.ts index 7ae73dd6e1..f7e73ff2c2 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -74,6 +74,7 @@ export const router = new Router({ MessagesInbox: '/messages/inbox', MessagesConversation: '/messages/:conversation', MessagesConversationSettings: '/messages/:conversation/settings', + MessagesJoinRequests: '/messages/:conversation/requests', // starter packs Start: '/start/:name/:rkey', StarterPackEdit: '/starter-pack/edit/:rkey', diff --git a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx index b9d9ecc2b2..bb3ebafb1c 100644 --- a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx +++ b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx @@ -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 requestCount: number hasMoreRequests: boolean isOwner: boolean }) { const t = useTheme() const {t: l} = useLingui() + const navigation = useNavigation() + + const memberCount = convo.details.memberCount + const memberLimit = convo.details.memberLimit return ( @@ -42,8 +47,11 @@ export function MembersAndRequests({ + {...createStaticClick(() => { + navigation.navigate('MessagesJoinRequests', { + conversation: convo.view.id, + }) + })}> {hasMoreRequests ? l({ message: `${requestCount}+ requests`, diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index 056afa8b5c..5e4ad91424 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -247,8 +247,7 @@ function GroupSettings({ case 'MEMBERS_AND_REQUESTS': return ( + +export function MessagesJoinRequestsScreen(props: Props) { + const {t: l} = useLingui() + const aaCopy = useAgeAssuranceCopy() + return ( + + + + ) +} + +function MessagesJoinRequestsScreenInner({route}: Props) { + const convoId = route.params.conversation + + return ( + + + + + + ) +} + +function JoinRequestsInner() { + const {t: l} = useLingui() + const convoState = useConvo() + const navigation = useNavigation() + + if (convoState.status === ConvoStatus.Error) { + return ( + <> +
+ convoState.error.retry()} + sideBorders={false} + /> + + ) + } + + if (!convoState.convo) { + return ( + <> +
+ + + + + ) + } + + if (convoState.convo.kind !== 'group') { + return ( + { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('Messages', {animation: 'pop'}) + } + }} + /> + ) + } + + return +} + +function JoinRequestsList({ + convo, +}: { + convo: Extract +}) { + const t = useTheme() + const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + const bottomBarOffset = useBottomBarOffset() + const {currentAccount} = useSession() + const navigation = useNavigation() + const queryClient = useQueryClient() + const inviteLinkControl = Dialog.useDialogControl() + + const getRemainingRequestCount = () => { + const data = queryClient.getQueryData< + InfiniteData + >(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 ( + + + + + + + + + + + + + approveJoinRequest({member: item.did})} + /> + rejectJoinRequest({member: item.did})} + /> + + + + + ) + } + + const footer = ( + 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, + }, + ]}> + + + ) + + 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 ( + <> +
+ + + + Unable to fetch join requests. + + + + + ) + } + + const showFooter = isOwner + + return ( + <> +
+ item.did} + renderItem={renderItem} + ListEmptyComponent={ + isPending ? ( + + + + ) : 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 && ( + + )} + + ) +} + +function Header({ + count, + hasMoreRequests, +}: { + count?: number + hasMoreRequests?: boolean +}) { + const {t: l} = useLingui() + return ( + + + + + {count === undefined ? ( + Requests to join + ) : hasMoreRequests ? ( + l({ + message: `${count}+ requests to join`, + comment: + 'Displayed when there are more requests to join a group chat than have been loaded', + }) + ) : ( + + )} + + + + + ) +} + +function AcceptButton({ + disabled, + onPress, +}: { + disabled?: boolean + onPress: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +function RejectButton({ + disabled, + onPress, +}: { + disabled?: boolean + onPress: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} diff --git a/src/state/queries/messages/join-requests.ts b/src/state/queries/messages/join-requests.ts new file mode 100644 index 0000000000..a3eda671e8 --- /dev/null +++ b/src/state/queries/messages/join-requests.ts @@ -0,0 +1,129 @@ +import { + type ChatBskyActorDefs, + type ChatBskyGroupApproveJoinRequest, + type ChatBskyGroupListJoinRequests, + type ChatBskyGroupRejectJoinRequest, +} from '@atproto/api' +import { + type InfiniteData, + useMutation, + useQueryClient, +} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {listConvoMembersQueryKey} from './list-convo-members' +import {createListJoinRequestsQueryKey} from './list-join-requests' + +type JoinRequestAction = 'approve' | 'reject' + +type JoinRequestOutput = A extends 'approve' + ? ChatBskyGroupApproveJoinRequest.OutputSchema + : ChatBskyGroupRejectJoinRequest.OutputSchema + +export function useJoinRequestMutation( + action: A, + convoId: string | undefined, + { + onSuccess, + onError, + }: { + onSuccess?: (data: JoinRequestOutput) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async ({member}: {member: string}) => { + if (!convoId) throw new Error('No convoId provided') + const endpoint = + action === 'approve' + ? agent.chat.bsky.group.approveJoinRequest + : agent.chat.bsky.group.rejectJoinRequest + const {data} = await endpoint( + {convoId, member}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + return data as JoinRequestOutput + }, + onMutate: ({member}) => { + if (!convoId) return + + const requestsKey = createListJoinRequestsQueryKey({convoId}) + const prevRequests = + queryClient.getQueryData< + InfiniteData + >(requestsKey) + + const requestedByProfile = prevRequests?.pages + .flatMap(page => page.requests) + .find(request => request.requestedBy.did === member)?.requestedBy + + queryClient.setQueryData< + InfiniteData + >(requestsKey, prev => { + if (!prev?.pages) return prev + return { + ...prev, + pages: prev.pages.map(page => ({ + ...page, + requests: page.requests.filter( + request => request.requestedBy.did !== member, + ), + })), + } + }) + + let prevMembers: ChatBskyActorDefs.ProfileViewBasic[] | undefined + if (action === 'approve' && requestedByProfile) { + const membersKey = listConvoMembersQueryKey(convoId) + prevMembers = + queryClient.getQueryData( + membersKey, + ) + queryClient.setQueryData( + membersKey, + prev => { + if (!prev) return prev + if (prev.some(m => m.did === member)) return prev + return [...prev, requestedByProfile] + }, + ) + } + + return {prevRequests, prevMembers} + }, + onSuccess: data => { + if (convoId) { + void queryClient.invalidateQueries({ + queryKey: createListJoinRequestsQueryKey({convoId}), + }) + if (action === 'approve') { + void queryClient.invalidateQueries({ + queryKey: listConvoMembersQueryKey(convoId), + }) + } + } + onSuccess?.(data) + }, + onError: (error, _variables, context) => { + logger.error(error) + if (convoId && context?.prevRequests) { + queryClient.setQueryData( + createListJoinRequestsQueryKey({convoId}), + context.prevRequests, + ) + } + if (convoId && action === 'approve' && context?.prevMembers) { + queryClient.setQueryData( + listConvoMembersQueryKey(convoId), + context.prevMembers, + ) + } + onError?.(error) + }, + }) +}