import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { compareEvents } from '@/lib/event' import logger from '@/lib/logger' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' import { kinds, NostrEvent } from 'nostr-tools' import { SubCloser } from 'nostr-tools/abstract-pool' import { useEffect, useRef, useMemo } from 'react' import { useNostr } from './NostrProvider' /** * Subscribes to live notifications and forwards new events via {@link client.emitNewEvent}. * (Read/unread UI and cross-device “seen at” sync were removed.) */ export function NotificationProvider({ children }: { children: React.ReactNode }) { const { pubkey, relayList } = useNostr() const { favoriteRelays } = useFavoriteRelays() const notificationBufferRef = useRef([]) const retryCountRef = useRef(0) const retryTimeoutIdRef = useRef(null) // Memoize relay URLs to prevent unnecessary re-subscriptions // This creates stable references based on actual relay URLs, not object references const userReadRelays = useMemo(() => { const userRelayList = relayList || { read: [], write: [] } return userRelayList.read || [] }, [relayList?.read?.join(',')]) // Compare by stringified array, not object reference const userFavoriteRelays = useMemo(() => { return favoriteRelays || [] }, [favoriteRelays?.join(',')]) // Compare by stringified array, not array reference // Memoize the notification relays to prevent re-subscriptions when they haven't changed const notificationRelays = useMemo(() => { if (userReadRelays.length > 0) { return userReadRelays.slice(0, 5) } else if (userFavoriteRelays.length > 0) { return userFavoriteRelays.slice(0, 5) } else { return FAST_READ_RELAY_URLS.slice(0, 5) } }, [userReadRelays, userFavoriteRelays]) useEffect(() => { if (!pubkey) return const deferredReset = setTimeout(() => { notificationBufferRef.current = [] }, 0) const isMountedRef = { current: true } const subCloserRef: { current: SubCloser | null } = { current: null } const topicSubCloserRef: { current: SubCloser | null } = { current: null } const MAX_RETRIES = 5 // Reset retry count when effect runs (relays changed) retryCountRef.current = 0 const subscribe = async () => { // Clear any pending retries if (retryTimeoutIdRef.current) { clearTimeout(retryTimeoutIdRef.current) retryTimeoutIdRef.current = null } if (subCloserRef.current) { subCloserRef.current.close() subCloserRef.current = null } if (topicSubCloserRef.current) { topicSubCloserRef.current.close() topicSubCloserRef.current = null } if (!isMountedRef.current) return null try { let eosed = false // Reset retry count on successful subscription attempt retryCountRef.current = 0 if (notificationRelays.length > 0) { logger.component('NotificationProvider', 'Using notification relays', { count: notificationRelays.length, relays: notificationRelays.slice(0, 3) }) } let discussionEosed = false const discussionSubCloser = client.subscribe( notificationRelays, [ { kinds: [11], limit: 20 } ], { oneose: (e) => { if (e) { discussionEosed = e } }, onevent: (evt) => { if (evt.pubkey !== pubkey) { const prev = notificationBufferRef.current if (!discussionEosed) { // Before EOSE: just buffer events, limit size if (prev.length < 100) { notificationBufferRef.current = [evt, ...prev] } return } if (prev.length && compareEvents(prev[0], evt) >= 0) { return } // Limit buffer size to prevent memory issues if (prev.length >= 50) { notificationBufferRef.current = [evt, ...prev.slice(0, 49)] } else { notificationBufferRef.current = [evt, ...prev] } client.emitNewEvent(evt) } } } ) topicSubCloserRef.current = discussionSubCloser const subCloser = client.subscribe( notificationRelays, [ { kinds: [ kinds.ShortTextNote, kinds.Repost, kinds.Reaction, kinds.Zap, ExtendedKind.COMMENT, ExtendedKind.POLL_RESPONSE, ExtendedKind.VOICE_COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE ], '#p': [pubkey], limit: 20 } ], { oneose: (e) => { if (e) { eosed = e // Don't sort on every EOSE - sorting is expensive and buffer is already maintained in order // Only sort if buffer is getting large and out of order if (notificationBufferRef.current.length > 100) { notificationBufferRef.current = [ ...notificationBufferRef.current.sort((a, b) => compareEvents(b, a)) ] } } }, onevent: (evt) => { if (evt.pubkey !== pubkey) { const prev = notificationBufferRef.current if (!eosed) { // Before EOSE: just buffer events, don't emit yet // Limit buffer size to prevent memory issues if (prev.length < 100) { notificationBufferRef.current = [evt, ...prev] } return } // After EOSE: only emit if it's newer than the most recent event if (prev.length && compareEvents(prev[0], evt) >= 0) { return } // Limit buffer size to prevent memory issues if (prev.length >= 50) { notificationBufferRef.current = [evt, ...prev.slice(0, 49)] } else { notificationBufferRef.current = [evt, ...prev] } client.emitNewEvent(evt) } }, onAllClose: (reasons) => { if (reasons.every((reason) => reason === 'closed by caller')) { return } if (isMountedRef.current && retryCountRef.current < MAX_RETRIES) { retryCountRef.current++ const delay = Math.min(15_000 * retryCountRef.current, 60_000) // Exponential backoff, max 60s logger.debug(`[NotificationProvider] Reconnecting after close (attempt ${retryCountRef.current}/${MAX_RETRIES})...`) retryTimeoutIdRef.current = setTimeout(() => { if (isMountedRef.current) { subscribe() } }, delay) } else if (retryCountRef.current >= MAX_RETRIES) { logger.error('[NotificationProvider] Max retries reached, stopping reconnection attempts') } } } ) subCloserRef.current = subCloser return subCloser } catch (error) { logger.error('Subscription error', { error, retryCount: retryCountRef.current }) if (isMountedRef.current && retryCountRef.current < MAX_RETRIES) { retryCountRef.current++ const delay = Math.min(5_000 * retryCountRef.current, 30_000) // Exponential backoff, max 30s retryTimeoutIdRef.current = setTimeout(() => { if (isMountedRef.current) { subscribe() } }, delay) } else if (retryCountRef.current >= MAX_RETRIES) { logger.error('[NotificationProvider] Max retries reached, stopping subscription attempts') } return null } } subscribe() return () => { clearTimeout(deferredReset) if (retryTimeoutIdRef.current) { clearTimeout(retryTimeoutIdRef.current) retryTimeoutIdRef.current = null } retryCountRef.current = 0 // Reset retry count on cleanup isMountedRef.current = false if (subCloserRef.current) { subCloserRef.current.close() subCloserRef.current = null } if (topicSubCloserRef.current) { topicSubCloserRef.current.close() topicSubCloserRef.current = null } } }, [pubkey, notificationRelays.join(',')]) // Use memoized notificationRelays instead of relayList/favoriteRelays return <>{children} }