Show a banner when there are incoming chat requests (#10498)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-06-04 14:22:29 -07:00
committed by GitHub
parent 45bd5b45e8
commit de926d1838
5 changed files with 231 additions and 11 deletions
+27 -2
View File
@@ -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}
</ScrollEdgeEffect>
) : (
header
<View onLayout={onHeaderLayout}>{header}</View>
)}
{isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? (
<RequestStatus
top={headerHeight}
count={unreadRequestCount}
onDismiss={() => {
markJoinRequestsRead()
}}
onPress={() => {
markJoinRequestsRead()
navigation.navigate('MessagesJoinRequests', {
conversation: convo.view.id,
})
}}
/>
) : null}
{isActive && (
<MessagesList
hasScrolled={hasScrolled}
@@ -25,6 +25,7 @@ import {
precacheConvoQuery,
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
@@ -214,15 +215,15 @@ function GroupChatItem({
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
subtitle={
convo.details.joinRequestCount
? convo.details.joinRequestCount > 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 && (
<Text
numberOfLines={1}
style={[
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium,
a.pb_2xs,
]}
emoji>
{requestInfo}
</Text>
)}
<View style={[a.flex_row, a.align_center]}>
{LastMessageIcon && (
<LastMessageIcon
@@ -623,8 +641,6 @@ function BaseChatItem({
emoji
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium,
]}>
@@ -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 (
<Animated.View
entering={FadeIn.duration(200).delay(200)}
exiting={FadeOut.duration(200)}
style={[
a.absolute,
a.z_50,
{
top: top + (IS_LIQUID_GLASS ? tokens.space.sm : tokens.space.xl),
left: tokens.space.xl,
right: tokens.space.xl,
},
]}>
<GlassView
style={[a.flex_1, a.rounded_full, a.flex_row, a.align_center]}
isInteractive
glassEffectStyle="regular"
tintColor={t.palette.primary_50}
fallbackStyle={{
backgroundColor: t.palette.primary_50,
borderWidth: 1,
borderColor: t.palette.primary_100,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`View incoming requests`}
accessibilityHint={l`View incoming requests to join this group chat`}
hitSlop={HITSLOP_10}
style={[a.flex_1, a.flex_row, a.align_center, a.p_lg]}
onPress={onPress}>
<EnvelopeIcon size="md" fill={t.palette.primary_500} />
<Text
style={[
a.flex_1,
a.ml_sm,
a.text_sm,
a.font_semi_bold,
{color: t.palette.primary_500},
]}>
{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',
})}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Close banner`}
accessibilityHint={l`Close the incoming requests banner`}
hitSlop={HITSLOP_10}
onPress={onDismiss}
style={[a.p_lg]}>
<CloseIcon size="md" fill={t.palette.primary_500} />
</Pressable>
</GlassView>
</Animated.View>
)
}
@@ -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
@@ -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<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView | undefined>(
CONVO_KEY(convoId),
old => {
if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old
return {
...old,
kind: {...old.kind, unreadJoinRequestCount: 0},
}
},
)
const prevListEntries = queryClient.getQueriesData<ConvoListQueryData>({
queryKey: [CONVO_LIST_ROOT_KEY],
})
queryClient.setQueriesData<ConvoListQueryData>(
{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]})
},
})
}