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