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