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]})
+ },
+ })
+}