index.tsx raw
1 import { FormattedTimestamp } from '@/components/FormattedTimestamp'
2 import { Button } from '@/components/ui/button'
3 import { Skeleton } from '@/components/ui/skeleton'
4 import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar'
5 import Username, { SimpleUsername } from '@/components/Username'
6 import { isMentioningMutedUsers } from '@/lib/event'
7 import { toNote, toProfile } from '@/lib/link'
8 import { cn, isTouchDevice } from '@/lib/utils'
9 import { useSecondaryPage } from '@/PageManager'
10 import { useContentPolicy } from '@/providers/ContentPolicyProvider'
11 import { useDeletedEvent } from '@/providers/DeletedEventProvider'
12 import { useMuteList } from '@/providers/MuteListProvider'
13 import { useNostr } from '@/providers/NostrProvider'
14 import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
15 import { useUserTrust } from '@/providers/UserTrustProvider'
16 import client from '@/services/client.service'
17 import threadService from '@/services/thread.service'
18 import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
19 import { TFeedSubRequest } from '@/types'
20 import dayjs from 'dayjs'
21 import { History, Loader, Star } from 'lucide-react'
22 import { Event, kinds } from 'nostr-tools'
23 import {
24 forwardRef,
25 useCallback,
26 useEffect,
27 useImperativeHandle,
28 useMemo,
29 useRef,
30 useState
31 } from 'react'
32 import { useTranslation } from 'react-i18next'
33 import PullToRefresh from 'react-simple-pull-to-refresh'
34 import { toast } from 'sonner'
35 import { LoadingBar } from '../LoadingBar'
36 import NewNotesButton from '../NewNotesButton'
37
38 const LIMIT = 500
39 const SHOW_COUNT = 20
40
41 export type TUserAggregationListRef = {
42 scrollToTop: (behavior?: ScrollBehavior) => void
43 refresh: () => void
44 }
45
46 const UserAggregationList = forwardRef<
47 TUserAggregationListRef,
48 {
49 subRequests: TFeedSubRequest[]
50 showKinds?: number[]
51 filterMutedNotes?: boolean
52 areAlgoRelays?: boolean
53 showRelayCloseReason?: boolean
54 }
55 >(
56 (
57 {
58 subRequests,
59 showKinds,
60 filterMutedNotes = true,
61 areAlgoRelays = false,
62 showRelayCloseReason = false
63 },
64 ref
65 ) => {
66 const { t } = useTranslation()
67 const { pubkey: currentPubkey, startLogin } = useNostr()
68 const { push } = useSecondaryPage()
69 const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
70 const { mutePubkeySet } = useMuteList()
71 const { pinnedPubkeySet } = usePinnedUsers()
72 const { hideContentMentioningMutedUsers } = useContentPolicy()
73 const { isEventDeleted } = useDeletedEvent()
74 const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
75 const [events, setEvents] = useState<Event[]>([])
76 const [newEvents, setNewEvents] = useState<Event[]>([])
77 const [newEventPubkeys, setNewEventPubkeys] = useState<Set<string>>(new Set())
78 const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
79 const [loading, setLoading] = useState(true)
80 const [showLoadingBar, setShowLoadingBar] = useState(true)
81 const [refreshCount, setRefreshCount] = useState(0)
82 const [showCount, setShowCount] = useState(SHOW_COUNT)
83 const [hasMore, setHasMore] = useState(true)
84 const supportTouch = useMemo(() => isTouchDevice(), [])
85 const feedId = useMemo(() => {
86 return userAggregationService.getFeedId(subRequests, showKinds)
87 }, [JSON.stringify(subRequests), JSON.stringify(showKinds)])
88 const bottomRef = useRef<HTMLDivElement | null>(null)
89 const topRef = useRef<HTMLDivElement | null>(null)
90 const nonPinnedTopRef = useRef<HTMLDivElement | null>(null)
91
92 const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
93 setTimeout(() => {
94 topRef.current?.scrollIntoView({ behavior, block: 'start' })
95 }, 20)
96 }
97
98 const refresh = () => {
99 scrollToTop()
100 setTimeout(() => {
101 setRefreshCount((count) => count + 1)
102 }, 500)
103 }
104
105 useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
106
107 useEffect(() => {
108 return () => {
109 userAggregationService.clearAggregations(feedId)
110 }
111 }, [feedId])
112
113 useEffect(() => {
114 if (!subRequests.length) return
115
116 setSince(dayjs().subtract(1, 'day').unix())
117 setHasMore(true)
118
119 async function init() {
120 setLoading(true)
121 setEvents([])
122 setNewEvents([])
123 setHasMore(true)
124
125 if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
126 setLoading(false)
127 setHasMore(false)
128 return () => {}
129 }
130
131 const preprocessedSubRequests = await Promise.all(
132 subRequests.map(async ({ urls, filter }) => {
133 const relays = urls.length ? urls : await client.determineRelaysByFilter(filter)
134 return {
135 urls: relays,
136 filter: {
137 kinds: showKinds ?? [],
138 ...filter,
139 limit: LIMIT
140 }
141 }
142 })
143 )
144
145 const { closer, timelineKey } = await client.subscribeTimeline(
146 preprocessedSubRequests,
147 {
148 onEvents: (events, eosed) => {
149 if (events.length > 0) {
150 setEvents(events)
151 }
152 if (areAlgoRelays) {
153 setHasMore(false)
154 }
155 if (eosed) {
156 setLoading(false)
157 setHasMore(events.length > 0)
158 threadService.addRepliesToThread(events)
159 }
160 },
161 onNew: (event) => {
162 setNewEvents((oldEvents) =>
163 [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
164 )
165 threadService.addRepliesToThread([event])
166 },
167 onClose: (url, reason) => {
168 if (!showRelayCloseReason) return
169 // ignore reasons from nostr-tools
170 if (
171 [
172 'closed by caller',
173 'relay connection errored',
174 'relay connection closed',
175 'pingpong timed out',
176 'relay connection closed by us'
177 ].includes(reason)
178 ) {
179 return
180 }
181
182 toast.error(`${url}: ${reason}`)
183 }
184 },
185 {
186 startLogin,
187 needSort: !areAlgoRelays
188 }
189 )
190 setTimelineKey(timelineKey)
191
192 return closer
193 }
194
195 const promise = init()
196 return () => {
197 promise.then((closer) => closer())
198 }
199 }, [feedId, refreshCount])
200
201 useEffect(() => {
202 if (loading || !hasMore || !timelineKey || !events.length) {
203 return
204 }
205
206 const until = events[events.length - 1].created_at - 1
207 if (until < since) {
208 return
209 }
210
211 setLoading(true)
212 client.loadMoreTimeline(timelineKey, until, LIMIT).then((moreEvents) => {
213 if (moreEvents.length === 0) {
214 setHasMore(false)
215 setLoading(false)
216 return
217 }
218 setEvents((oldEvents) => [...oldEvents, ...moreEvents])
219 setLoading(false)
220 })
221 }, [loading, timelineKey, events, since, hasMore])
222
223 useEffect(() => {
224 if (loading) {
225 setShowLoadingBar(true)
226 return
227 }
228
229 const timeout = setTimeout(() => {
230 setShowLoadingBar(false)
231 }, 1000)
232
233 return () => clearTimeout(timeout)
234 }, [loading])
235
236 const shouldHideEvent = useCallback(
237 (evt: Event) => {
238 if (evt.pubkey === currentPubkey) return true
239 if (isEventDeleted(evt)) return true
240 if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
241 if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
242 if (
243 filterMutedNotes &&
244 hideContentMentioningMutedUsers &&
245 isMentioningMutedUsers(evt, mutePubkeySet)
246 ) {
247 return true
248 }
249
250 return false
251 },
252 [
253 hideUntrustedNotes,
254 mutePubkeySet,
255 isEventDeleted,
256 currentPubkey,
257 filterMutedNotes,
258 isUserTrusted,
259 hideContentMentioningMutedUsers,
260 isMentioningMutedUsers
261 ]
262 )
263
264 const lastXDays = useMemo(() => {
265 return dayjs().diff(dayjs.unix(since), 'day')
266 }, [since])
267
268 const filteredEvents = useMemo(() => {
269 return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt))
270 }, [events, since, shouldHideEvent])
271
272 const filteredNewEvents = useMemo(() => {
273 return newEvents.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt))
274 }, [newEvents, since, shouldHideEvent])
275
276 const aggregations = useMemo(() => {
277 const aggs = userAggregationService.aggregateByUser(filteredEvents)
278 userAggregationService.saveAggregations(feedId, aggs)
279 return aggs
280 }, [feedId, filteredEvents])
281
282 const pinnedAggregations = useMemo(() => {
283 return aggregations.filter((agg) => pinnedPubkeySet.has(agg.pubkey))
284 }, [aggregations, pinnedPubkeySet])
285
286 const normalAggregations = useMemo(() => {
287 return aggregations.filter((agg) => !pinnedPubkeySet.has(agg.pubkey))
288 }, [aggregations, pinnedPubkeySet])
289
290 const displayedNormalAggregations = useMemo(() => {
291 return normalAggregations.slice(0, showCount)
292 }, [normalAggregations, showCount])
293
294 const hasMoreToDisplay = useMemo(() => {
295 return normalAggregations.length > displayedNormalAggregations.length
296 }, [normalAggregations, displayedNormalAggregations])
297
298 useEffect(() => {
299 const options = {
300 root: null,
301 rootMargin: '10px',
302 threshold: 1
303 }
304 if (!hasMoreToDisplay) return
305
306 const observerInstance = new IntersectionObserver((entries) => {
307 if (entries[0].isIntersecting) {
308 setShowCount((count) => count + SHOW_COUNT)
309 }
310 }, options)
311
312 const currentBottomRef = bottomRef.current
313 if (currentBottomRef) {
314 observerInstance.observe(currentBottomRef)
315 }
316
317 return () => {
318 if (observerInstance && currentBottomRef) {
319 observerInstance.unobserve(currentBottomRef)
320 }
321 }
322 }, [hasMoreToDisplay])
323
324 const handleViewUser = (agg: TUserAggregation) => {
325 // Mark as viewed when user clicks
326 userAggregationService.markAsViewed(feedId, agg.pubkey)
327 setNewEventPubkeys((prev) => {
328 const newSet = new Set(prev)
329 newSet.delete(agg.pubkey)
330 return newSet
331 })
332
333 if (agg.count === 1) {
334 const evt = agg.events[0]
335 if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) {
336 push(toNote(agg.events[0]))
337 return
338 }
339 }
340
341 push(toProfile(agg.pubkey))
342 }
343
344 const handleLoadEarlier = () => {
345 setSince((prevSince) => dayjs.unix(prevSince).subtract(1, 'day').unix())
346 setShowCount(SHOW_COUNT)
347 }
348
349 const showNewEvents = () => {
350 const pubkeySet = new Set<string>()
351 let hasPinnedUser = false
352 newEvents.forEach((evt) => {
353 pubkeySet.add(evt.pubkey)
354 if (pinnedPubkeySet.has(evt.pubkey)) {
355 hasPinnedUser = true
356 }
357 })
358 setNewEventPubkeys(pubkeySet)
359 setEvents((oldEvents) => [...newEvents, ...oldEvents])
360 setNewEvents([])
361 setTimeout(() => {
362 if (hasPinnedUser) {
363 scrollToTop('smooth')
364 return
365 }
366 nonPinnedTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
367 }, 0)
368 }
369
370 const list = (
371 <div className="min-h-screen">
372 {pinnedAggregations.map((agg) => (
373 <UserAggregationItem
374 key={agg.pubkey}
375 feedId={feedId}
376 aggregation={agg}
377 onClick={() => handleViewUser(agg)}
378 isNew={newEventPubkeys.has(agg.pubkey)}
379 />
380 ))}
381
382 <div ref={nonPinnedTopRef} className="scroll-mt-[calc(6rem+1px)]" />
383 {normalAggregations.map((agg) => (
384 <UserAggregationItem
385 key={agg.pubkey}
386 feedId={feedId}
387 aggregation={agg}
388 onClick={() => handleViewUser(agg)}
389 isNew={newEventPubkeys.has(agg.pubkey)}
390 />
391 ))}
392
393 {loading || hasMoreToDisplay ? (
394 <div ref={bottomRef}>
395 <UserAggregationItemSkeleton />
396 </div>
397 ) : aggregations.length === 0 ? (
398 <div className="flex justify-center w-full mt-2">
399 <Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
400 {t('Reload')}
401 </Button>
402 </div>
403 ) : (
404 <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
405 )}
406 </div>
407 )
408
409 return (
410 <div>
411 <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
412 {showLoadingBar && <LoadingBar />}
413 <div className="border-b h-12 pl-4 pr-1 flex items-center justify-between gap-2">
414 <div className="text-sm text-muted-foreground flex items-center gap-1.5 min-w-0">
415 <span className="font-medium text-foreground">
416 {lastXDays === 1
417 ? t('Last 24 hours')
418 : t('Last {{count}} days', { count: lastXDays })}
419 </span>
420 ยท
421 <span>
422 {filteredEvents.length} {t('notes')}
423 </span>
424 </div>
425 <Button
426 variant="ghost"
427 className="h-10 px-3 shrink-0 rounded-lg text-muted-foreground hover:text-foreground"
428 disabled={showLoadingBar || !hasMore}
429 onClick={handleLoadEarlier}
430 >
431 {showLoadingBar ? <Loader className="animate-spin" /> : <History />}
432 {t('Load earlier')}
433 </Button>
434 </div>
435 {supportTouch ? (
436 <PullToRefresh
437 onRefresh={async () => {
438 refresh()
439 await new Promise((resolve) => setTimeout(resolve, 1000))
440 }}
441 pullingContent=""
442 >
443 {list}
444 </PullToRefresh>
445 ) : (
446 list
447 )}
448 <div className="h-20" />
449 {filteredNewEvents.length > 0 && (
450 <NewNotesButton newEvents={filteredNewEvents} onClick={showNewEvents} />
451 )}
452 </div>
453 )
454 }
455 )
456 UserAggregationList.displayName = 'UserAggregationList'
457 export default UserAggregationList
458
459 function UserAggregationItem({
460 feedId,
461 aggregation,
462 onClick,
463 isNew
464 }: {
465 feedId: string
466 aggregation: TUserAggregation
467 onClick: () => void
468 isNew?: boolean
469 }) {
470 const { t } = useTranslation()
471 const supportTouch = useMemo(() => isTouchDevice(), [])
472 const [hasNewEvents, setHasNewEvents] = useState(true)
473 const [loading, setLoading] = useState(false)
474 const { isPinned, togglePin } = usePinnedUsers()
475 const pinned = useMemo(() => isPinned(aggregation.pubkey), [aggregation.pubkey, isPinned])
476
477 useEffect(() => {
478 const update = () => {
479 const lastViewedTime = userAggregationService.getLastViewedTime(feedId, aggregation.pubkey)
480 setHasNewEvents(aggregation.lastEventTime > lastViewedTime)
481 }
482
483 const unSub = userAggregationService.subscribeViewedTimeChange(
484 feedId,
485 aggregation.pubkey,
486 () => {
487 update()
488 }
489 )
490
491 update()
492
493 return unSub
494 }, [feedId, aggregation])
495
496 const onTogglePin = (e: React.MouseEvent) => {
497 e.stopPropagation()
498 setLoading(true)
499 togglePin(aggregation.pubkey).finally(() => {
500 setLoading(false)
501 })
502 }
503
504 const onToggleViewed = (e: React.MouseEvent) => {
505 e.stopPropagation()
506 if (hasNewEvents) {
507 userAggregationService.markAsViewed(feedId, aggregation.pubkey)
508 } else {
509 userAggregationService.markAsUnviewed(feedId, aggregation.pubkey)
510 }
511 }
512
513 return (
514 <div
515 className={cn(
516 'group relative flex items-center gap-4 px-4 py-3 border-b hover:bg-accent/30 cursor-pointer transition-all duration-200',
517 isNew && 'bg-primary/15 hover:bg-primary/20'
518 )}
519 onClick={onClick}
520 >
521 {supportTouch ? (
522 <SimpleUserAvatar
523 userId={aggregation.pubkey}
524 className={!hasNewEvents ? 'grayscale' : ''}
525 />
526 ) : (
527 <UserAvatar userId={aggregation.pubkey} className={!hasNewEvents ? 'grayscale' : ''} />
528 )}
529
530 <div className="flex-1 min-w-0 flex flex-col">
531 {supportTouch ? (
532 <SimpleUsername
533 userId={aggregation.pubkey}
534 className={cn(
535 'font-semibold text-base truncate max-w-fit',
536 !hasNewEvents && 'text-muted-foreground'
537 )}
538 skeletonClassName="h-4"
539 />
540 ) : (
541 <Username
542 userId={aggregation.pubkey}
543 className={cn(
544 'font-semibold text-base truncate max-w-fit',
545 !hasNewEvents && 'text-muted-foreground'
546 )}
547 skeletonClassName="h-4"
548 />
549 )}
550 <FormattedTimestamp
551 timestamp={aggregation.lastEventTime}
552 className="text-sm text-muted-foreground"
553 />
554 </div>
555
556 <Button
557 variant="ghost"
558 size="icon"
559 onClick={onTogglePin}
560 className={`flex-shrink-0 ${
561 pinned
562 ? 'text-primary hover:text-primary/80'
563 : 'text-muted-foreground hover:text-foreground'
564 }`}
565 title={pinned ? t('Unfollow Special') : t('Special Follow')}
566 >
567 {loading ? (
568 <Loader className="animate-spin" />
569 ) : (
570 <Star className={pinned ? 'fill-primary stroke-primary' : ''} />
571 )}
572 </Button>
573
574 <button
575 className={cn(
576 'flex-shrink-0 size-10 rounded-full font-bold tabular-nums text-primary border border-primary/80 bg-primary/10 hover:border-primary hover:bg-primary/20 flex flex-col items-center justify-center transition-colors',
577 !hasNewEvents &&
578 'border-muted-foreground/80 text-muted-foreground/80 bg-muted-foreground/10 hover:border-muted-foreground hover:text-muted-foreground hover:bg-muted-foreground/20'
579 )}
580 onClick={onToggleViewed}
581 >
582 {aggregation.count > 99 ? '99+' : aggregation.count}
583 </button>
584 </div>
585 )
586 }
587
588 function UserAggregationItemSkeleton() {
589 return (
590 <div className="flex items-center gap-4 px-4 py-3">
591 <Skeleton className="size-10 rounded-full" />
592 <div className="flex-1">
593 <Skeleton className="h-4 w-36 my-1" />
594 <Skeleton className="h-3 w-14 my-1" />
595 </div>
596 <Skeleton className="size-10 rounded-full" />
597 </div>
598 )
599 }
600