From de926d1838185ce08a6fbad5b6e9edb71dc02314 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:22:29 -0700 Subject: [PATCH] Show a banner when there are incoming chat requests (#10498) Co-authored-by: Samuel Newman --- src/screens/Messages/Conversation.tsx | 29 +++++- .../Messages/components/ChatListItem.tsx | 32 +++++-- .../Messages/components/RequestStatus.tsx | 92 +++++++++++++++++++ .../queries/messages/list-join-requests.ts | 4 +- .../messages/mark-join-request-read.ts | 85 +++++++++++++++++ 5 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 src/screens/Messages/components/RequestStatus.tsx create mode 100644 src/state/queries/messages/mark-join-request-read.ts diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 6233c467ff..b269046534 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type LayoutChangeEvent, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {moderateProfile} from '@atproto/api' +import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import { ScrollEdgeEffect, ScrollEdgeEffectProvider, @@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useConvoQuery} from '#/state/queries/messages/conversation' +import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read' import {useSession} from '#/state/session' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, web} from '#/alf' @@ -51,6 +52,7 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env' import {ChatDisabled} from './components/ChatDisabled' import {ChatEnded} from './components/ChatEnded' import {ChatLocked} from './components/ChatLocked' +import {RequestStatus} from './components/RequestStatus' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -180,6 +182,12 @@ function InnerReady({ const {needsEmailVerification} = useEmail() const emailDialogControl = useEmailDialogControl() + const unreadRequestCount = + convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind) + ? (convo.view.kind.unreadJoinRequestCount ?? 0) + : 0 + const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id) + /** * Must be non-reactive, otherwise the update to open the global dialog will * cause a re-render loop. @@ -264,8 +272,25 @@ function InnerReady({ {header} ) : ( - header + {header} )} + + {isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? ( + { + markJoinRequestsRead() + }} + onPress={() => { + markJoinRequestsRead() + navigation.navigate('MessagesJoinRequests', { + conversation: convo.view.id, + }) + }} + /> + ) : null} + {isActive && ( 20 + requestInfo={ + convo.details.unreadJoinRequestCount + ? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD ? l({ - message: '20+ new join requests', + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, context: 'Displayed when there are more than 20 requests to join a group chat', }) - : plural(convo.details.joinRequestCount, { + : plural(convo.details.unreadJoinRequestCount, { one: '# new join request', other: '# new join requests', }) @@ -241,6 +242,7 @@ function BaseChatItem({ avatar, title, subtitle, + requestInfo, accessibilityHint, isDeletedAccount, isBlockedAccount, @@ -256,6 +258,7 @@ function BaseChatItem({ avatar: React.ReactNode title: string subtitle?: string + requestInfo?: string accessibilityHint: string isDeletedAccount: boolean isBlockedAccount: boolean @@ -280,8 +283,10 @@ function BaseChatItem({ const playHaptic = useHaptics() const queryClient = useQueryClient() const hasUnread = - convo.view.unreadCount > 0 && !isDeletedAccount && + (convo.view.unreadCount > 0 || + (convo.kind === 'group' && + (convo.details.unreadJoinRequestCount ?? 0) > 0)) && (convo.kind !== 'group' || convo.details.lockStatus === 'unlocked') const blockInfo = useMemo(() => { @@ -607,6 +612,19 @@ function BaseChatItem({ {postAlerts} + {requestInfo && ( + + {requestInfo} + + )} + {LastMessageIcon && ( diff --git a/src/screens/Messages/components/RequestStatus.tsx b/src/screens/Messages/components/RequestStatus.tsx new file mode 100644 index 0000000000..9b35b69445 --- /dev/null +++ b/src/screens/Messages/components/RequestStatus.tsx @@ -0,0 +1,92 @@ +import {Pressable} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests' +import {atoms as a, tokens, useTheme} from '#/alf' +import {GlassView} from '#/components/GlassView' +import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_LIQUID_GLASS} from '#/env' + +export function RequestStatus({ + top, + count, + onDismiss, + onPress, +}: { + top: number + count: number + onDismiss: () => void + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + {count > JOIN_REQUESTS_THRESHOLD + ? l({ + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, + comment: + 'Displayed when the number of requests is greater than 20', + }) + : plural(count, { + one: '# new join request', + other: '# new join requests', + })} + + + + + + + + ) +} diff --git a/src/state/queries/messages/list-join-requests.ts b/src/state/queries/messages/list-join-requests.ts index 9f0860c20d..64a999c118 100644 --- a/src/state/queries/messages/list-join-requests.ts +++ b/src/state/queries/messages/list-join-requests.ts @@ -8,6 +8,8 @@ import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' import {STALE} from '..' +export const JOIN_REQUESTS_THRESHOLD = 20 + const listJoinRequestsQueryKeyRoot = 'list-join-requests' export const createListJoinRequestsQueryKey = (args: {convoId: string}) => @@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({ queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}), queryFn: async ({pageParam}) => { const {data} = await agent.chat.bsky.group.listJoinRequests( - {convoId: convoId!, cursor: pageParam, limit: 20}, + {convoId: convoId!, cursor: pageParam, limit: JOIN_REQUESTS_THRESHOLD}, {headers: DM_SERVICE_HEADERS}, ) return data diff --git a/src/state/queries/messages/mark-join-request-read.ts b/src/state/queries/messages/mark-join-request-read.ts new file mode 100644 index 0000000000..ae6d7e88a7 --- /dev/null +++ b/src/state/queries/messages/mark-join-request-read.ts @@ -0,0 +1,85 @@ +import {ChatBskyConvoDefs} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {RQKEY as CONVO_KEY} from './conversation' +import { + type ConvoListQueryData, + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, +} from './list-conversations' + +export function useMarkJoinRequestsRead(convoId: string | undefined) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + if (!convoId) throw new Error('No convoId provided') + await agent.chat.bsky.group.updateJoinRequestsRead( + {convoId}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + }, + onMutate: () => { + if (!convoId) return + + const prevConvo = queryClient.getQueryData( + CONVO_KEY(convoId), + ) + queryClient.setQueryData( + CONVO_KEY(convoId), + old => { + if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old + return { + ...old, + kind: {...old.kind, unreadJoinRequestCount: 0}, + } + }, + ) + + const prevListEntries = queryClient.getQueriesData({ + queryKey: [CONVO_LIST_ROOT_KEY], + }) + queryClient.setQueriesData( + {queryKey: [CONVO_LIST_ROOT_KEY]}, + old => { + if (!old) return old + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + convos: page.convos.map(convo => { + if ( + convo.id !== convoId || + !ChatBskyConvoDefs.isGroupConvo(convo.kind) + ) { + return convo + } + return { + ...convo, + kind: {...convo.kind, unreadJoinRequestCount: 0}, + } + }), + })), + } + }, + ) + + return {prevConvo, prevListEntries} + }, + onError: (error, _, context) => { + logger.error('Failed to mark join requests as read', {safeMessage: error}) + if (!convoId) return + if (context?.prevConvo) { + queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) + } + for (const [key, data] of context?.prevListEntries ?? []) { + queryClient.setQueryData(key, data) + } + void queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + }, + }) +}