about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/messages/index.tsx10
-rw-r--r--src/state/queries/messages/conversation.ts36
-rw-r--r--src/state/queries/messages/list-converations.ts107
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx10
-rw-r--r--src/view/shell/desktop/LeftNav.tsx7
5 files changed, 162 insertions, 8 deletions
diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx
index 7145e5d88..60538615a 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 <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
 }
diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts
index 9456861d2..c322e0c62 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 1e4ecb6d7..e66551ceb 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<string | undefined>
+  pages: Array<ChatBskyConvoListConvos.OutputSchema>
+}
+
+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 33f713322..0db8b242a 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`
+                    : ''
+                }
               />
             )}
             <Btn
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 91d20e089..1d27a10a4 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -16,6 +16,7 @@ import {useGate} from '#/lib/statsig/statsig'
 import {isInvalidHandle} from '#/lib/strings/handles'
 import {emitSoftReset} from '#/state/events'
 import {useFetchHandle} from '#/state/queries/handle'
+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'
@@ -274,7 +275,8 @@ export function DesktopLeftNav() {
   const pal = usePalette('default')
   const {_} = useLingui()
   const {isDesktop, isTablet} = useWebMediaQueries()
-  const numUnread = useUnreadNotifications()
+  const numUnreadNotifications = useUnreadNotifications()
+  const numUnreadMessages = useUnreadMessageCount()
   const gate = useGate()
 
   if (!hasSession && !isDesktop) {
@@ -333,7 +335,7 @@ export function DesktopLeftNav() {
           />
           <NavItem
             href="/notifications"
-            count={numUnread}
+            count={numUnreadNotifications}
             icon={
               <BellIcon
                 strokeWidth={2}
@@ -353,6 +355,7 @@ export function DesktopLeftNav() {
           {gate('dms') && (
             <NavItem
               href="/messages"
+              count={numUnreadMessages.numUnread}
               icon={<Envelope style={pal.text} width={isDesktop ? 26 : 30} />}
               iconFilled={
                 <EnvelopeFilled style={pal.text} width={isDesktop ? 26 : 30} />