forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * A kind of companion API to ./feed.ts. See that file for more info.
3 */
4
5import React, {useRef} from 'react'
6import {AppState} from 'react-native'
7import {useQueryClient} from '@tanstack/react-query'
8import EventEmitter from 'eventemitter3'
9
10import BroadcastChannel from '#/lib/broadcast'
11import {resetBadgeCount} from '#/lib/notifications/notifications'
12import {useModerationOpts} from '#/state/preferences/moderation-opts'
13import {truncateAndInvalidate} from '#/state/queries/util'
14import {useAgent, useSession} from '#/state/session'
15import {RQKEY as RQKEY_NOTIFS} from './feed'
16import {type CachedFeedPage, type FeedPage} from './types'
17import {fetchPage} from './util'
18
19const UPDATE_INTERVAL = 30 * 1e3 // 30sec
20
21const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL')
22
23const emitter = new EventEmitter()
24
25type StateContext = string
26
27interface ApiContext {
28 markAllRead: () => Promise<void>
29 checkUnread: (opts?: {
30 invalidate?: boolean
31 isPoll?: boolean
32 }) => Promise<void>
33 getCachedUnreadPage: () => FeedPage | undefined
34}
35
36const stateContext = React.createContext<StateContext>('')
37stateContext.displayName = 'NotificationsUnreadStateContext'
38
39const apiContext = React.createContext<ApiContext>({
40 async markAllRead() {},
41 async checkUnread() {},
42 getCachedUnreadPage: () => undefined,
43})
44apiContext.displayName = 'NotificationsUnreadApiContext'
45
46export function Provider({children}: React.PropsWithChildren<{}>) {
47 const {hasSession} = useSession()
48 const agent = useAgent()
49 const queryClient = useQueryClient()
50 const moderationOpts = useModerationOpts()
51
52 const [numUnread, setNumUnread] = React.useState('')
53
54 const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
55 const cacheRef = React.useRef<CachedFeedPage>({
56 usableInFeed: false,
57 syncedAt: new Date(),
58 data: undefined,
59 unreadCount: 0,
60 })
61
62 React.useEffect(() => {
63 function markAsUnusable() {
64 if (cacheRef.current) {
65 cacheRef.current.usableInFeed = false
66 }
67 }
68 emitter.addListener('invalidate', markAsUnusable)
69 return () => {
70 emitter.removeListener('invalidate', markAsUnusable)
71 }
72 }, [])
73
74 // periodic sync
75 React.useEffect(() => {
76 if (!hasSession || !checkUnreadRef.current) {
77 return
78 }
79 checkUnreadRef.current() // fire on init
80 const interval = setInterval(
81 () => checkUnreadRef.current?.({isPoll: true}),
82 UPDATE_INTERVAL,
83 )
84 return () => clearInterval(interval)
85 }, [hasSession])
86
87 // listen for broadcasts
88 React.useEffect(() => {
89 const listener = ({data}: MessageEvent) => {
90 cacheRef.current = {
91 usableInFeed: false,
92 syncedAt: new Date(),
93 data: undefined,
94 unreadCount:
95 data.event === '30+'
96 ? 30
97 : data.event === ''
98 ? 0
99 : parseInt(data.event, 10) || 1,
100 }
101 setNumUnread(data.event)
102 }
103 broadcast.addEventListener('message', listener)
104 return () => {
105 broadcast.removeEventListener('message', listener)
106 }
107 }, [setNumUnread])
108
109 const isFetchingRef = useRef(false)
110
111 // create API
112 const api = React.useMemo<ApiContext>(() => {
113 return {
114 async markAllRead() {
115 // update server
116 await agent.updateSeenNotifications(
117 cacheRef.current.syncedAt.toISOString(),
118 )
119
120 // update & broadcast
121 setNumUnread('')
122 broadcast.postMessage({event: ''})
123 resetBadgeCount()
124 },
125
126 async checkUnread({
127 invalidate,
128 isPoll,
129 }: {invalidate?: boolean; isPoll?: boolean} = {}) {
130 try {
131 if (!agent.session) return
132 if (AppState.currentState !== 'active') {
133 return
134 }
135
136 // reduce polling if unread count is set
137 if (isPoll && cacheRef.current?.unreadCount !== 0) {
138 // if hit 30+ then don't poll, otherwise reduce polling by 50%
139 if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) {
140 return
141 }
142 }
143
144 if (isFetchingRef.current) {
145 return
146 }
147 // Do not move this without ensuring it gets a symmetrical reset in the finally block.
148 isFetchingRef.current = true
149
150 // count
151 const {page, indexedAt: lastIndexed} = await fetchPage({
152 agent,
153 cursor: undefined,
154 limit: 40,
155 queryClient,
156 moderationOpts,
157 reasons: [],
158
159 // only fetch subjects when the page is going to be used
160 // in the notifications query, otherwise skip it
161 fetchAdditionalData: !!invalidate,
162 })
163 const unreadCount = countUnread(page)
164 const unreadCountStr =
165 unreadCount >= 30
166 ? '30+'
167 : unreadCount === 0
168 ? ''
169 : String(unreadCount)
170
171 // track last sync
172 const now = new Date()
173 const lastIndexedDate = lastIndexed
174 ? new Date(lastIndexed)
175 : undefined
176 cacheRef.current = {
177 usableInFeed: !!invalidate, // will be used immediately
178 data: page,
179 syncedAt:
180 !lastIndexedDate || now > lastIndexedDate ? now : lastIndexedDate,
181 unreadCount,
182 }
183
184 // update & broadcast
185 setNumUnread(unreadCountStr)
186 if (invalidate) {
187 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
188 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
189 }
190 broadcast.postMessage({event: unreadCountStr})
191 } finally {
192 isFetchingRef.current = false
193 }
194 },
195
196 getCachedUnreadPage() {
197 // return cached page if it's marked as fresh enough
198 if (cacheRef.current.usableInFeed) {
199 return cacheRef.current.data
200 }
201 },
202 }
203 }, [setNumUnread, queryClient, moderationOpts, agent])
204 checkUnreadRef.current = api.checkUnread
205
206 return (
207 <stateContext.Provider value={numUnread}>
208 <apiContext.Provider value={api}>{children}</apiContext.Provider>
209 </stateContext.Provider>
210 )
211}
212
213export function useUnreadNotifications() {
214 return React.useContext(stateContext)
215}
216
217export function useUnreadNotificationsApi() {
218 return React.useContext(apiContext)
219}
220
221function countUnread(page: FeedPage) {
222 let num = 0
223 for (const item of page.items) {
224 if (!item.notification.isRead) {
225 num++
226 }
227 if (item.additional) {
228 for (const item2 of item.additional) {
229 if (!item2.isRead) {
230 num++
231 }
232 }
233 }
234 }
235 return num
236}
237
238export function invalidateCachedUnreadPage() {
239 emitter.emit('invalidate')
240}