index.tsx raw
1 import { ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants'
2 import { compareEvents } from '@/lib/event'
3 import { isTouchDevice } from '@/lib/utils'
4 import { usePrimaryPage } from '@/PageManager'
5 import { useNostr } from '@/providers/NostrProvider'
6 import { useNotification } from '@/providers/NotificationProvider'
7 import { useUserPreferences } from '@/providers/UserPreferencesProvider'
8 import client from '@/services/client.service'
9 import stuffStatsService from '@/services/stuff-stats.service'
10 import threadService from '@/services/thread.service'
11 import { TNotificationType } from '@/types'
12 import dayjs from 'dayjs'
13 import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
14 import {
15 forwardRef,
16 useCallback,
17 useEffect,
18 useImperativeHandle,
19 useMemo,
20 useRef,
21 useState
22 } from 'react'
23 import { useTranslation } from 'react-i18next'
24 import PullToRefresh from 'react-simple-pull-to-refresh'
25 import { RefreshButton } from '../RefreshButton'
26 import Tabs from '../Tabs'
27 import { NotificationItem } from './NotificationItem'
28 import { NotificationSkeleton } from './NotificationItem/Notification'
29
30 const LIMIT = 100
31 const SHOW_COUNT = 30
32
33 const NotificationList = forwardRef((_, ref) => {
34 const { t } = useTranslation()
35 const { current, display } = usePrimaryPage()
36 const active = useMemo(() => current === 'notifications' && display, [current, display])
37 const { pubkey } = useNostr()
38 const { getNotificationsSeenAt } = useNotification()
39 const { notificationListStyle } = useUserPreferences()
40 const [notificationType, setNotificationType] = useState<TNotificationType>('all')
41 const [lastReadTime, setLastReadTime] = useState(0)
42 const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
43 const [initialLoading, setInitialLoading] = useState(true)
44 const [loadingMore, setLoadingMore] = useState(false)
45 const [refreshing, setRefreshing] = useState(false)
46 const [notifications, setNotifications] = useState<NostrEvent[]>([])
47 const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([])
48 const [showCount, setShowCount] = useState(SHOW_COUNT)
49 const [until, setUntil] = useState<number | undefined>(dayjs().unix())
50 const supportTouch = useMemo(() => isTouchDevice(), [])
51 const topRef = useRef<HTMLDivElement | null>(null)
52 const bottomRef = useRef<HTMLDivElement | null>(null)
53 const closerRef = useRef<(() => void) | null>(null)
54 const filterKinds = useMemo(() => {
55 switch (notificationType) {
56 case 'mentions':
57 return [
58 kinds.ShortTextNote,
59 kinds.Highlights,
60 ExtendedKind.COMMENT,
61 ExtendedKind.VOICE_COMMENT,
62 ExtendedKind.POLL
63 ]
64 case 'reactions':
65 return [kinds.Reaction, kinds.Repost, kinds.GenericRepost, ExtendedKind.POLL_RESPONSE]
66 case 'zaps':
67 return [kinds.Zap]
68 default:
69 return [
70 kinds.ShortTextNote,
71 kinds.Repost,
72 kinds.GenericRepost,
73 kinds.Reaction,
74 kinds.Zap,
75 kinds.Highlights,
76 ExtendedKind.COMMENT,
77 ExtendedKind.POLL_RESPONSE,
78 ExtendedKind.VOICE_COMMENT,
79 ExtendedKind.POLL
80 ]
81 }
82 }, [notificationType])
83
84 const mergeNotifications = useCallback(
85 (incoming: NostrEvent[]) => {
86 const filtered = incoming.filter((event) => event.pubkey !== pubkey)
87 if (filtered.length === 0) return
88 setNotifications((old) => {
89 const existingIds = new Set(old.map((n) => n.id))
90 const uniqueNew = filtered.filter((e) => !existingIds.has(e.id))
91 if (uniqueNew.length === 0) return old
92 const merged = [...uniqueNew, ...old]
93 merged.sort((a, b) => b.created_at - a.created_at)
94 return merged
95 })
96 },
97 [pubkey]
98 )
99
100 const handleNewEvent = useCallback(
101 (event: NostrEvent) => {
102 if (event.pubkey === pubkey) return
103 setNotifications((oldEvents) => {
104 const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0)
105 if (index !== -1 && oldEvents[index].id === event.id) {
106 return oldEvents
107 }
108
109 stuffStatsService.updateStuffStatsByEvents([event])
110 if (index === -1) {
111 return [...oldEvents, event]
112 }
113 return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
114 })
115 },
116 [pubkey]
117 )
118
119 // Refresh: fetch latest events and merge into existing list without tearing down
120 const doRefresh = useCallback(async () => {
121 if (!pubkey || refreshing) return
122 setRefreshing(true)
123 setLastReadTime(getNotificationsSeenAt())
124
125 try {
126 const relayList = await client.fetchRelayList(pubkey)
127 const urls = relayList.read.length > 0
128 ? relayList.read.slice(0, 5)
129 : client.currentRelays.slice(0, 5)
130
131 const events = await client.fetchEvents(urls, {
132 '#p': [pubkey],
133 kinds: filterKinds,
134 limit: LIMIT
135 })
136
137 if (events.length > 0) {
138 mergeNotifications(events)
139 threadService.addRepliesToThread(events)
140 stuffStatsService.updateStuffStatsByEvents(events)
141 }
142 } finally {
143 setRefreshing(false)
144 }
145 }, [pubkey, refreshing, filterKinds, mergeNotifications, getNotificationsSeenAt])
146
147 useImperativeHandle(
148 ref,
149 () => ({
150 refresh: () => {
151 if (!refreshing) doRefresh()
152 }
153 }),
154 [refreshing, doRefresh]
155 )
156
157 // Initial subscription — only re-runs when pubkey or filterKinds change
158 useEffect(() => {
159 if (current !== 'notifications') return
160
161 if (!pubkey) {
162 setUntil(undefined)
163 return
164 }
165
166 const init = async () => {
167 setInitialLoading(true)
168 setNotifications([])
169 setShowCount(SHOW_COUNT)
170 setLastReadTime(getNotificationsSeenAt())
171 const relayList = await client.fetchRelayList(pubkey)
172
173 const { closer, timelineKey } = await client.subscribeTimeline(
174 [
175 {
176 urls: relayList.read.length > 0 ? relayList.read.slice(0, 5) : client.currentRelays.slice(0, 5),
177 filter: {
178 '#p': [pubkey],
179 kinds: filterKinds,
180 limit: LIMIT
181 }
182 }
183 ],
184 {
185 onEvents: (events, eosed) => {
186 if (events.length > 0) {
187 setNotifications(events.filter((event) => event.pubkey !== pubkey))
188 }
189 if (eosed) {
190 setInitialLoading(false)
191 setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
192 threadService.addRepliesToThread(events)
193 stuffStatsService.updateStuffStatsByEvents(events)
194 }
195 },
196 onNew: (event) => {
197 handleNewEvent(event)
198 threadService.addRepliesToThread([event])
199 }
200 }
201 )
202 closerRef.current = closer
203 setTimelineKey(timelineKey)
204 }
205
206 init()
207 return () => {
208 closerRef.current?.()
209 closerRef.current = null
210 }
211 }, [pubkey, filterKinds, current])
212
213 useEffect(() => {
214 if (!active || !pubkey) return
215
216 const handler = (data: Event) => {
217 const customEvent = data as CustomEvent<NostrEvent>
218 const evt = customEvent.detail
219 if (
220 matchFilter(
221 {
222 kinds: filterKinds,
223 '#p': [pubkey]
224 },
225 evt
226 )
227 ) {
228 handleNewEvent(evt)
229 }
230 }
231
232 client.addEventListener('newEvent', handler)
233 return () => {
234 client.removeEventListener('newEvent', handler)
235 }
236 }, [pubkey, active, filterKinds, handleNewEvent])
237
238 useEffect(() => {
239 setVisibleNotifications(notifications.slice(0, showCount))
240 }, [notifications, showCount])
241
242 useEffect(() => {
243 const options = {
244 root: null,
245 rootMargin: '10px',
246 threshold: 1
247 }
248
249 const loadMore = async () => {
250 if (showCount < notifications.length) {
251 setShowCount((count) => count + SHOW_COUNT)
252 // preload more
253 if (notifications.length - showCount > LIMIT / 2) {
254 return
255 }
256 }
257
258 if (!pubkey || !timelineKey || !until || loadingMore || initialLoading) return
259 setLoadingMore(true)
260 const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
261 setLoadingMore(false)
262 if (newNotifications.length === 0) {
263 setUntil(undefined)
264 return
265 }
266
267 if (newNotifications.length > 0) {
268 setNotifications((oldNotifications) => [
269 ...oldNotifications,
270 ...newNotifications.filter((event) => event.pubkey !== pubkey)
271 ])
272 }
273
274 setUntil(newNotifications[newNotifications.length - 1].created_at - 1)
275 }
276
277 const observerInstance = new IntersectionObserver((entries) => {
278 if (entries[0].isIntersecting) {
279 loadMore()
280 }
281 }, options)
282
283 const currentBottomRef = bottomRef.current
284
285 if (currentBottomRef) {
286 observerInstance.observe(currentBottomRef)
287 }
288
289 return () => {
290 if (observerInstance && currentBottomRef) {
291 observerInstance.unobserve(currentBottomRef)
292 }
293 }
294 }, [pubkey, timelineKey, until, loadingMore, initialLoading, showCount, notifications])
295
296 const refresh = () => {
297 topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' })
298 doRefresh()
299 }
300
301 const list = (
302 <div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
303 {visibleNotifications.map((notification, index) => (
304 <NotificationItem
305 key={notification.id}
306 notification={notification}
307 isNew={notification.created_at > lastReadTime}
308 navIndex={index}
309 />
310 ))}
311 <div className="text-center text-sm text-muted-foreground">
312 {until || loadingMore || initialLoading ? (
313 <div ref={bottomRef}>
314 <NotificationSkeleton />
315 </div>
316 ) : (
317 t('no more notifications')
318 )}
319 </div>
320 </div>
321 )
322
323 return (
324 <div>
325 <Tabs
326 value={notificationType}
327 tabs={[
328 { value: 'all', label: 'All' },
329 { value: 'mentions', label: 'Mentions' },
330 { value: 'reactions', label: 'Reactions' },
331 { value: 'zaps', label: 'Zaps' }
332 ]}
333 onTabChange={(type) => {
334 setShowCount(SHOW_COUNT)
335 setNotificationType(type as TNotificationType)
336 }}
337 options={!supportTouch ? <RefreshButton onClick={() => refresh()} /> : null}
338 />
339 <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
340 {supportTouch ? (
341 <PullToRefresh
342 onRefresh={async () => {
343 doRefresh()
344 await new Promise((resolve) => setTimeout(resolve, 1000))
345 }}
346 pullingContent=""
347 >
348 {list}
349 </PullToRefresh>
350 ) : (
351 list
352 )}
353 </div>
354 )
355 })
356 NotificationList.displayName = 'NotificationList'
357 export default NotificationList
358