From c995eb2f2fa3e73dcc6943078c85cd6a68f5370b Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Tue, 4 Mar 2025 13:54:19 +0000
Subject: DMs inbox (#7778)

* improve error screen

* add chat request prompt

* mock up inbox

* bigger button

* use two-button layout

* get inbox working somewhat

* fix type errors

* fetch both pages for badge

* don't include read convos in preview

* in-chat ui for non-accepted convos (part 1)

* add chatstatusinfo

* fix status info not disappearing

* get chat status info working

* change min item height

* move files around

* add updated sdk

* improve badge behaviour

* mock up mark all as read

* update sdk to 0.14.4

* hide chat status info if initiating convo

* fix unread count for deleted accounts

* add toasts after rejection

* add prompt to delete

* adjust badge on desktop

* requests -> chat requests

* fix height flicker

* add mark as read button to header

* add mark all as read APIs

* separate avatarstack into two components (#7845)

* fix messages being hidden behind chatstatusinfo

* show inbox preview on empty state

* fix empty state again

* Use new convo availability API (#7812)

* [Inbox] Accept button on convo screen (#7795)

* accept button on convo screen

* fix types

* fix type error

* improve spacing

* [DMs] Implement new log types (#7835)

* optimise badge state

* add read message log

* add isLogAcceptConvo

* mute/unmute convo logs

* use setqueriesdata

* always show label on button

* optimistically update badge

* change incorrect unread count change

* Update src/screens/Messages/Inbox.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestListItem.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix race condition with accepting convo

* fix back button on web

* filter left convos from badge

* update atproto to fix CI

* Add accept override external to convo (#7891)

* Add accept override external to convo

* rm log

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
---
 src/state/queries/messages/list-conversations.tsx | 421 +++++++++++++++-------
 1 file changed, 296 insertions(+), 125 deletions(-)

(limited to 'src/state/queries/messages/list-conversations.tsx')

diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index 8c9d6c429..f5fce6347 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -9,6 +9,7 @@ import {
   ChatBskyConvoDefs,
   ChatBskyConvoListConvos,
   moderateProfile,
+  ModerationOpts,
 } from '@atproto/api'
 import {
   InfiniteData,
@@ -23,26 +24,39 @@ import {useMessagesEventBus} from '#/state/messages/events'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {useAgent, useSession} from '#/state/session'
+import {useLeftConvos} from './leave-conversation'
 
-export const RQKEY = ['convo-list']
+export const RQKEY_ROOT = 'convo-list'
+export const RQKEY = (
+  status: 'accepted' | 'request' | 'all',
+  readState: 'all' | 'unread' = 'all',
+) => [RQKEY_ROOT, status, readState]
 type RQPageParam = string | undefined
 
 export function useListConvosQuery({
   enabled,
+  status,
+  readState = 'all',
 }: {
   enabled?: boolean
+  status?: 'request' | 'accepted'
+  readState?: 'all' | 'unread'
 } = {}) {
   const agent = useAgent()
 
   return useInfiniteQuery({
     enabled,
-    queryKey: RQKEY,
+    queryKey: RQKEY(status ?? 'all', readState),
     queryFn: async ({pageParam}) => {
-      const {data} = await agent.api.chat.bsky.convo.listConvos(
-        {cursor: pageParam, limit: 20},
+      const {data} = await agent.chat.bsky.convo.listConvos(
+        {
+          limit: 20,
+          cursor: pageParam,
+          readState: readState === 'unread' ? 'unread' : undefined,
+          status,
+        },
         {headers: DM_SERVICE_HEADERS},
       )
-
       return data
     },
     initialPageParam: undefined as RQPageParam,
@@ -50,9 +64,10 @@ export function useListConvosQuery({
   })
 }
 
-const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>(
-  null,
-)
+const ListConvosContext = createContext<{
+  accepted: ChatBskyConvoDefs.ConvoView[]
+  request: ChatBskyConvoDefs.ConvoView[]
+} | null>(null)
 
 export function useListConvos() {
   const ctx = useContext(ListConvosContext)
@@ -62,12 +77,13 @@ export function useListConvos() {
   return ctx
 }
 
+const empty = {accepted: [], request: []}
 export function ListConvosProvider({children}: {children: React.ReactNode}) {
   const {hasSession} = useSession()
 
   if (!hasSession) {
     return (
-      <ListConvosContext.Provider value={[]}>
+      <ListConvosContext.Provider value={empty}>
         {children}
       </ListConvosContext.Provider>
     )
@@ -81,20 +97,23 @@ export function ListConvosProviderInner({
 }: {
   children: React.ReactNode
 }) {
-  const {refetch, data} = useListConvosQuery()
+  const {refetch, data} = useListConvosQuery({readState: 'unread'})
   const messagesBus = useMessagesEventBus()
   const queryClient = useQueryClient()
   const {currentConvoId} = useCurrentConvoId()
   const {currentAccount} = useSession()
+  const leftConvos = useLeftConvos()
 
-  const debouncedRefetch = useMemo(
-    () =>
-      throttle(refetch, 500, {
-        leading: true,
-        trailing: true,
-      }),
-    [refetch],
-  )
+  const debouncedRefetch = useMemo(() => {
+    const refetchAndInvalidate = () => {
+      refetch()
+      queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
+    }
+    return throttle(refetchAndInvalidate, 500, {
+      leading: true,
+      trailing: true,
+    })
+  }, [refetch, queryClient])
 
   useEffect(() => {
     const unsub = messagesBus.on(
@@ -105,69 +124,159 @@ export function ListConvosProviderInner({
           if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
             debouncedRefetch()
           } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
-            queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>
-              optimisticDelete(log.convoId, old),
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
             )
           } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
-            queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>
-              optimisticUpdate(log.convoId, old, convo => {
-                if (
-                  (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
-                    ChatBskyConvoDefs.isMessageView(log.message)) &&
-                  (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) ||
-                    ChatBskyConvoDefs.isMessageView(convo.lastMessage))
-                ) {
-                  return log.message.id === convo.lastMessage.id
-                    ? {
-                        ...convo,
-                        rev: log.rev,
-                        lastMessage: log.message,
-                      }
-                    : convo
-                } else {
-                  return convo
-                }
-              }),
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(log.convoId, old, convo => {
+                  if (
+                    (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
+                      ChatBskyConvoDefs.isMessageView(log.message)) &&
+                    (ChatBskyConvoDefs.isDeletedMessageView(
+                      convo.lastMessage,
+                    ) ||
+                      ChatBskyConvoDefs.isMessageView(convo.lastMessage))
+                  ) {
+                    return log.message.id === convo.lastMessage.id
+                      ? {
+                          ...convo,
+                          rev: log.rev,
+                          lastMessage: log.message,
+                        }
+                      : convo
+                  } else {
+                    return convo
+                  }
+                }),
             )
           } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
             // Store in a new var to avoid TS errors due to closures.
             const logRef: ChatBskyConvoDefs.LogCreateMessage = log
 
-            queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
-              if (!old) return old
+            // Get all matching queries
+            const queries = queryClient.getQueriesData<ConvoListQueryData>({
+              queryKey: [RQKEY_ROOT],
+            })
 
-              function updateConvo(convo: ChatBskyConvoDefs.ConvoView) {
-                let unreadCount = convo.unreadCount
-                if (convo.id !== currentConvoId) {
-                  if (
-                    ChatBskyConvoDefs.isMessageView(logRef.message) ||
-                    ChatBskyConvoDefs.isDeletedMessageView(logRef.message)
-                  ) {
-                    if (logRef.message.sender.did !== currentAccount?.did) {
-                      unreadCount++
+            // Check if convo exists in any query
+            let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
+            for (const [_key, query] of queries) {
+              if (!query) continue
+              const convo = getConvoFromQueryData(logRef.convoId, query)
+              if (convo) {
+                foundConvo = convo
+                break
+              }
+            }
+
+            if (!foundConvo) {
+              // Convo not found, trigger refetch
+              debouncedRefetch()
+              return
+            }
+
+            // Update the convo
+            const updatedConvo = {
+              ...foundConvo,
+              rev: logRef.rev,
+              lastMessage: logRef.message,
+              unreadCount:
+                foundConvo.id !== currentConvoId
+                  ? (ChatBskyConvoDefs.isMessageView(logRef.message) ||
+                      ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) &&
+                    logRef.message.sender.did !== currentAccount?.did
+                    ? foundConvo.unreadCount + 1
+                    : foundConvo.unreadCount
+                  : 0,
+            }
+
+            function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) {
+              return convo.filter(c => c.id !== logRef.convoId)
+            }
+
+            // Update all matching queries
+            function updateFn(old?: ConvoListQueryData) {
+              if (!old) return old
+              return {
+                ...old,
+                pages: old.pages.map((page, i) => {
+                  if (i === 0) {
+                    return {
+                      ...page,
+                      convos: [
+                        updatedConvo,
+                        ...filterConvoFromPage(page.convos),
+                      ],
                     }
                   }
-                } else {
-                  unreadCount = 0
-                }
-
-                return {
+                  return {
+                    ...page,
+                    convos: filterConvoFromPage(page.convos),
+                  }
+                }),
+              }
+            }
+            // always update the unread one
+            queryClient.setQueriesData(
+              {queryKey: RQKEY('all', 'unread')},
+              (old?: ConvoListQueryData) =>
+                old
+                  ? updateFn(old)
+                  : ({
+                      pageParams: [undefined],
+                      pages: [{convos: [updatedConvo], cursor: undefined}],
+                    } satisfies ConvoListQueryData),
+            )
+            // update the other ones based on status of the incoming message
+            if (updatedConvo.status === 'accepted') {
+              queryClient.setQueriesData(
+                {queryKey: RQKEY('accepted')},
+                updateFn,
+              )
+            } else if (updatedConvo.status === 'request') {
+              queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
+            }
+          } else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
+            const logRef: ChatBskyConvoDefs.LogReadMessage = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
                   ...convo,
+                  unreadCount: 0,
                   rev: logRef.rev,
-                  lastMessage: logRef.message,
-                  unreadCount,
+                })),
+            )
+          } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
+            const logRef: ChatBskyConvoDefs.LogAcceptConvo = log
+            const requests = queryClient.getQueryData<ConvoListQueryData>(
+              RQKEY('request'),
+            )
+            if (!requests) {
+              debouncedRefetch()
+              return
+            }
+            const acceptedConvo = getConvoFromQueryData(log.convoId, requests)
+            if (!acceptedConvo) {
+              debouncedRefetch()
+              return
+            }
+            queryClient.setQueryData(
+              RQKEY('request'),
+              (old?: ConvoListQueryData) =>
+                optimisticDelete(logRef.convoId, old),
+            )
+            queryClient.setQueriesData(
+              {queryKey: RQKEY('accepted')},
+              (old?: ConvoListQueryData) => {
+                if (!old) {
+                  debouncedRefetch()
+                  return old
                 }
-              }
-
-              function filterConvoFromPage(
-                convo: ChatBskyConvoDefs.ConvoView[],
-              ) {
-                return convo.filter(c => c.id !== logRef.convoId)
-              }
-
-              const existingConvo = getConvoFromQueryData(logRef.convoId, old)
-
-              if (existingConvo) {
                 return {
                   ...old,
                   pages: old.pages.map((page, i) => {
@@ -175,26 +284,38 @@ export function ListConvosProviderInner({
                       return {
                         ...page,
                         convos: [
-                          updateConvo(existingConvo),
-                          ...filterConvoFromPage(page.convos),
+                          {...acceptedConvo, status: 'accepted'},
+                          ...page.convos,
                         ],
                       }
                     }
-                    return {
-                      ...page,
-                      convos: filterConvoFromPage(page.convos),
-                    }
+                    return page
                   }),
                 }
-              } else {
-                /**
-                 * We received a message from an conversation old enough that
-                 * it doesn't exist in the query cache, meaning we need to
-                 * refetch and bump the old convo to the top.
-                 */
-                debouncedRefetch()
-              }
-            })
+              },
+            )
+          } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
+            const logRef: ChatBskyConvoDefs.LogMuteConvo = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
+                  ...convo,
+                  muted: true,
+                  rev: logRef.rev,
+                })),
+            )
+          } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) {
+            const logRef: ChatBskyConvoDefs.LogUnmuteConvo = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
+                  ...convo,
+                  muted: false,
+                  rev: logRef.rev,
+                })),
+            )
           }
         }
       },
@@ -208,15 +329,21 @@ export function ListConvosProviderInner({
   }, [
     messagesBus,
     currentConvoId,
-    refetch,
     queryClient,
     currentAccount?.did,
     debouncedRefetch,
   ])
 
   const ctx = useMemo(() => {
-    return data?.pages.flatMap(page => page.convos) ?? []
-  }, [data])
+    const convos =
+      data?.pages
+        .flatMap(page => page.convos)
+        .filter(convo => !leftConvos.includes(convo.id)) ?? []
+    return {
+      accepted: convos.filter(conv => conv.status === 'accepted'),
+      request: convos.filter(conv => conv.status === 'request'),
+    }
+  }, [data, leftConvos])
 
   return (
     <ListConvosContext.Provider value={ctx}>
@@ -228,38 +355,76 @@ export function ListConvosProviderInner({
 export function useUnreadMessageCount() {
   const {currentConvoId} = useCurrentConvoId()
   const {currentAccount} = useSession()
-  const convos = useListConvos()
+  const {accepted, request} = useListConvos()
   const moderationOpts = useModerationOpts()
 
-  const count = useMemo(() => {
-    return (
-      convos
-        .filter(convo => convo.id !== currentConvoId)
-        .reduce((acc, convo) => {
-          const otherMember = convo.members.find(
-            member => member.did !== currentAccount?.did,
-          )
-
-          if (!otherMember || !moderationOpts) return acc
-
-          const moderation = moderateProfile(otherMember, moderationOpts)
-          const shouldIgnore =
-            convo.muted ||
-            moderation.blocked ||
-            otherMember.did === 'missing.invalid'
-          const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0
-
-          return acc + unreadCount
-        }, 0) ?? 0
+  return useMemo<{
+    count: number
+    numUnread?: string
+    hasNew: boolean
+  }>(() => {
+    const acceptedCount = calculateCount(
+      accepted,
+      currentAccount?.did,
+      currentConvoId,
+      moderationOpts,
     )
-  }, [convos, currentAccount?.did, currentConvoId, moderationOpts])
-
-  return useMemo(() => {
-    return {
-      count,
-      numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined,
+    const requestCount = calculateCount(
+      request,
+      currentAccount?.did,
+      currentConvoId,
+      moderationOpts,
+    )
+    if (acceptedCount > 0) {
+      const total = acceptedCount + Math.min(requestCount, 1)
+      return {
+        count: total,
+        numUnread: total > 10 ? '10+' : String(total),
+        // only needed when numUnread is undefined
+        hasNew: false,
+      }
+    } else if (requestCount > 0) {
+      return {
+        count: 1,
+        numUnread: undefined,
+        hasNew: true,
+      }
+    } else {
+      return {
+        count: 0,
+        numUnread: undefined,
+        hasNew: false,
+      }
     }
-  }, [count])
+  }, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts])
+}
+
+function calculateCount(
+  convos: ChatBskyConvoDefs.ConvoView[],
+  currentAccountDid: string | undefined,
+  currentConvoId: string | undefined,
+  moderationOpts: ModerationOpts | undefined,
+) {
+  return (
+    convos
+      .filter(convo => convo.id !== currentConvoId)
+      .reduce((acc, convo) => {
+        const otherMember = convo.members.find(
+          member => member.did !== currentAccountDid,
+        )
+
+        if (!otherMember || !moderationOpts) return acc
+
+        const moderation = moderateProfile(otherMember, moderationOpts)
+        const shouldIgnore =
+          convo.muted ||
+          moderation.blocked ||
+          otherMember.handle === 'missing.invalid'
+        const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0
+
+        return acc + unreadCount
+      }, 0) ?? 0
+  )
 }
 
 export type ConvoListQueryData = {
@@ -272,12 +437,16 @@ export function useOnMarkAsRead() {
 
   return useCallback(
     (chatId: string) => {
-      queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
-        return optimisticUpdate(chatId, old, convo => ({
-          ...convo,
-          unreadCount: 0,
-        }))
-      })
+      queryClient.setQueriesData(
+        {queryKey: [RQKEY_ROOT]},
+        (old?: ConvoListQueryData) => {
+          if (!old) return old
+          return optimisticUpdate(chatId, old, convo => ({
+            ...convo,
+            unreadCount: 0,
+          }))
+        },
+      )
     },
     [queryClient],
   )
@@ -285,10 +454,12 @@ export function useOnMarkAsRead() {
 
 function optimisticUpdate(
   chatId: string,
-  old: ConvoListQueryData,
-  updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
+  old?: ConvoListQueryData,
+  updateFn?: (
+    convo: ChatBskyConvoDefs.ConvoView,
+  ) => ChatBskyConvoDefs.ConvoView,
 ) {
-  if (!old) return old
+  if (!old || !updateFn) return old
 
   return {
     ...old,
@@ -301,7 +472,7 @@ function optimisticUpdate(
   }
 }
 
-function optimisticDelete(chatId: string, old: ConvoListQueryData) {
+function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
   if (!old) return old
 
   return {
@@ -331,7 +502,7 @@ export function* findAllProfilesInQueryData(
   const queryDatas = queryClient.getQueriesData<
     InfiniteData<ChatBskyConvoListConvos.OutputSchema>
   >({
-    queryKey: RQKEY,
+    queryKey: [RQKEY_ROOT],
   })
   for (const [_queryKey, queryData] of queryDatas) {
     if (!queryData?.pages) {
-- 
cgit 1.4.1

