NotificationProvider.tsx raw
1 import { ExtendedKind } from '@/constants'
2 import { compareEvents } from '@/lib/event'
3 import { notificationFilter } from '@/lib/notification'
4 import { usePrimaryPage } from '@/PageManager'
5 import client from '@/services/client.service'
6 import storage from '@/services/local-storage.service'
7 import { kinds, NostrEvent } from 'nostr-tools'
8 import { SubCloser } from 'nostr-tools/abstract-pool'
9 import { createContext, useContext, useEffect, useMemo, useState } from 'react'
10 import { useContentPolicy } from './ContentPolicyProvider'
11 import { useMuteList } from './MuteListProvider'
12 import { useNostr } from './NostrProvider'
13 import { useUserTrust } from './UserTrustProvider'
14
15 type TNotificationContext = {
16 hasNewNotification: boolean
17 getNotificationsSeenAt: () => number
18 isNotificationRead: (id: string) => boolean
19 markNotificationAsRead: (id: string) => void
20 }
21
22 const NotificationContext = createContext<TNotificationContext | undefined>(undefined)
23
24 export const useNotification = () => {
25 const context = useContext(NotificationContext)
26 if (!context) {
27 throw new Error('useNotification must be used within a NotificationProvider')
28 }
29 return context
30 }
31
32 export function NotificationProvider({ children }: { children: React.ReactNode }) {
33 const { current } = usePrimaryPage()
34 const active = useMemo(() => current === 'notifications', [current])
35 const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
36 const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
37 const { mutePubkeySet } = useMuteList()
38 const { hideContentMentioningMutedUsers } = useContentPolicy()
39 const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([])
40 const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set())
41 const filteredNewNotifications = useMemo(() => {
42 if (active || notificationsSeenAt < 0) {
43 return []
44 }
45 const filtered: NostrEvent[] = []
46 for (const notification of newNotifications) {
47 if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
48 break
49 }
50 if (
51 !notificationFilter(notification, {
52 pubkey,
53 mutePubkeySet,
54 hideContentMentioningMutedUsers,
55 hideUntrustedNotifications,
56 isUserTrusted
57 })
58 ) {
59 continue
60 }
61 filtered.push(notification)
62 }
63 return filtered
64 }, [
65 newNotifications,
66 notificationsSeenAt,
67 mutePubkeySet,
68 hideContentMentioningMutedUsers,
69 hideUntrustedNotifications,
70 isUserTrusted,
71 active
72 ])
73
74 useEffect(() => {
75 setNewNotifications([])
76 updateNotificationsSeenAt(!active)
77 }, [active])
78
79 useEffect(() => {
80 if (!pubkey) return
81
82 setNewNotifications([])
83 setReadNotificationIdSet(new Set())
84
85 // Track if component is mounted
86 const isMountedRef = { current: true }
87 const subCloserRef: {
88 current: SubCloser | null
89 } = { current: null }
90
91 const subscribe = async () => {
92 if (subCloserRef.current) {
93 subCloserRef.current.close()
94 subCloserRef.current = null
95 }
96 if (!isMountedRef.current) return null
97
98 try {
99 let eosed = false
100 const relayList = await client.fetchRelayList(pubkey)
101 const subCloser = client.subscribe(
102 relayList.read.length > 0 ? relayList.read.slice(0, 5) : client.currentRelays.slice(0, 5),
103 [
104 {
105 kinds: [
106 kinds.ShortTextNote,
107 kinds.Repost,
108 kinds.Reaction,
109 kinds.Zap,
110 ExtendedKind.COMMENT,
111 ExtendedKind.POLL_RESPONSE,
112 ExtendedKind.VOICE_COMMENT,
113 ExtendedKind.POLL
114 ],
115 '#p': [pubkey],
116 limit: 20
117 }
118 ],
119 {
120 oneose: (e) => {
121 if (e) {
122 eosed = e
123 setNewNotifications((prev) => {
124 return [...prev.sort((a, b) => compareEvents(b, a))]
125 })
126 }
127 },
128 onevent: (evt) => {
129 if (evt.pubkey !== pubkey) {
130 setNewNotifications((prev) => {
131 if (!eosed) {
132 return [evt, ...prev]
133 }
134 if (prev.length && compareEvents(prev[0], evt) >= 0) {
135 return prev
136 }
137
138 client.emitNewEvent(evt)
139 return [evt, ...prev]
140 })
141 }
142 },
143 onAllClose: (reasons) => {
144 if (reasons.every((reason) => reason === 'closed by caller')) {
145 return
146 }
147
148 // Only reconnect if still mounted and not a manual close
149 if (isMountedRef.current) {
150 setTimeout(() => {
151 if (isMountedRef.current) {
152 subscribe()
153 }
154 }, 5_000)
155 }
156 }
157 }
158 )
159
160 subCloserRef.current = subCloser
161 return subCloser
162 } catch (error) {
163 console.error('Subscription error:', error)
164
165 // Retry on error if still mounted
166 if (isMountedRef.current) {
167 setTimeout(() => {
168 if (isMountedRef.current) {
169 subscribe()
170 }
171 }, 5_000)
172 }
173 return null
174 }
175 }
176
177 // Initial subscription
178 subscribe()
179
180 // Cleanup function
181 return () => {
182 isMountedRef.current = false
183 if (subCloserRef.current) {
184 subCloserRef.current.close()
185 subCloserRef.current = null
186 }
187 }
188 }, [pubkey])
189
190 useEffect(() => {
191 const newNotificationCount = filteredNewNotifications.length
192
193 // Update title
194 if (newNotificationCount > 0) {
195 document.title = `(${newNotificationCount >= 10 ? '9+' : newNotificationCount}) Smesh`
196 } else {
197 document.title = 'Smesh'
198 }
199
200 // Update favicons
201 const favicons = document.querySelectorAll<HTMLLinkElement>("link[rel*='icon']")
202 if (!favicons.length) return
203
204 if (newNotificationCount === 0) {
205 favicons.forEach((favicon) => {
206 favicon.href = '/favicon.ico'
207 })
208 } else {
209 const img = document.createElement('img')
210 img.src = '/favicon.ico'
211 img.onload = () => {
212 const size = Math.max(img.width, img.height, 32)
213 const canvas = document.createElement('canvas')
214 canvas.width = size
215 canvas.height = size
216 const ctx = canvas.getContext('2d')
217 if (!ctx) return
218 ctx.drawImage(img, 0, 0, size, size)
219 const r = size * 0.16
220 ctx.beginPath()
221 ctx.arc(size - r - 6, r + 6, r, 0, 2 * Math.PI)
222 ctx.fillStyle = '#FF0000'
223 ctx.fill()
224 favicons.forEach((favicon) => {
225 favicon.href = canvas.toDataURL('image/png')
226 })
227 }
228 }
229 }, [filteredNewNotifications])
230
231 const getNotificationsSeenAt = () => {
232 if (notificationsSeenAt >= 0) {
233 return notificationsSeenAt
234 }
235 if (pubkey) {
236 return storage.getLastReadNotificationTime(pubkey)
237 }
238 return 0
239 }
240
241 const isNotificationRead = (notificationId: string): boolean => {
242 return readNotificationIdSet.has(notificationId)
243 }
244
245 const markNotificationAsRead = (notificationId: string): void => {
246 setReadNotificationIdSet((prev) => new Set([...prev, notificationId]))
247 }
248
249 return (
250 <NotificationContext.Provider
251 value={{
252 hasNewNotification: filteredNewNotifications.length > 0,
253 getNotificationsSeenAt,
254 isNotificationRead,
255 markNotificationAsRead
256 }}
257 >
258 {children}
259 </NotificationContext.Provider>
260 )
261 }
262