diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index 140937a6..a8010444 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { cn } from '@/lib/utils' -import { useSecondaryPageOptional } from '@/PageManager' +import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { Event, kinds } from 'nostr-tools' @@ -20,6 +20,7 @@ export default function PublicationCard({ }) { const screenSize = useScreenSizeOptional() const isSmallScreen = screenSize?.isSmallScreen ?? false + const { navigateToNote } = useSmartNoteNavigationOptional() const secondaryPage = useSecondaryPageOptional() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const contentPolicy = useContentPolicyOptional() @@ -32,7 +33,7 @@ export default function PublicationCard({ const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() - push(toNote(event)) + navigateToNote(toNote(event), event) } const titleComponent = metadata.title ?
{metadata.title}
: null diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx index cfc88f48..735ef1de 100644 --- a/src/components/Profile/ProfileMediaFeed.tsx +++ b/src/components/Profile/ProfileMediaFeed.tsx @@ -77,7 +77,11 @@ const ProfileMediaFeed = forwardRef(({ pubkey } }, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays]) - const authorRelayUrls = refinedAuthorRelayUrls ?? provisionalAuthorRelayUrls + /** Empty NIP-65 stack is not “unknown” — fall back to provisional tier so augmented read relays still apply. */ + const authorRelayUrls = + refinedAuthorRelayUrls != null && refinedAuthorRelayUrls.length > 0 + ? refinedAuthorRelayUrls + : provisionalAuthorRelayUrls const subRequests = useMemo(() => { const pk = pubkey?.trim() diff --git a/src/components/Profile/ProfileTimeline.tsx b/src/components/Profile/ProfileTimeline.tsx index ae036b87..51db841a 100644 --- a/src/components/Profile/ProfileTimeline.tsx +++ b/src/components/Profile/ProfileTimeline.tsx @@ -140,7 +140,7 @@ const ProfileTimeline = forwardRef< return () => { observer.disconnect() } - }, [displayedEvents.length, filteredEvents.length]) + }, [displayedEvents.length, filteredEvents.length, isLoading]) if (!pubkey) { return ( diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 396fcdd5..bf776b6c 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -200,6 +200,7 @@ export default function Profile({ const postsFeedRef = useRef<{ refresh: () => void }>(null) const mediaFeedRef = useRef(null) const publicationsFeedRef = useRef<{ refresh: () => void }>(null) + const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications'>('posts') const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey, publish, checkLogin } = useNostr() @@ -388,6 +389,24 @@ export default function Profile({ forceUpdateCache() }, [profile?.pubkey]) + useEffect(() => { + if (!profile?.pubkey) return + setProfileFeedTab('posts') + }, [profile?.pubkey]) + + /** + * Radix {@link TabsContent} unmounts inactive panels, so media / publications feeds can miss the same + * warm-up window as Posts or show a frozen first paint. Re-run their refresh path when the tab becomes active + * (after refs attach — {@link useLayoutEffect}). + */ + useLayoutEffect(() => { + if (profileFeedTab === 'media') { + mediaFeedRef.current?.refresh() + } else if (profileFeedTab === 'publications') { + publicationsFeedRef.current?.refresh() + } + }, [profileFeedTab]) + if (!profile && isFetching) { return ( <> @@ -695,7 +714,15 @@ export default function Profile({ - + { + if (v === 'posts' || v === 'media' || v === 'publications') { + setProfileFeedTab(v) + } + }} + className="min-w-0 pt-4" + > {t('Posts')} {t('Media')} diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index 35af0ae2..673c7b4b 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -111,7 +111,11 @@ export default function SidebarCalendarWeekWidget() { void (async () => { try { const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset) - const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs) + const [fromIdb, fromArchive] = await Promise.all([ + indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs), + indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) + ]) + const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) if (!relayUrls.length) { if (cancelled) return @@ -120,7 +124,7 @@ export default function SidebarCalendarWeekWidget() { SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSession])) + setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSession])) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return @@ -129,7 +133,7 @@ export default function SidebarCalendarWeekWidget() { SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...fromIdb])) + setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline])) }, 2500) return } @@ -190,7 +194,7 @@ export default function SidebarCalendarWeekWidget() { SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb])) + setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline])) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return @@ -199,10 +203,27 @@ export default function SidebarCalendarWeekWidget() { SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later])) + setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline])) }, 2500) } catch { - if (!cancelled) setRawEvents([]) + if (!cancelled) { + try { + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + const [idb, arc] = await Promise.all([ + indexedDb.getCalendarEventsForOccurrenceWindow(ws, we), + indexedDb.getArchivedCalendarEventsOverlappingWindow(ws, we, 25_000, 400) + ]) + const salvage = dedupeCalendarEvents([...idb, ...arc]) + const fromSession = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession])) + } catch { + setRawEvents([]) + } + } } finally { if (!cancelled) setLoading(false) } @@ -269,11 +290,7 @@ export default function SidebarCalendarWeekWidget() { {t('sidebarCalendarLoading')} - ) : !relayUrls.length ? ( -

{t('sidebarCalendarNoRelays')}

- ) : sortedForWeek.length === 0 ? ( -

{t('sidebarCalendarEmptyWeek')}

- ) : ( + ) : sortedForWeek.length > 0 ? (
    {sortedForWeek.map((ev) => { const meta = getCalendarEventMeta(ev) @@ -308,6 +325,10 @@ export default function SidebarCalendarWeekWidget() { ) })}
+ ) : !relayUrls.length ? ( +

{t('sidebarCalendarNoRelays')}

+ ) : ( +

{t('sidebarCalendarEmptyWeek')}

)} ) diff --git a/src/constants.ts b/src/constants.ts index 4c420871..e8820eaa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -106,9 +106,9 @@ export const MAX_CONCURRENT_SUBS_PER_RELAY = 7 * How many timeline shards may open relay subscriptions at once. Each shard sends one REQ per relay * in its list; with 6 shards in parallel a popular relay can see 6+ SUBs from this app alone, and a * second feed wave (remount / strict mode) pushes past strict relay caps (e.g. nostr.sovbit.host ≤10). - * 3 is a modest bump for faster multi-shard home loads; lower to 2 if a relay complains about SUB count. + * 5 balances faster multi-shard home loads against per-relay SUB caps (see {@link MAX_CONCURRENT_SUBS_PER_RELAY}). */ -export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 3 +export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 5 /** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */ export const MAX_PUBLISH_RELAYS = 20 @@ -666,8 +666,10 @@ export function isSocialKindBlockedKind(kind: number): boolean { /** * True when a filter should avoid relays that do not carry social-note surface. * - * Important: kindless lookup filters (e.g. `ids`, `authors + #d`) are often used for - * publication / replaceable resolution and must keep relays like thecitadel in scope. + * Important: kindless lookup filters (e.g. `ids`, `authors + #d`, **`#p` mentions**, `#e` threads) + * are scoped and must keep aggregators / read mirrors in scope. The notifications faux spell uses + * `#p` only (kinds applied client-side); misclassifying it as a broad social firehose stripped every + * relay and skipped real REQ batches (`groupedRequests.length === 0`). */ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolean { const k = filter.kinds @@ -676,8 +678,38 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea const dTags = Array.isArray((filter as Record)['#d']) ? ((filter as Record)['#d'] as unknown[]).length : 0 + const pTags = Array.isArray((filter as Record)['#p']) + ? ((filter as Record)['#p'] as unknown[]).length + : 0 + const eTags = Array.isArray((filter as Record)['#e']) + ? ((filter as Record)['#e'] as unknown[]).length + : 0 + const eUpperTags = Array.isArray((filter as Record)['#E']) + ? ((filter as Record)['#E'] as unknown[]).length + : 0 + const aTags = Array.isArray((filter as Record)['#a']) + ? ((filter as Record)['#a'] as unknown[]).length + : 0 + const tTags = Array.isArray((filter as Record)['#t']) + ? ((filter as Record)['#t'] as unknown[]).length + : 0 + const authors = Array.isArray(filter.authors) ? filter.authors.length : 0 + const search = filter.search + const hasSearch = typeof search === 'string' && search.trim().length > 0 // Scoped lookups are not "broad social feed" queries. - if (ids > 0 || dTags > 0) return false + if ( + ids > 0 || + dTags > 0 || + pTags > 0 || + eTags > 0 || + eUpperTags > 0 || + aTags > 0 || + tTags > 0 || + authors > 0 || + hasSearch + ) { + return false + } return true } const arr = Array.isArray(k) ? k : [k] diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index 2aa6fd15..70d080bc 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -44,7 +44,6 @@ export function useFetchEvent( const initialMatches = initialEvent && (initialEvent.id === eventId || - eventId.includes(initialEvent.id) || (() => { try { return getNoteBech32Id(initialEvent) === eventId @@ -76,6 +75,11 @@ export function useFetchEvent( } } + // New target without a synchronous hit: drop the previous note immediately so the panel does not + // keep showing the last-opened article (or fail to show a skeleton) while the new fetch runs or + // after it returns empty. + setEvent(undefined) + setError(null) setIsFetching(true) const fetchEvent = async () => { @@ -90,10 +94,13 @@ export function useFetchEvent( if (fetchedEvent && !isEventDeleted(fetchedEvent)) { setEvent(fetchedEvent) addReplies([fetchedEvent]) + } else { + setEvent(undefined) } } catch (error) { if (!cancelled) { setError(error as Error) + setEvent(undefined) } } finally { if (!cancelled) { diff --git a/src/hooks/useNoteStatsById.tsx b/src/hooks/useNoteStatsById.tsx index 4d4ef74b..af3ac882 100644 --- a/src/hooks/useNoteStatsById.tsx +++ b/src/hooks/useNoteStatsById.tsx @@ -3,7 +3,8 @@ import { useSyncExternalStore } from 'react' export function useNoteStatsById(noteId: string) { return useSyncExternalStore( - (cb) => noteStats.subscribeNoteStats(noteId, cb), - () => noteStats.getNoteStats(noteId) - ) + (onStoreChange) => noteStats.subscribeNoteStats(noteId, onStoreChange), + () => noteStats.getNoteStatsExternalSnapshot(noteId), + () => noteStats.getNoteStatsExternalSnapshot(noteId) + ).stats } diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 7863d27f..7af2c1ec 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -155,6 +155,9 @@ export function useProfileTimeline({ const cachedEntry = useMemo(() => memoryTimelineByKey.get(cacheKey), [cacheKey]) const [events, setEvents] = useState(cachedEntry?.events ?? []) const [isLoading, setIsLoading] = useState(!cachedEntry) + /** Last painted rows — re-seed merge pool after `refresh()` clears memory so relay hiccups do not wipe the list. */ + const latestEventsRef = useRef(events) + latestEventsRef.current = events const [refreshToken, setRefreshToken] = useState(0) const subscriptionRef = useRef<() => void>(() => {}) @@ -212,7 +215,25 @@ export function useProfileTimeline({ setIsLoading(false) mem.events.forEach((e) => pool.set(e.id, e)) } else { - setIsLoading(!mem) + /** + * Stale memory: keep showing last rows while revalidating (SWR). Previously we set `isLoading` false + * whenever `mem` existed (`!mem` is false), which hid the refresh banner and skipped priming the pool — + * relay failures then left the UI “frozen” on an empty pool with no new merge. + */ + if (mem?.events?.length) { + mem.events.forEach((e) => pool.set(e.id, e)) + setEvents(mem.events) + } else { + try { + const pk = normalizeHexPubkey(pubkey) + for (const e of latestEventsRef.current) { + if (normalizeHexPubkey(e.pubkey) === pk) pool.set(e.id, e) + } + } catch { + /* ignore malformed pubkeys */ + } + } + setIsLoading(true) } const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) @@ -231,16 +252,22 @@ export function useProfileTimeline({ if (idbDocKinds.length > 0) { try { const pkNorm = normalizeHexPubkey(pubkey) - const fromIdb = await indexedDb.getCachedPublicationStoreEventsForProfileAuthor( - pkNorm, - idbDocKinds, - limit - ) + const [fromPubStore, fromArchive] = await Promise.all([ + indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkNorm, idbDocKinds, limit), + indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { + kinds: idbDocKinds, + maxRowsScanned: 18_000, + maxMatches: limit + }) + ]) if (!cancelled) { - for (const e of fromIdb) { + for (const e of fromPubStore) { + pool.set(e.id, e) + } + for (const e of fromArchive) { pool.set(e.id, e) } - if (fromIdb.length) flushPool() + if (fromPubStore.length || fromArchive.length) flushPool() } } catch { /* IDB optional */ diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 82d8a64f..8edc0ba7 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 3d6630e1..00e857e4 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -794,6 +794,7 @@ export default { heatMapRescan: "Erneut scannen", heatMapOpenThread: "Thread öffnen", heatMapBubbleStats: "{{posts}} Notes · {{people}} Personen · {{follows}} Folge-Accounts im Thread", + heatMapConnectorHint: "Verknüpfte Threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Bitte anmelden, um die Thread-Heatmap zu öffnen.", Calendar: "Kalender", "No subscribed interests yet.": "Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c8615708..4785fb4f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -798,6 +798,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index f2c515ff..27919b0a 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 9818d4cc..3fa925b5 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 8d026857..f58276c4 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 2faa38d7..4257076d 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index af7bba44..fe3c68e6 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index c9528fa0..6ba75768 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 9f89c18b..6722c678 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -774,6 +774,7 @@ export default { heatMapRescan: "Rescan", heatMapOpenThread: "Open thread", heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index 5054691f..19b127c8 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -179,23 +179,33 @@ const CalendarPrimaryPage = forwardRef(funct try { const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange - const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow( - rangeStartMs, - rangeEndExclusiveMs, - MONTH_IDB_MAX_SCAN - ) + const [fromIdb, fromArchive] = await Promise.all([ + indexedDb.getCalendarEventsForOccurrenceWindow( + rangeStartMs, + rangeEndExclusiveMs, + MONTH_IDB_MAX_SCAN + ), + indexedDb.getArchivedCalendarEventsOverlappingWindow( + rangeStartMs, + rangeEndExclusiveMs, + 55_000, + 2500 + ) + ]) if (cancelled) return + const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) + const fromSessionNow = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSessionNow])) + setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSessionNow])) setLoading(false) if (!relayUrls.length) { - scheduleLateSessionMerge(fromIdb) + scheduleLateSessionMerge(localBaseline) return } @@ -258,7 +268,7 @@ const CalendarPrimaryPage = forwardRef(funct SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb])) + setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline])) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return @@ -267,11 +277,26 @@ const CalendarPrimaryPage = forwardRef(funct SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later])) + setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline])) }, 2500) } catch { if (!cancelled) { - setRawEvents([]) + try { + const { rangeStartMs: rs, rangeEndExclusiveMs: re } = paddedMonthRange + const [idb, arc] = await Promise.all([ + indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN), + indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500) + ]) + const salvage = dedupeCalendarEvents([...idb, ...arc]) + const fromSession = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession])) + } catch { + setRawEvents([]) + } setLoading(false) } } diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index 0cc0e2d0..f97e4391 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -333,15 +333,37 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) const graphAreaRef = useRef(null) const bubbleRefs = useRef>(new Map()) - const [lineSegs, setLineSegs] = useState< - Array<{ x1: number; y1: number; x2: number; y2: number }> - >([]) + type ConnectorSeg = { + x1: number + y1: number + x2: number + y2: number + threadA: string + threadB: string + } + const [lineSegs, setLineSegs] = useState([]) + const [hoveredConnector, setHoveredConnector] = useState<{ a: string; b: string } | null>(null) + + const rowByRoot = useMemo(() => new Map(layoutRows.map((r) => [r.rootId, r])), [layoutRows]) const bindBubbleRef = useCallback((rootId: string) => (el: HTMLButtonElement | null) => { if (el) bubbleRefs.current.set(rootId, el) else bubbleRefs.current.delete(rootId) }, []) + const connectorTitle = useCallback( + (threadA: string, threadB: string) => { + const clip = (s: string, max: number) => { + const x = s.replace(/\s+/g, ' ').trim() + return x.length <= max ? x : `${x.slice(0, max - 1)}…` + } + const left = clip(rowByRoot.get(threadA)?.snippet ?? threadA, 96) + const right = clip(rowByRoot.get(threadB)?.snippet ?? threadB, 96) + return t('heatMapConnectorHint', { left, right }) + }, + [rowByRoot, t] + ) + const recomputeConnectorLines = useCallback(() => { const host = graphAreaRef.current if (!host || layoutRows.length === 0) { @@ -355,11 +377,13 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) const r = el.getBoundingClientRect() return { x: r.left - br.left + r.width / 2, y: r.top - br.top + r.height / 2 } } - const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] + const segs: ConnectorSeg[] = [] for (const { a, b } of edges) { const ca = centerOf(a) const cb = centerOf(b) - if (ca && cb) segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y }) + if (ca && cb) { + segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y, threadA: a, threadB: b }) + } } setLineSegs(segs) }, [layoutRows, edges]) @@ -439,24 +463,56 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
- {lineSegs.map((s, i) => ( - - ))} + + {lineSegs.map((s, i) => { + const hi = + hoveredConnector != null && + ((hoveredConnector.a === s.threadA && hoveredConnector.b === s.threadB) || + (hoveredConnector.a === s.threadB && hoveredConnector.b === s.threadA)) + return ( + + ) + })} + + + {lineSegs.map((s, i) => { + const title = connectorTitle(s.threadA, s.threadB) + return ( + + {title} + setHoveredConnector({ a: s.threadA, b: s.threadB })} + onMouseLeave={() => setHoveredConnector(null)} + /> + + ) + })} + -
+
{layoutRows.map((row) => { const intensity = Math.min(1, row.heat / maxHeat) const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9)) @@ -466,6 +522,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) follows: row.followAuthorsInThread }) const ariaLabel = [row.snippet, statsLine, t('heatMapOpenThread')].filter(Boolean).join('. ') + const connectorHit = + hoveredConnector != null && + (row.rootId === hoveredConnector.a || row.rootId === hoveredConnector.b) return ( @@ -473,10 +532,11 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) ref={bindBubbleRef(row.rootId)} type="button" className={cn( - 'group relative shrink-0 rounded-full border shadow-sm transition-transform', + 'pointer-events-auto group relative shrink-0 rounded-full border shadow-sm transition-transform', 'flex items-center justify-center', 'hover:z-10 hover:scale-[1.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', - 'border-border/70 bg-card/90 backdrop-blur-sm' + 'border-border/70 bg-card/90 backdrop-blur-sm', + connectorHit && 'z-[9] scale-[1.03] ring-2 ring-primary/70 ring-offset-2 ring-offset-background' )} style={{ width: size, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 83d46b97..3c008b19 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -180,6 +180,8 @@ export const JUMBLE_SESSION_RELAY_STRIKES_CHANGED = 'jumble:session-relay-strike /** Live timeline REQ: EOSE caps “connected but silent” relays. */ const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800 +/** Coalesce pre-EOSE timeline snapshots; `setTimeout` so updates still run when rAF is throttled (background tab). */ +const TIMELINE_STREAMING_COALESCE_MS = 24 /** * After initial timeline EOSE (incl. grace), events with `created_at` older than this many seconds @@ -1542,6 +1544,17 @@ class ClientService extends EventTarget { }) let hasResolved = false let earlyGraceTimer: ReturnType | null = null + /** + * Live timelines listen for {@link emitNewEvent} on the first relay ACK — not after N/3 successes. + * Waiting for a third of many relays meant the profile/home feed stayed stale until a subscription + * picked the note up from the network (minutes later if few relays accepted the publish). + */ + let newEventLiveFanoutEmitted = false + const maybeEmitNewEventForLiveFeeds = () => { + if (newEventLiveFanoutEmitted || successCount < 1) return + newEventLiveFanoutEmitted = true + client.emitNewEvent(event) + } const globalTimeout = setTimeout(() => { if (hasResolved) { @@ -1574,6 +1587,7 @@ class ClientService extends EventTarget { earlyGraceTimer = null } hasResolved = true + maybeEmitNewEventForLiveFeeds() logger.debug('[PublishEvent] Resolving due to timeout', { success: successCount >= uniqueRelayUrls.length / 3, successCount, @@ -1782,11 +1796,7 @@ class ClientService extends EventTarget { successCount }) - // If one third of the relays have accepted the event, consider it a success - const isSuccess = successCount >= uniqueRelayUrls.length / 3 - if (isSuccess) { - this.emitNewEvent(event) - } + maybeEmitNewEventForLiveFeeds() if (currentFinished >= uniqueRelayUrls.length && !hasResolved) { if (earlyGraceTimer != null) { clearTimeout(earlyGraceTimer) @@ -2674,13 +2684,20 @@ class ClientService extends EventTarget { /** * Stream matching events to the UI immediately. Initial completion is either aggregate `oneose` from all * relays, or {@link firstRelayResultGraceMs} after the first event (whichever comes first). - * While still before EOSE, coalesce bursts onto one rAF so we do not sort the full buffer on every microtask. + * While still before EOSE, coalesce bursts with a short timeout (not rAF) so feeds still advance when the + * tab is in the background — browsers throttle rAF heavily there, which looked like a frozen timeline. */ - let streamFlushRafId: number | null = null + let streamFlushDelayId: ReturnType | null = null + const clearStreamFlushDelay = () => { + if (streamFlushDelayId != null) { + clearTimeout(streamFlushDelayId) + streamFlushDelayId = null + } + } const flushStreamingSnapshot = () => { if (eosedAt) return const emit = () => { - streamFlushRafId = null + streamFlushDelayId = null if (eosedAt) return if (needSort) { const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) @@ -2690,15 +2707,12 @@ class ClientService extends EventTarget { } } if (events.length <= 1) { - if (streamFlushRafId != null) { - cancelAnimationFrame(streamFlushRafId) - streamFlushRafId = null - } + clearStreamFlushDelay() emit() return } - if (streamFlushRafId == null) { - streamFlushRafId = requestAnimationFrame(emit) + if (streamFlushDelayId == null) { + streamFlushDelayId = setTimeout(emit, TIMELINE_STREAMING_COALESCE_MS) } } @@ -2786,8 +2800,7 @@ class ClientService extends EventTarget { } idx++ } - if (idx >= timeline.refs.length) return - + // idx === refs.length → strictly older than tail; splice appends (previous early-return dropped these). timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) that.scheduleTimelinePersist(key) } @@ -2834,10 +2847,7 @@ class ClientService extends EventTarget { if (eosedAt != null) return clearFirstResultGraceTimer() - if (streamFlushRafId != null) { - cancelAnimationFrame(streamFlushRafId) - streamFlushRafId = null - } + clearStreamFlushDelay() eosedAt = dayjs().unix() @@ -2924,10 +2934,7 @@ class ClientService extends EventTarget { timelineKey: key, closer: () => { clearFirstResultGraceTimer() - if (streamFlushRafId != null) { - cancelAnimationFrame(streamFlushRafId) - streamFlushRafId = null - } + clearStreamFlushDelay() clearHttpTimelinePoll() onEvents = () => {} onNew = () => {} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index ebe1e325..5cad992e 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { publicationCoordinateLookupKeys, splitPublicationCoordinate @@ -21,6 +21,7 @@ import { } from '@/lib/event' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' +import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' /** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */ export type TArchivedEventRow = { @@ -3037,6 +3038,57 @@ class IndexedDbService { }) } + /** + * Hot {@link StoreNames.EVENT_ARCHIVE} rows for NIP-52 calendar notes whose occurrence overlaps the range. + * Calendar kinds are no longer archived on ingest, but older builds could still have 31922/31923 in the archive. + */ + async getArchivedCalendarEventsOverlappingWindow( + rangeStartMs: number, + rangeEndExclusiveMs: number, + maxRowsScanned = 30_000, + maxMatches = 800 + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] + + const kindSet = new Set(CALENDAR_EVENT_KINDS as readonly number[]) + const maxRows = Math.min(Math.max(maxRowsScanned, 1), 50_000) + const maxOut = Math.min(Math.max(maxMatches, 1), 3000) + + return new Promise((resolve, reject) => { + const out: Event[] = [] + let scanned = 0 + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor || scanned >= maxRows || out.length >= maxOut) { + tx.commit() + resolve(out) + return + } + scanned += 1 + const row = cursor.value as TArchivedEventRow + const ev = row?.value + if ( + ev && + isLikelyCachedNostrEvent(ev) && + kindSet.has(ev.kind) && + !shouldDropEventOnIngest(ev) && + calendarOccurrenceOverlapsRange(ev, rangeStartMs, rangeEndExclusiveMs) + ) { + out.push(ev) + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + async deleteArchivedEvent(eventId: string): Promise { const id = eventId.toLowerCase() await this.initPromise diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index c233cce1..cf51c4c2 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -56,6 +56,13 @@ export type TNoteStats = { class NoteStatsService { static instance: NoteStatsService private noteStatsMap: Map> = new Map() + /** Bumped whenever {@link notifyNoteStats} runs so {@link useNoteStatsById} can rely on `Object.is` (map entries alone are not always a new reference). */ + private noteStatsUiEpochByKey = new Map() + /** Last `{ stats, epoch }` object per note for {@link getNoteStatsExternalSnapshot} — must be stable across renders. */ + private noteStatsExternalSnapCache = new Map< + string, + { stats: Partial | undefined; epoch: number; out: { stats: Partial | undefined; epoch: number } } + >() private noteStatsSubscribers = new Map void>>() /** * Batched, microtask-deferred subscriber wakes. Without this, {@link updateNoteStatsByEvents} called from @@ -276,7 +283,7 @@ class NoteStatsService { return } - logger.info('[NoteStats] processBatch: running', { + logger.debug('[NoteStats] processBatch: running', { pendingForeground: this.pendingForeground.size, pendingBackground: this.pendingEvents.size }) @@ -288,7 +295,7 @@ class NoteStatsService { try { const eventsToProcess = this.takeNextStatsSlice() - logger.info('[NoteStats] processBatch slice', { + logger.debug('[NoteStats] processBatch slice', { count: eventsToProcess.length, ids: eventsToProcess.map((id) => `${id.slice(0, 12)}…`), remainingForeground: this.pendingForeground.size, @@ -330,7 +337,7 @@ class NoteStatsService { updatedAt: dayjs().unix() }) const subscriberCount = this.noteStatsSubscribers.get(statsKey)?.size ?? 0 - logger.info('[NoteStats] processSingleEvent: snapshot published', { + logger.debug('[NoteStats] processSingleEvent: snapshot published', { statsKey: `${statsKey.slice(0, 12)}…`, reason, subscriberCount @@ -637,6 +644,16 @@ class NoteStatsService { this.noteStatsSubscribers.set(key, set) } set.add(callback) + // Stats may have been merged while this note was off-screen (subscriberCount was 0 on publish). + // One microtask ping lets `useSyncExternalStore` re-read after mount so counts are not stuck blank. + queueMicrotask(() => { + if (!set?.has(callback)) return + try { + callback() + } catch (e) { + logger.warn('[NoteStatsService] subscribeNoteStats ping failed', { err: e }) + } + }) return () => { set?.delete(callback) if (set?.size === 0) this.noteStatsSubscribers.delete(key) @@ -662,6 +679,7 @@ class NoteStatsService { private notifyNoteStats(noteId: string) { const key = this.statsKey(noteId) + this.noteStatsUiEpochByKey.set(key, (this.noteStatsUiEpochByKey.get(key) ?? 0) + 1) this.subscriberNotifyKeys.add(key) if (this.subscriberNotifyMicrotaskQueued) return this.subscriberNotifyMicrotaskQueued = true @@ -674,6 +692,26 @@ class NoteStatsService { return this.noteStatsMap.get(this.statsKey(id)) } + /** + * Snapshot for {@link useNoteStatsById} / `useSyncExternalStore`: `epoch` changes on every stats notify so React + * always re-renders when counts update (avoids stale UI when the map entry reference is reused or updates race mount). + */ + getNoteStatsExternalSnapshot(noteId: string): { + stats: Partial | undefined + epoch: number + } { + const key = this.statsKey(noteId) + const stats = this.noteStatsMap.get(key) + const epoch = this.noteStatsUiEpochByKey.get(key) ?? 0 + const prev = this.noteStatsExternalSnapCache.get(key) + if (prev && prev.stats === stats && prev.epoch === epoch) { + return prev.out + } + const out = { stats, epoch } + this.noteStatsExternalSnapCache.set(key, { stats, epoch, out }) + return out + } + addZap( pubkey: string, eventId: string,