import { ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants' import { compareEvents } from '@/lib/event' import { isTouchDevice } from '@/lib/utils' import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { useNotification } from '@/providers/NotificationProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import client from '@/services/client.service' import stuffStatsService from '@/services/stuff-stats.service' import threadService from '@/services/thread.service' import { TNotificationType } from '@/types' import dayjs from 'dayjs' import { NostrEvent, kinds, matchFilter } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import { RefreshButton } from '../RefreshButton' import Tabs from '../Tabs' import { NotificationItem } from './NotificationItem' import { NotificationSkeleton } from './NotificationItem/Notification' const LIMIT = 100 const SHOW_COUNT = 30 const NotificationList = forwardRef((_, ref) => { const { t } = useTranslation() const { current, display } = usePrimaryPage() const active = useMemo(() => current === 'notifications' && display, [current, display]) const { pubkey } = useNostr() const { getNotificationsSeenAt } = useNotification() const { notificationListStyle } = useUserPreferences() const [notificationType, setNotificationType] = useState('all') const [lastReadTime, setLastReadTime] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) const [initialLoading, setInitialLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [refreshing, setRefreshing] = useState(false) const [notifications, setNotifications] = useState([]) const [visibleNotifications, setVisibleNotifications] = useState([]) const [showCount, setShowCount] = useState(SHOW_COUNT) const [until, setUntil] = useState(dayjs().unix()) const supportTouch = useMemo(() => isTouchDevice(), []) const topRef = useRef(null) const bottomRef = useRef(null) const closerRef = useRef<(() => void) | null>(null) const filterKinds = useMemo(() => { switch (notificationType) { case 'mentions': return [ kinds.ShortTextNote, kinds.Highlights, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.POLL ] case 'reactions': return [kinds.Reaction, kinds.Repost, kinds.GenericRepost, ExtendedKind.POLL_RESPONSE] case 'zaps': return [kinds.Zap] default: return [ kinds.ShortTextNote, kinds.Repost, kinds.GenericRepost, kinds.Reaction, kinds.Zap, kinds.Highlights, ExtendedKind.COMMENT, ExtendedKind.POLL_RESPONSE, ExtendedKind.VOICE_COMMENT, ExtendedKind.POLL ] } }, [notificationType]) const mergeNotifications = useCallback( (incoming: NostrEvent[]) => { const filtered = incoming.filter((event) => event.pubkey !== pubkey) if (filtered.length === 0) return setNotifications((old) => { const existingIds = new Set(old.map((n) => n.id)) const uniqueNew = filtered.filter((e) => !existingIds.has(e.id)) if (uniqueNew.length === 0) return old const merged = [...uniqueNew, ...old] merged.sort((a, b) => b.created_at - a.created_at) return merged }) }, [pubkey] ) const handleNewEvent = useCallback( (event: NostrEvent) => { if (event.pubkey === pubkey) return setNotifications((oldEvents) => { const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0) if (index !== -1 && oldEvents[index].id === event.id) { return oldEvents } stuffStatsService.updateStuffStatsByEvents([event]) if (index === -1) { return [...oldEvents, event] } return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)] }) }, [pubkey] ) // Refresh: fetch latest events and merge into existing list without tearing down const doRefresh = useCallback(async () => { if (!pubkey || refreshing) return setRefreshing(true) setLastReadTime(getNotificationsSeenAt()) try { const relayList = await client.fetchRelayList(pubkey) const urls = relayList.read.length > 0 ? relayList.read.slice(0, 5) : client.currentRelays.slice(0, 5) const events = await client.fetchEvents(urls, { '#p': [pubkey], kinds: filterKinds, limit: LIMIT }) if (events.length > 0) { mergeNotifications(events) threadService.addRepliesToThread(events) stuffStatsService.updateStuffStatsByEvents(events) } } finally { setRefreshing(false) } }, [pubkey, refreshing, filterKinds, mergeNotifications, getNotificationsSeenAt]) useImperativeHandle( ref, () => ({ refresh: () => { if (!refreshing) doRefresh() } }), [refreshing, doRefresh] ) // Initial subscription — only re-runs when pubkey or filterKinds change useEffect(() => { if (current !== 'notifications') return if (!pubkey) { setUntil(undefined) return } const init = async () => { setInitialLoading(true) setNotifications([]) setShowCount(SHOW_COUNT) setLastReadTime(getNotificationsSeenAt()) const relayList = await client.fetchRelayList(pubkey) const { closer, timelineKey } = await client.subscribeTimeline( [ { urls: relayList.read.length > 0 ? relayList.read.slice(0, 5) : client.currentRelays.slice(0, 5), filter: { '#p': [pubkey], kinds: filterKinds, limit: LIMIT } } ], { onEvents: (events, eosed) => { if (events.length > 0) { setNotifications(events.filter((event) => event.pubkey !== pubkey)) } if (eosed) { setInitialLoading(false) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) threadService.addRepliesToThread(events) stuffStatsService.updateStuffStatsByEvents(events) } }, onNew: (event) => { handleNewEvent(event) threadService.addRepliesToThread([event]) } } ) closerRef.current = closer setTimelineKey(timelineKey) } init() return () => { closerRef.current?.() closerRef.current = null } }, [pubkey, filterKinds, current]) useEffect(() => { if (!active || !pubkey) return const handler = (data: Event) => { const customEvent = data as CustomEvent const evt = customEvent.detail if ( matchFilter( { kinds: filterKinds, '#p': [pubkey] }, evt ) ) { handleNewEvent(evt) } } client.addEventListener('newEvent', handler) return () => { client.removeEventListener('newEvent', handler) } }, [pubkey, active, filterKinds, handleNewEvent]) useEffect(() => { setVisibleNotifications(notifications.slice(0, showCount)) }, [notifications, showCount]) useEffect(() => { const options = { root: null, rootMargin: '10px', threshold: 1 } const loadMore = async () => { if (showCount < notifications.length) { setShowCount((count) => count + SHOW_COUNT) // preload more if (notifications.length - showCount > LIMIT / 2) { return } } if (!pubkey || !timelineKey || !until || loadingMore || initialLoading) return setLoadingMore(true) const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT) setLoadingMore(false) if (newNotifications.length === 0) { setUntil(undefined) return } if (newNotifications.length > 0) { setNotifications((oldNotifications) => [ ...oldNotifications, ...newNotifications.filter((event) => event.pubkey !== pubkey) ]) } setUntil(newNotifications[newNotifications.length - 1].created_at - 1) } const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { loadMore() } }, options) const currentBottomRef = bottomRef.current if (currentBottomRef) { observerInstance.observe(currentBottomRef) } return () => { if (observerInstance && currentBottomRef) { observerInstance.unobserve(currentBottomRef) } } }, [pubkey, timelineKey, until, loadingMore, initialLoading, showCount, notifications]) const refresh = () => { topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' }) doRefresh() } const list = (
{visibleNotifications.map((notification, index) => ( lastReadTime} navIndex={index} /> ))}
{until || loadingMore || initialLoading ? (
) : ( t('no more notifications') )}
) return (
{ setShowCount(SHOW_COUNT) setNotificationType(type as TNotificationType) }} options={!supportTouch ? refresh()} /> : null} />
{supportTouch ? ( { doRefresh() await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" > {list} ) : ( list )}
) }) NotificationList.displayName = 'NotificationList' export default NotificationList