about summary refs log tree commit diff
path: root/src/state/queries/notifications/unread.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/notifications/unread.tsx')
-rw-r--r--src/state/queries/notifications/unread.tsx179
1 files changed, 179 insertions, 0 deletions
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
new file mode 100644
index 000000000..6c130aaea
--- /dev/null
+++ b/src/state/queries/notifications/unread.tsx
@@ -0,0 +1,179 @@
+/**
+ * A kind of companion API to ./feed.ts. See that file for more info.
+ */
+
+import React from 'react'
+import * as Notifications from 'expo-notifications'
+import {useQueryClient} from '@tanstack/react-query'
+import BroadcastChannel from '#/lib/broadcast'
+import {useSession, getAgent} from '#/state/session'
+import {useModerationOpts} from '../preferences'
+import {fetchPage} from './util'
+import {CachedFeedPage, FeedPage} from './types'
+import {isNative} from '#/platform/detection'
+import {useMutedThreads} from '#/state/muted-threads'
+import {RQKEY as RQKEY_NOTIFS} from './feed'
+import {logger} from '#/logger'
+import {truncateAndInvalidate} from '../util'
+
+const UPDATE_INTERVAL = 30 * 1e3 // 30sec
+
+const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL')
+
+type StateContext = string
+
+interface ApiContext {
+  markAllRead: () => Promise<void>
+  checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
+  getCachedUnreadPage: () => FeedPage | undefined
+}
+
+const stateContext = React.createContext<StateContext>('')
+
+const apiContext = React.createContext<ApiContext>({
+  async markAllRead() {},
+  async checkUnread() {},
+  getCachedUnreadPage: () => undefined,
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const {hasSession, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  const moderationOpts = useModerationOpts()
+  const threadMutes = useMutedThreads()
+
+  const [numUnread, setNumUnread] = React.useState('')
+
+  const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
+  const cacheRef = React.useRef<CachedFeedPage>({
+    sessDid: currentAccount?.did || '',
+    syncedAt: new Date(),
+    data: undefined,
+  })
+
+  // periodic sync
+  React.useEffect(() => {
+    if (!hasSession || !checkUnreadRef.current) {
+      return
+    }
+    checkUnreadRef.current() // fire on init
+    const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL)
+    return () => clearInterval(interval)
+  }, [hasSession])
+
+  // listen for broadcasts
+  React.useEffect(() => {
+    const listener = ({data}: MessageEvent) => {
+      cacheRef.current = {
+        sessDid: currentAccount?.did || '',
+        syncedAt: new Date(),
+        data: undefined,
+      }
+      setNumUnread(data.event)
+    }
+    broadcast.addEventListener('message', listener)
+    return () => {
+      broadcast.removeEventListener('message', listener)
+    }
+  }, [setNumUnread, currentAccount])
+
+  // create API
+  const api = React.useMemo<ApiContext>(() => {
+    return {
+      async markAllRead() {
+        // update server
+        await getAgent().updateSeenNotifications(
+          cacheRef.current.syncedAt.toISOString(),
+        )
+
+        // update & broadcast
+        setNumUnread('')
+        broadcast.postMessage({event: ''})
+      },
+
+      async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
+        try {
+          if (!getAgent().session) return
+
+          // count
+          const page = await fetchPage({
+            cursor: undefined,
+            limit: 40,
+            queryClient,
+            moderationOpts,
+            threadMutes,
+          })
+          const unreadCount = countUnread(page)
+          const unreadCountStr =
+            unreadCount >= 30
+              ? '30+'
+              : unreadCount === 0
+              ? ''
+              : String(unreadCount)
+          if (isNative) {
+            Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
+          }
+
+          // track last sync
+          const now = new Date()
+          const lastIndexed =
+            page.items[0] && new Date(page.items[0].notification.indexedAt)
+          cacheRef.current = {
+            sessDid: currentAccount?.did || '',
+            data: page,
+            syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
+          }
+
+          // update & broadcast
+          setNumUnread(unreadCountStr)
+          if (invalidate) {
+            truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
+          }
+          broadcast.postMessage({event: unreadCountStr})
+        } catch (e) {
+          logger.error('Failed to check unread notifications', {error: e})
+        }
+      },
+
+      getCachedUnreadPage() {
+        // return cached page if was for the current user
+        // (protects against session changes serving data from the past session)
+        if (cacheRef.current.sessDid === currentAccount?.did) {
+          return cacheRef.current.data
+        }
+      },
+    }
+  }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount])
+  checkUnreadRef.current = api.checkUnread
+
+  return (
+    <stateContext.Provider value={numUnread}>
+      <apiContext.Provider value={api}>{children}</apiContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useUnreadNotifications() {
+  return React.useContext(stateContext)
+}
+
+export function useUnreadNotificationsApi() {
+  return React.useContext(apiContext)
+}
+
+function countUnread(page: FeedPage) {
+  let num = 0
+  for (const item of page.items) {
+    if (!item.notification.isRead) {
+      num++
+    }
+    if (item.additional) {
+      for (const item2 of item.additional) {
+        if (!item2.isRead) {
+          num++
+        }
+      }
+    }
+  }
+  return num
+}