From ed843f637ac1616cd0fbae7a8313f62c3db263af Mon Sep 17 00:00:00 2001 From: codytseng Date: Tue, 6 Jan 2026 00:00:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=92=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/NoteList/index.tsx | 73 ++++++---- src/components/UserAggregationList/index.tsx | 36 +++-- src/layouts/PrimaryPageLayout/index.tsx | 60 ++++---- src/layouts/SecondaryPageLayout/index.tsx | 63 +++++---- src/lib/smart-pool.ts | 2 +- src/providers/NotificationProvider.tsx | 140 ++++++------------- src/providers/PageActiveProvider.tsx | 8 ++ src/routes/primary.tsx | 13 +- 8 files changed, 198 insertions(+), 197 deletions(-) create mode 100644 src/providers/PageActiveProvider.tsx diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index b850eea..c967d32 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -10,6 +10,7 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' +import { usePageActive } from '@/providers/PageActiveProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import threadService from '@/services/thread.service' @@ -75,6 +76,7 @@ const NoteList = forwardRef< ref ) => { const { t } = useTranslation() + const active = usePageActive() const { startLogin } = useNostr() const { isSpammer, meetsMinTrustScore } = useUserTrust() const { mutePubkeySet } = useMuteList() @@ -93,6 +95,12 @@ const NoteList = forwardRef< const [refreshCount, setRefreshCount] = useState(0) const supportTouch = useMemo(() => isTouchDevice(), []) const topRef = useRef(null) + const sinceRef = useRef(undefined) + sinceRef.current = newEvents.length + ? newEvents[0].created_at + 1 + : events.length + ? events[0].created_at + 1 + : undefined const showNewNotesDirectlyRef = useRef(showNewNotesDirectly) showNewNotesDirectlyRef.current = showNewNotesDirectly @@ -287,16 +295,24 @@ const NoteList = forwardRef< useEffect(() => { if (!subRequests.length) return + sinceRef.current = undefined + setEvents([]) + setStoredEvents([]) + setNewEvents([]) + }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)]) + + useEffect(() => { + if (!subRequests.length || !active) return + async function init() { setInitialLoading(true) - setEvents([]) - setStoredEvents([]) - setNewEvents([]) if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { return () => {} } + const since = sinceRef.current + if (isPubkeyFeed) { const storedEvents = await client.getEventsFromIndexed({ authors: subRequests.flatMap(({ filter }) => filter.authors ?? []), @@ -320,12 +336,35 @@ const NoteList = forwardRef< }) ) + const handleNewEvents = (newEvents: Event[]) => { + if (showNewNotesDirectlyRef.current) { + setEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) + } else { + const isAtTop = (() => { + if (!topRef.current) return true + const rect = topRef.current.getBoundingClientRect() + return rect.top >= 50 + })() + + if (isAtTop) { + setEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) + } else { + setNewEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) + } + } + } + const { closer, timelineKey } = await client.subscribeTimeline( preprocessedSubRequests, { onEvents: (events, eosed) => { if (events.length > 0) { - setEvents(events) + if (!since) { + setEvents(events) + } else { + const newEvents = events.filter((evt) => evt.created_at >= since) + handleNewEvents(newEvents) + } } if (eosed) { threadService.addRepliesToThread(events) @@ -333,27 +372,7 @@ const NoteList = forwardRef< } }, onNew: (event) => { - if (showNewNotesDirectlyRef.current) { - setEvents((oldEvents) => - oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] - ) - } else { - const isAtTop = (() => { - if (!topRef.current) return true - const rect = topRef.current.getBoundingClientRect() - return rect.top >= 50 - })() - - if (isAtTop) { - setEvents((oldEvents) => - oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] - ) - } else { - setNewEvents((oldEvents) => - [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) - } - } + handleNewEvents([event]) threadService.addRepliesToThread([event]) }, onClose: (url, reason) => { @@ -388,7 +407,7 @@ const NoteList = forwardRef< return () => { promise.then((closer) => closer()) } - }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)]) + }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds), active]) const handleLoadMore = useCallback(async () => { if (!timelineKey || areAlgoRelays) return false @@ -412,7 +431,7 @@ const NoteList = forwardRef< }) const showNewEvents = () => { - setEvents((oldEvents) => [...newEvents, ...oldEvents]) + setEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) setNewEvents([]) setTimeout(() => { scrollToTop('smooth') diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx index bf8dfe1..6d6601c 100644 --- a/src/components/UserAggregationList/index.tsx +++ b/src/components/UserAggregationList/index.tsx @@ -12,6 +12,7 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' +import { usePageActive } from '@/providers/PageActiveProvider' import { usePinnedUsers } from '@/providers/PinnedUsersProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' @@ -68,6 +69,7 @@ const UserAggregationList = forwardRef< ref ) => { const { t } = useTranslation() + const active = usePageActive() const { pubkey: currentPubkey, startLogin } = useNostr() const { push } = useSecondaryPage() const { mutePubkeySet } = useMuteList() @@ -95,6 +97,12 @@ const UserAggregationList = forwardRef< const bottomRef = useRef(null) const topRef = useRef(null) const nonPinnedTopRef = useRef(null) + const sinceRef = useRef(undefined) + sinceRef.current = newEvents.length + ? newEvents[0].created_at + 1 + : events.length + ? events[0].created_at + 1 + : undefined const scrollToTop = (behavior: ScrollBehavior = 'instant') => { setTimeout(() => { @@ -120,15 +128,19 @@ const UserAggregationList = forwardRef< useEffect(() => { if (!subRequests.length) return + sinceRef.current = undefined setSince(dayjs().subtract(1, 'day').unix()) + setStoredEvents([]) + setEvents([]) + setNewEvents([]) setHasMore(true) + }, [feedId, refreshCount]) + + useEffect(() => { + if (!subRequests.length || !active) return async function init() { setLoading(true) - setStoredEvents([]) - setEvents([]) - setNewEvents([]) - setHasMore(true) if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { setLoading(false) @@ -136,6 +148,8 @@ const UserAggregationList = forwardRef< return () => {} } + const since = sinceRef.current + if (isPubkeyFeed) { const storedEvents = await client.getEventsFromIndexed({ authors: subRequests.flatMap(({ filter }) => filter.authors ?? []), @@ -164,21 +178,23 @@ const UserAggregationList = forwardRef< { onEvents: (events, eosed) => { if (events.length > 0) { - setEvents(events) + if (!since) { + setEvents(events) + } else { + const newEvents = events.filter((evt) => evt.created_at >= since) + setNewEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) + } } if (areAlgoRelays) { setHasMore(false) } if (eosed) { setLoading(false) - setHasMore(events.length > 0) threadService.addRepliesToThread(events) } }, onNew: (event) => { - setNewEvents((oldEvents) => - [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) + setNewEvents((oldEvents) => mergeTimelines([[event], oldEvents])) threadService.addRepliesToThread([event]) }, onClose: (url, reason) => { @@ -214,7 +230,7 @@ const UserAggregationList = forwardRef< return () => { promise.then((closer) => closer()) } - }, [feedId, refreshCount]) + }, [feedId, refreshCount, active]) useEffect(() => { if (loading || !hasMore || !timelineKey || !events.length) { diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index 678b373..b66db3e 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { usePrimaryPage } from '@/PageManager' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' import { useNostr } from '@/providers/NostrProvider' +import { PageActiveContext } from '@/providers/PageActiveProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { TPrimaryPageName } from '@/routes/primary' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' @@ -76,38 +77,45 @@ const PrimaryPageLayout = forwardRef( if (enableSingleColumnLayout) { return ( - -
+ +
+ + {titlebar} + + {children} +
+ {displayScrollToTopButton && } +
+ + ) + } + + return ( + + + {titlebar} {children} -
- {displayScrollToTopButton && } +
+ + {displayScrollToTopButton && } - ) - } - - return ( - - - - {titlebar} - - {children} -
- - {displayScrollToTopButton && } - + ) } ) diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index 3bb9541..d024da7 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { useSecondaryPage } from '@/PageManager' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' +import { PageActiveContext } from '@/providers/PageActiveProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { ChevronLeft } from 'lucide-react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' @@ -60,11 +61,35 @@ const SecondaryPageLayout = forwardRef( if (enableSingleColumnLayout) { return ( - -
+ +
+ + {children} +
+ {displayScrollToTopButton && } +
+ + ) + } + + return ( + + + {children} -
- {displayScrollToTopButton && } +
+ + {displayScrollToTopButton && } - ) - } - - return ( - - - - {children} -
- - {displayScrollToTopButton && } - + ) } ) diff --git a/src/lib/smart-pool.ts b/src/lib/smart-pool.ts index 35282f3..2ff421b 100644 --- a/src/lib/smart-pool.ts +++ b/src/lib/smart-pool.ts @@ -3,7 +3,7 @@ import { AbstractRelay } from 'nostr-tools/abstract-relay' const DEFAULT_CONNECTION_TIMEOUT = 10 * 1000 // 10 seconds const CLEANUP_THRESHOLD = 15 // number of relays to trigger cleanup -const CLEANUP_INTERVAL = 5 * 1000 // 5 seconds +const CLEANUP_INTERVAL = 30 * 1000 // 30 seconds const IDLE_TIMEOUT = 10 * 1000 // 10 seconds export class SmartPool extends SimplePool { diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index fd1c884..723489c 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -6,7 +6,6 @@ import { usePrimaryPage } from '@/PageManager' import client from '@/services/client.service' import storage from '@/services/local-storage.service' import { kinds, NostrEvent } from 'nostr-tools' -import { SubCloser } from 'nostr-tools/abstract-pool' import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { useContentPolicy } from './ContentPolicyProvider' import { useMuteList } from './MuteListProvider' @@ -88,110 +87,61 @@ export function NotificationProvider({ children }: { children: React.ReactNode } setNewNotifications([]) setReadNotificationIdSet(new Set()) - // Track if component is mounted - const isMountedRef = { current: true } - const subCloserRef: { - current: SubCloser | null - } = { current: null } - const subscribe = async () => { - if (subCloserRef.current) { - subCloserRef.current.close() - subCloserRef.current = null - } - if (!isMountedRef.current) return null - - try { - let eosed = false - const relayList = await client.fetchRelayList(pubkey) - const relays = - relayList.read.length > 0 ? relayList.read.slice(0, 5) : getDefaultRelayUrls() - const subCloser = client.subscribe( - relays, - [ - { - kinds: [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Reaction, - kinds.Zap, - ExtendedKind.COMMENT, - ExtendedKind.POLL_RESPONSE, - ExtendedKind.VOICE_COMMENT, - ExtendedKind.POLL - ], - '#p': [pubkey], - limit: 20 - } - ], + let eosed = false + const relayList = await client.fetchRelayList(pubkey) + const relays = relayList.read.length > 0 ? relayList.read.slice(0, 5) : getDefaultRelayUrls() + return client.subscribe( + relays, + [ { - oneose: (e) => { - if (e) { - eosed = e - setNewNotifications((prev) => { - return [...prev.sort((a, b) => compareEvents(b, a))] - }) - } - }, - onevent: (evt) => { - if (evt.pubkey !== pubkey) { - setNewNotifications((prev) => { - if (!eosed) { - return [evt, ...prev] - } - if (prev.length && compareEvents(prev[0], evt) >= 0) { - return prev - } - - client.emitNewEvent(evt, relays) + kinds: [ + kinds.ShortTextNote, + kinds.Repost, + kinds.GenericRepost, + kinds.Reaction, + kinds.Zap, + kinds.Highlights, + ExtendedKind.COMMENT, + ExtendedKind.POLL_RESPONSE, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.POLL + ], + '#p': [pubkey], + limit: 20 + } + ], + { + oneose: (e) => { + if (e) { + eosed = e + setNewNotifications((prev) => { + return [...prev.sort((a, b) => compareEvents(b, a))] + }) + } + }, + onevent: (evt) => { + if (evt.pubkey !== pubkey) { + setNewNotifications((prev) => { + if (!eosed) { return [evt, ...prev] - }) - } - }, - onAllClose: (reasons) => { - if (reasons.every((reason) => reason === 'closed by caller')) { - return - } + } + if (prev.length && compareEvents(prev[0], evt) >= 0) { + return prev + } - // Only reconnect if still mounted and not a manual close - if (isMountedRef.current) { - setTimeout(() => { - if (isMountedRef.current) { - subscribe() - } - }, 5_000) - } + client.emitNewEvent(evt, relays) + return [evt, ...prev] + }) } } - ) - - subCloserRef.current = subCloser - return subCloser - } catch (error) { - console.error('Subscription error:', error) - - // Retry on error if still mounted - if (isMountedRef.current) { - setTimeout(() => { - if (isMountedRef.current) { - subscribe() - } - }, 5_000) } - return null - } + ) } - // Initial subscription - subscribe() - - // Cleanup function + const promise = subscribe() return () => { - isMountedRef.current = false - if (subCloserRef.current) { - subCloserRef.current.close() - subCloserRef.current = null - } + promise.then((closer) => closer.close()) } }, [pubkey]) diff --git a/src/providers/PageActiveProvider.tsx b/src/providers/PageActiveProvider.tsx new file mode 100644 index 0000000..d2d0180 --- /dev/null +++ b/src/providers/PageActiveProvider.tsx @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react' + +export const PageActiveContext = createContext(null) + +export function usePageActive() { + const ctx = useContext(PageActiveContext) + return ctx ?? false +} diff --git a/src/routes/primary.tsx b/src/routes/primary.tsx index c6def55..d598635 100644 --- a/src/routes/primary.tsx +++ b/src/routes/primary.tsx @@ -8,14 +8,9 @@ import RelayPage from '@/pages/primary/RelayPage' import SearchPage from '@/pages/primary/SearchPage' import SettingsPage from '@/pages/primary/SettingsPage' import { TPageRef } from '@/types' -import { createRef, ForwardRefExoticComponent, RefAttributes } from 'react' +import { createRef } from 'react' -type RouteConfig = { - key: string - component: ForwardRefExoticComponent> -} - -const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [ +const PRIMARY_ROUTE_CONFIGS = [ { key: 'home', component: NoteListPage }, { key: 'explore', component: ExplorePage }, { key: 'notifications', component: NotificationListPage }, @@ -25,7 +20,7 @@ const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [ { key: 'search', component: SearchPage }, { key: 'bookmark', component: BookmarkPage }, { key: 'settings', component: SettingsPage } -] +] as const export const PRIMARY_PAGE_REF_MAP = PRIMARY_ROUTE_CONFIGS.reduce( (acc, { key }) => { @@ -40,7 +35,7 @@ export const PRIMARY_PAGE_MAP = PRIMARY_ROUTE_CONFIGS.reduce( acc[key] = return acc }, - {} as Record + {} as Record<(typeof PRIMARY_ROUTE_CONFIGS)[number]['key'], JSX.Element> ) export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP