diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 50733cd8..70c09825 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -15,6 +15,7 @@ import { ImwaldBrandBar } from '@/assets/Logo' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import NoteDrawer from '@/components/NoteDrawer' import client from '@/services/client.service' +import noteStatsService from '@/services/note-stats.service' import { navigationEventStore } from '@/services/navigation-event-store' import type { Event } from 'nostr-tools' import { Sheet, SheetContent } from '@/components/ui/sheet' @@ -2126,19 +2127,31 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setSinglePaneSheetOpen(shouldBeOpen) }, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen]) + const primaryFrozen = + secondaryStack.length > 0 && (isSmallScreen || panelMode === 'double') + + useEffect(() => { + noteStatsService.setBackgroundStatsPaused(primaryFrozen) + if (primaryFrozen) { + client.interruptBackgroundQueries() + } + }, [primaryFrozen]) + const primaryPageContextValue = useMemo( (): PrimaryPageContextValue => ({ navigate: navigatePrimaryPageStable, current: currentPrimaryPage, currentPageProps, - display: isSmallScreen ? secondaryStack.length === 0 : true + display: isSmallScreen ? secondaryStack.length === 0 : true, + frozen: primaryFrozen }), [ navigatePrimaryPageStable, currentPrimaryPage, currentPageProps, isSmallScreen, - secondaryStack.length + secondaryStack.length, + primaryFrozen ] ) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 832cb2e1..72ad72fe 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1016,6 +1016,7 @@ const NoteList = forwardRef( const primaryPageCtx = usePrimaryPageOptional() const primaryPageCurrent = primaryPageCtx?.current ?? null + const primaryPanelFrozen = primaryPageCtx?.frozen ?? false /** Clears text/author/time/full-search; does not change panel open state. */ const clearFeedClientSearchCriteria = useCallback(() => { @@ -1907,6 +1908,10 @@ const NoteList = forwardRef( timelineEstablishedCloserRef.current?.() timelineEstablishedCloserRef.current = null + if (primaryPanelFrozen) { + return () => {} + } + const currentSubRequests = subRequestsRef.current if (!currentSubRequests.length) { if (oneShotDebugLabel) { @@ -3127,11 +3132,17 @@ const NoteList = forwardRef( mapLiveSubRequestsForTimeline, progressiveWarmupQuery, hostPrimaryPageName, - relayAuthoritativeFeedOnly + relayAuthoritativeFeedOnly, + primaryPanelFrozen ]) useEffect(() => { if (oneShotFetch) return + if (primaryPanelFrozen) { + followingFeedDeltaCloserRef.current?.() + followingFeedDeltaCloserRef.current = null + return + } const deltas = followingFeedDeltaSubRequests ?? [] if (deltas.length === 0) { followingFeedDeltaCloserRef.current?.() @@ -3388,7 +3399,8 @@ const NoteList = forwardRef( effectiveShowKinds, showKind1OPs, showKind1Replies, - showKind1111 + showKind1111, + primaryPanelFrozen ]) const oneShotDebugPrevLoadingRef = useRef(false) diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index ab22f18c..a67972c9 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -22,7 +22,7 @@ import indexedDb from '@/services/indexed-db.service' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' import { type Event } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { Button } from '@/components/ui/button' @@ -103,36 +103,50 @@ export default function SidebarCalendarWeekWidget() { } }, [rawEvents, weekOffset]) + const fetchGenRef = useRef(0) + useEffect(() => { + const fetchGen = ++fetchGenRef.current let cancelled = false let lateMergeTimer: number | null = null - const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset) + const stale = () => cancelled || fetchGenRef.current !== fetchGen + + const weekBounds = () => getLocalMondayWeekBounds(weekOffset) + + const replacePool = (pool: Event[]) => { + if (stale()) return + const { weekStartMs, weekEndExclusiveMs } = weekBounds() + setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange(pool, weekStartMs, weekEndExclusiveMs)) + } + + const mergeIntoPool = (incoming: Event[]) => { + if (stale()) return + const { weekStartMs, weekEndExclusiveMs } = weekBounds() + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...incoming], weekStartMs, weekEndExclusiveMs) + ) + } + const { weekStartMs, weekEndExclusiveMs } = weekBounds() const fromSessionSync = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( - fromSessionSync, - weekStartMs, - weekEndExclusiveMs + replacePool( + dedupeCalendarEventsPreferringOccurrenceRange(fromSessionSync, weekStartMs, weekEndExclusiveMs) ) - setRawEvents(sessionOnly) - const scheduleLateSessionMerge = (mergeWithIdb: Event[]) => { + const scheduleLateSessionMerge = () => { lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null - if (cancelled) return - const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + if (stale()) return const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...mergeWithIdb], ws, we) - ) + mergeIntoPool(later) }, 2500) } @@ -151,25 +165,18 @@ export default function SidebarCalendarWeekWidget() { ) .catch((): Event[] => []) - void idbP.then((localBaseline) => { - if (cancelled) return - const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) - const s2 = client.getSessionEventsMatchingSearch( + if (stale()) return + + if (!relayUrls.length) { + const localBaseline = await idbP + if (stale()) return + const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents( - dedupeCalendarEventsPreferringOccurrenceRange([...localBaseline, ...s2], ws, we) - ) - }) - - if (cancelled) return - - if (!relayUrls.length) { - void idbP.then((lb) => { - if (!cancelled) scheduleLateSessionMerge(lb) - }) + replacePool([...localBaseline, ...fromSession]) + scheduleLateSessionMerge() return } @@ -223,40 +230,19 @@ export default function SidebarCalendarWeekWidget() { .catch(() => ({ batch: [] as Event[], fromFollowing: [] as Event[] })) const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP]) - if (cancelled) return + if (stale()) return - const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) const fromSessionAfterNet = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - if (!cancelled) { - setRawEvents( - dedupeCalendarEventsPreferringOccurrenceRange( - [...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing], - ws, - we - ) - ) - } - lateMergeTimer = window.setTimeout(() => { - lateMergeTimer = null - if (cancelled) return - const { weekStartMs: w2, weekEndExclusiveMs: w2e } = getLocalMondayWeekBounds(weekOffset) - const later = client.getSessionEventsMatchingSearch( - '', - SESSION_CALENDAR_MERGE_CAP, - [...CALENDAR_EVENT_KINDS] - ) - setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], w2, w2e) - ) - }, 2500) + replacePool([...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing]) + scheduleLateSessionMerge() } catch { - if (!cancelled) { + if (!stale()) { try { - const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + const { weekStartMs: ws, weekEndExclusiveMs: we } = weekBounds() const [idb, arc] = await Promise.all([ indexedDb.getCalendarEventsForOccurrenceWindow(ws, we), indexedDb.getArchivedCalendarEventsOverlappingWindow(ws, we, 25_000, 400) @@ -267,9 +253,9 @@ export default function SidebarCalendarWeekWidget() { SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], ws, we)) + replacePool([...salvage, ...fromSession]) } catch { - setRawEvents([]) + if (!stale()) setRawEvents([]) } } } diff --git a/src/constants.ts b/src/constants.ts index d212c1bb..e010256a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -74,7 +74,6 @@ export const DESKTOP_APP_DOWNLOAD_URL_DEFAULT = export const DEFAULT_FAVORITE_RELAYS = [ 'wss://theforest.nostr1.com', 'wss://nostr.land', - 'wss://relays.land/spatianostra' ] /** diff --git a/src/contexts/primary-page-context.tsx b/src/contexts/primary-page-context.tsx index 2fb6eb83..258ffc4e 100644 --- a/src/contexts/primary-page-context.tsx +++ b/src/contexts/primary-page-context.tsx @@ -12,7 +12,16 @@ export type PrimaryPageContextValue = { current: TPrimaryPageName | null /** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */ currentPageProps: object | undefined + /** + * False on small screens while the secondary stack is open (primary feed unmounted). + * True on desktop double-pane so the left column stays visible. + */ display: boolean + /** + * True while a secondary panel is open: pause primary feed timelines / background stats + * and preserve scroll position until the panel closes. + */ + frozen: boolean } export const PrimaryPageContext = createContext(undefined) diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index cd84af23..1fc25053 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -44,7 +44,9 @@ const PrimaryPageLayout = forwardRef( const smallScreenScrollAreaRef = useRef(null) const smallScreenLastScrollTopRef = useRef(0) const { isSmallScreen } = useScreenSize() - const { current, display } = usePrimaryPage() + const { current, display, frozen } = usePrimaryPage() + const savedScrollTopRef = useRef(0) + const wasFrozenRef = useRef(false) useImperativeHandle( ref, @@ -84,6 +86,28 @@ const PrimaryPageLayout = forwardRef( } }, [current, isSmallScreen, display]) + useEffect(() => { + if (isSmallScreen) return + const el = scrollAreaRef.current + if (!el) return + + if (frozen && !wasFrozenRef.current) { + savedScrollTopRef.current = el.scrollTop + wasFrozenRef.current = true + return + } + + if (!frozen && wasFrozenRef.current) { + wasFrozenRef.current = false + const top = savedScrollTopRef.current + requestAnimationFrame(() => { + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = top + } + }) + } + }, [frozen, isSmallScreen, pageName]) + useEffect(() => { if (isSmallScreen) return if (current !== pageName || !display) return @@ -133,7 +157,10 @@ const PrimaryPageLayout = forwardRef( } return ( - +
{hasTitlebarRow ? ( (funct return { rangeStartMs: startMs - pad, rangeEndExclusiveMs: endExclusiveMs + pad } }, [viewYear, viewMonth]) + const calendarFetchGenRef = useRef(0) + useEffect(() => { + const fetchGen = ++calendarFetchGenRef.current let cancelled = false let lateMergeTimer: number | null = null + const stale = () => cancelled || calendarFetchGenRef.current !== fetchGen const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange + const replacePool = (pool: NostrEvent[]) => { + if (stale()) return + setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange(pool, rangeStartMs, rangeEndExclusiveMs)) + setLoading(false) + } + + const mergeIntoPool = (incoming: NostrEvent[]) => { + if (stale()) return + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange( + [...prev, ...incoming], + rangeStartMs, + rangeEndExclusiveMs + ) + ) + } + /** Same-tick paint from in-memory session (no await) — IDB + relays merge in the async block below. */ const fromSessionSync = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( - fromSessionSync, - rangeStartMs, - rangeEndExclusiveMs + replacePool( + dedupeCalendarEventsPreferringOccurrenceRange(fromSessionSync, rangeStartMs, rangeEndExclusiveMs) ) - setRawEvents(sessionOnly) - setLoading(false) - const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => { + const scheduleLateSessionMerge = () => { lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null - if (cancelled) return + if (stale()) return const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange( - [...prev, ...later, ...mergeWithIdb], - rangeStartMs, - rangeEndExclusiveMs - ) - ) + mergeIntoPool(later) }, 2500) } @@ -212,26 +223,18 @@ const CalendarPrimaryPage = forwardRef(funct ) .catch((): NostrEvent[] => []) - void idbP.then((localBaseline) => { - if (cancelled) return - const s2 = client.getSessionEventsMatchingSearch( + if (stale()) return + + if (!relayUrls.length) { + const localBaseline = await idbP + if (stale()) return + const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents( - dedupeCalendarEventsPreferringOccurrenceRange( - [...localBaseline, ...s2], - rangeStartMs, - rangeEndExclusiveMs - ) - ) - }) - - if (!relayUrls.length) { - void idbP.then((lb) => { - if (!cancelled) scheduleLateSessionMerge(lb) - }) + replacePool([...localBaseline, ...fromSession]) + scheduleLateSessionMerge() return } @@ -288,38 +291,17 @@ const CalendarPrimaryPage = forwardRef(funct .catch(() => ({ batch: [] as NostrEvent[], fromFollowing: [] as NostrEvent[] })) const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP]) - if (cancelled) return + if (stale()) return const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents( - dedupeCalendarEventsPreferringOccurrenceRange( - [...batch, ...fromFollowing, ...fromSession, ...localBaseline], - rangeStartMs, - rangeEndExclusiveMs - ) - ) - lateMergeTimer = window.setTimeout(() => { - lateMergeTimer = null - if (cancelled) return - const later = client.getSessionEventsMatchingSearch( - '', - SESSION_CALENDAR_MERGE_CAP, - [...CALENDAR_EVENT_KINDS] - ) - setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange( - [...prev, ...later, ...localBaseline], - rangeStartMs, - rangeEndExclusiveMs - ) - ) - }, 2500) + replacePool([...localBaseline, ...fromSession, ...batch, ...fromFollowing]) + scheduleLateSessionMerge() } catch { - if (!cancelled) { + if (!stale()) { try { const rs = rangeStartMs const re = rangeEndExclusiveMs diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index e8afc0e5..35f44628 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -91,6 +91,8 @@ class NoteStatsService { private batchTimeout: NodeJS.Timeout | null = null /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */ private processBatchRunning = false + /** When true (secondary panel open), skip background stats relay batches so the note panel is not starved. */ + private backgroundStatsPaused = false /** While greater than zero, {@link processBatch} defers so user publishes are not starved for WebSocket pool / bandwidth. */ private publishPriorityDepth = 0 private readonly BATCH_DELAY = 40 @@ -153,8 +155,13 @@ class NoteStatsService { return out } + setBackgroundStatsPaused(paused: boolean): void { + this.backgroundStatsPaused = paused + } + /** Coalesce scroll bursts; flush immediately when backlog is large or a foreground note was queued. */ private maybeFlushStatsBatch(foreground: boolean) { + if (!foreground && this.backgroundStatsPaused) return if (this.processBatchRunning) { return } @@ -200,6 +207,11 @@ class NoteStatsService { } } + if (!foreground && this.backgroundStatsPaused) { + rememberRoot() + return + } + if (this.pendingEvents.has(eventId) || this.pendingForeground.has(eventId)) { this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays) rememberRoot()