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