diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx
index 7145e5d88f..60538615a1 100644
--- a/src/state/messages/index.tsx
+++ b/src/state/messages/index.tsx
@@ -6,6 +6,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id'
import {MessagesEventBusProvider} from '#/state/messages/events'
+import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
@@ -37,15 +38,18 @@ export function ChatProvider({
}),
)
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
+ const {mutate: markAsRead} = useMarkAsReadMutation()
useFocusEffect(
React.useCallback(() => {
convo.resume()
+ markAsRead({convoId})
return () => {
convo.background()
+ markAsRead({convoId})
}
- }, [convo]),
+ }, [convo, convoId, markAsRead]),
)
React.useEffect(() => {
@@ -56,6 +60,8 @@ export function ChatProvider({
} else {
convo.background()
}
+
+ markAsRead({convoId})
}
}
@@ -64,7 +70,7 @@ export function ChatProvider({
return () => {
sub.remove()
}
- }, [convo, isScreenFocused])
+ }, [convoId, convo, isScreenFocused, markAsRead])
return {children}
}
diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts
index 9456861d27..c322e0c62c 100644
--- a/src/state/queries/messages/conversation.ts
+++ b/src/state/queries/messages/conversation.ts
@@ -1,6 +1,7 @@
import {BskyAgent} from '@atproto-labs/api'
-import {useQuery} from '@tanstack/react-query'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-converations'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
@@ -23,3 +24,36 @@ export function useConvoQuery(convoId: string) {
},
})
}
+
+export function useMarkAsReadMutation() {
+ const headers = useHeaders()
+ const {serviceUrl} = useDmServiceUrlStorage()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ convoId,
+ messageId,
+ }: {
+ convoId: string
+ messageId?: string
+ }) => {
+ const agent = new BskyAgent({service: serviceUrl})
+ await agent.api.chat.bsky.convo.updateRead(
+ {
+ convoId,
+ messageId,
+ },
+ {
+ encoding: 'application/json',
+ headers,
+ },
+ )
+ },
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: ListConvosQueryKey,
+ })
+ },
+ })
+}
diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts
index 1e4ecb6d72..e66551ceb7 100644
--- a/src/state/queries/messages/list-converations.ts
+++ b/src/state/queries/messages/list-converations.ts
@@ -1,6 +1,12 @@
-import {BskyAgent} from '@atproto-labs/api'
-import {useInfiniteQuery} from '@tanstack/react-query'
+import {useCallback, useMemo} from 'react'
+import {
+ BskyAgent,
+ ChatBskyConvoDefs,
+ ChatBskyConvoListConvos,
+} from '@atproto-labs/api'
+import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query'
+import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
@@ -27,3 +33,100 @@ export function useListConvos({refetchInterval}: {refetchInterval: number}) {
refetchInterval,
})
}
+
+export function useUnreadMessageCount() {
+ const {currentConvoId} = useCurrentConvoId()
+ const convos = useListConvos({
+ refetchInterval: 30_000,
+ })
+
+ const count =
+ convos.data?.pages
+ .flatMap(page => page.convos)
+ .filter(convo => convo.id !== currentConvoId)
+ .reduce((acc, convo) => {
+ return acc + (!convo.muted && convo.unreadCount > 0 ? 1 : 0)
+ }, 0) ?? 0
+
+ return useMemo(() => {
+ return {
+ count,
+ numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined,
+ }
+ }, [count])
+}
+
+type ConvoListQueryData = {
+ pageParams: Array
+ pages: Array
+}
+
+export function useOnDeleteMessage() {
+ const queryClient = useQueryClient()
+
+ return useCallback(
+ (chatId: string, messageId: string) => {
+ queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
+ return optimisticUpdate(chatId, old, convo =>
+ messageId === convo.lastMessage?.id
+ ? {
+ ...convo,
+ lastMessage: {
+ $type: 'chat.bsky.convo.defs#deletedMessageView',
+ id: messageId,
+ rev: '',
+ },
+ }
+ : convo,
+ )
+ })
+ },
+ [queryClient],
+ )
+}
+
+export function useOnNewMessage() {
+ const queryClient = useQueryClient()
+
+ return useCallback(
+ (chatId: string, message: ChatBskyConvoDefs.MessageView) => {
+ queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
+ return optimisticUpdate(chatId, old, convo => ({
+ ...convo,
+ lastMessage: message,
+ unreadCount: convo.unreadCount + 1,
+ }))
+ })
+ queryClient.invalidateQueries({queryKey: RQKEY})
+ },
+ [queryClient],
+ )
+}
+
+export function useOnCreateConvo() {
+ const queryClient = useQueryClient()
+
+ return useCallback(() => {
+ queryClient.invalidateQueries({queryKey: RQKEY})
+ }, [queryClient])
+}
+
+function optimisticUpdate(
+ chatId: string,
+ old: ConvoListQueryData,
+ updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
+) {
+ if (!old) {
+ return old
+ }
+
+ return {
+ ...old,
+ pages: old.pages.map(page => ({
+ ...page,
+ convos: page.convos.map(convo =>
+ chatId === convo.id ? updateFn(convo) : convo,
+ ),
+ })),
+ }
+}
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 33f713322a..0db8b242a1 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -27,6 +27,7 @@ import {getTabState, TabState} from '#/lib/routes/helpers'
import {useGate} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {emitSoftReset} from '#/state/events'
+import {useUnreadMessageCount} from '#/state/queries/messages/list-converations'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -68,6 +69,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
isAtMessages,
} = useNavigationTabState()
const numUnreadNotifications = useUnreadNotifications()
+ const numUnreadMessages = useUnreadMessageCount()
const {footerMinimalShellTransform} = useMinimalShellMode()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {requestSwitchToAccount} = useLoggedOutViewControls()
@@ -257,9 +259,15 @@ export function BottomBar({navigation}: BottomTabBarProps) {
)
}
onPress={onPressMessages}
+ notificationCount={numUnreadMessages.numUnread}
+ accessible={true}
accessibilityRole="tab"
accessibilityLabel={_(msg`Messages`)}
- accessibilityHint=""
+ accessibilityHint={
+ numUnreadMessages.count > 0
+ ? `${numUnreadMessages.numUnread} unread`
+ : ''
+ }
/>
)}
}
iconFilled={