From 4264f417761853cd29e6d949648b7fecec98068f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 11 May 2026 09:22:22 +0200 Subject: [PATCH] bug-fixes. combine profile feed with favorites feed --- src/PageManager.tsx | 17 +- src/components/Embedded/EmbeddedNote.tsx | 2 +- src/components/NormalFeed/index.tsx | 6 +- .../Profile/ProfileFeedWithPins.tsx | 319 ++++-------------- .../Sidebar/SidebarCalendarWeekWidget.tsx | 139 ++++---- src/hooks/useFetchEvent.tsx | 4 +- src/hooks/useProfileAuthorFeedSubRequests.ts | 139 ++++++++ src/lib/profile-author-subrequests.ts | 41 +++ src/pages/primary/CalendarPrimaryPage.tsx | 73 ++-- .../FavoriteRelaysActivityProvider.tsx | 32 +- src/services/navigation-event-store.ts | 22 +- 11 files changed, 410 insertions(+), 384 deletions(-) create mode 100644 src/hooks/useProfileAuthorFeedSubRequests.ts create mode 100644 src/lib/profile-author-subrequests.ts diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 3a33d11e..d97f8bd5 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -449,17 +449,19 @@ export function useSmartNoteNavigation() { return } const { noteId } = parsed - - // If event is provided, store it in navigation event store to avoid re-fetching + + navigationEventStore.clear() if (event) { - navigationEventStore.clear() navigationEventStore.setEvent(event) client.addEventToCache(event) } // Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching if (relatedEvents?.length) { for (const ev of relatedEvents) { - if (ev && ev !== event) client.addEventToCache(ev) + if (ev && ev !== event) { + client.addEventToCache(ev) + navigationEventStore.setEvent(ev) + } } } @@ -516,14 +518,17 @@ export function useSmartNoteNavigationOptional() { return } const { noteId } = parsed + navigationEventStore.clear() if (event) { - navigationEventStore.clear() navigationEventStore.setEvent(event) client.addEventToCache(event) } if (relatedEvents?.length) { for (const ev of relatedEvents) { - if (ev && ev !== event) client.addEventToCache(ev) + if (ev && ev !== event) { + client.addEventToCache(ev) + navigationEventStore.setEvent(ev) + } } } const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index f4e8882c..c808d95b 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -280,7 +280,7 @@ function EmbeddedNoteFetched({ const resolve = (ev: Event | undefined) => resolveAndSetRef.current(ev) const tryShortcuts = (): boolean => { - const nav = navigationEventStore.getEvent(noteKey) + const nav = navigationEventStore.peekEvent(noteKey) if (nav && resolve(nav)) return true const peek = client.peekSessionCachedEvent(noteKey) if (peek && resolve(peek)) return true diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 2737402a..a7d4eac0 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -124,7 +124,11 @@ const NormalFeed = forwardRef(null) const noteListRef = ref || internalNoteListRef diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index 054c8ad6..d1e698f7 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -1,25 +1,19 @@ +import NoteList, { type TNoteListRef } from '@/components/NoteList' import NoteCard from '@/components/NoteCard' -import ProfileSearchBar from '@/components/ui/ProfileSearchBar' +import KindFilter from '@/components/KindFilter' +import { RefreshButton } from '@/components/RefreshButton' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind, PROFILE_POSTS_TAB_KINDS } from '@/constants' -import { isReplyNoteEvent } from '@/lib/event' -import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' +import { useProfileAuthorFeedSubRequests } from '@/hooks/useProfileAuthorFeedSubRequests' import { useProfilePins } from '@/hooks/useProfilePins' -import { useProfileTimeline } from '@/hooks/useProfileTimeline' -import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation' -import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' -import { useZap } from '@/providers/ZapProvider' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client from '@/services/client.service' import storage from '@/services/local-storage.service' -import { RefreshCw } from 'lucide-react' -import { Event, kinds } from 'nostr-tools' +import { nip19, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -const INITIAL_SHOW_COUNT = 25 -const LOAD_MORE_COUNT = 25 - function useHideRepliesLikeMainFeed() { const [hideReplies, setHideReplies] = useState(() => { const m = storage.getNoteListMode() @@ -41,9 +35,8 @@ function useHideRepliesLikeMainFeed() { const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() - const { zapReplyThreshold } = useZap() - const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() - /** Profile timelines always show reposts; global kind filter still applies to other kinds. */ + const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = + useKindFilterOrDefaults() const profileTimelineShowKinds = useMemo(() => { if (showKinds.includes(kinds.Repost) && showKinds.includes(ExtendedKind.GENERIC_REPOST)) { return showKinds @@ -54,188 +47,53 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string return next.sort((a, b) => a - b) }, [showKinds]) const hideReplies = useHideRepliesLikeMainFeed() - const [searchQuery, setSearchQuery] = useState('') const [isRefreshing, setIsRefreshing] = useState(false) - const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) - const bottomRef = useRef(null) + const noteListRef = useRef(null) const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) - const filterPredicate = useCallback( - (event: Event) => { - if (event.kind === ExtendedKind.ZAP_RECEIPT) { - return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold) - } - return true - }, - [zapReplyThreshold] - ) - - /** Bump when posts-tab `kinds` change so in-memory timeline cache is not reused across incompatible filters. */ - const cacheKey = useMemo( - () => `${pubkey}-profile-posts-tab-v2-${zapReplyThreshold}`, - [pubkey, zapReplyThreshold] - ) - const postsTabKinds = useMemo(() => [...PROFILE_POSTS_TAB_KINDS], []) - const { events: timelineEvents, isLoading: loadingTimeline, refresh: refreshTimeline } = useProfileTimeline({ - pubkey, - cacheKey, - kinds: postsTabKinds, - limit: 200, - filterPredicate - }) - - const { rows: zapPollVoteRows, loading: loadingZapPollVotes, reload: reloadZapPollVotes } = - useProfileZapPollParticipation(pubkey) - - const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) - - const passesMainFeedTimelineRules = useCallback( - (event: Event) => { - if (!profileTimelineShowKinds.includes(event.kind)) return false - if (event.kind === kinds.ShortTextNote) { - const isReply = isReplyNoteEvent(event) - if (hideReplies && isReply) return false - if (isReply && !showKind1Replies) return false - if (!isReply && !showKind1OPs) return false - } - if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false - if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false - return true - }, - [profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] - ) - - const restTimeline = useMemo( - () => timelineEvents.filter((e) => !pinIds.has(e.id)).filter(passesMainFeedTimelineRules), - [timelineEvents, pinIds, passesMainFeedTimelineRules] - ) - - type ProfileMergedRow = { - key: string - event: Event - sortAt: number - zapPollVoteHighlight?: number - } - - const mergedRestRows = useMemo((): ProfileMergedRow[] => { - const showZapPollVotes = profileTimelineShowKinds.includes(ExtendedKind.ZAP_POLL) - const timelinePollIds = new Set( - restTimeline.filter((e) => e.kind === ExtendedKind.ZAP_POLL).map((e) => e.id) - ) - const noteRows: ProfileMergedRow[] = restTimeline.map((e) => ({ - key: e.id, - event: e, - sortAt: e.created_at - })) - const voteRows: ProfileMergedRow[] = showZapPollVotes - ? zapPollVoteRows - .filter((r) => !timelinePollIds.has(r.poll.id)) - .map((r) => ({ - key: `zap-poll-vote:${r.voteReceipt.id}`, - event: r.poll, - sortAt: r.voteReceipt.created_at, - zapPollVoteHighlight: r.optionIndex - })) - : [] - return [...noteRows, ...voteRows].sort((a, b) => b.sortAt - a.sortAt) - }, [restTimeline, zapPollVoteRows, profileTimelineShowKinds]) - - const rowMatchesSearch = useCallback( - (event: Event) => { - const q = searchQuery.trim().toLowerCase() - if (!q) return true - if (event.content.toLowerCase().includes(q)) return true - return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) - }, - [searchQuery] - ) - - const applySearch = useCallback( - (events: Event[]) => { - const q = searchQuery.trim().toLowerCase() - if (!q) return events - return events.filter((event) => rowMatchesSearch(event)) - }, - [rowMatchesSearch] - ) + const { subRequests, followingFeedDeltaSubRequests, feedSubscriptionKey, refresh: refreshAuthorRelayLayers } = + useProfileAuthorFeedSubRequests({ + pubkey, + kinds: postsTabKinds, + limit: 200 + }) - const filteredPins = useMemo( - () => applySearch(pinEvents).filter((e) => !isEventDeleted(e)), - [pinEvents, applySearch, isEventDeleted] - ) - const filteredRest = useMemo( + const pinnedEventIds = useMemo( () => - mergedRestRows.filter((row) => rowMatchesSearch(row.event) && !isEventDeleted(row.event)), - [mergedRestRows, rowMatchesSearch, isEventDeleted] - ) - - const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) - - /** Pins always occupy the top of the profile; `showCount` caps total visible rows (pins + posts). */ - const displayedPins = useMemo(() => { - if (filteredPins.length <= showCount) return filteredPins - return filteredPins.slice(0, showCount) - }, [filteredPins, showCount]) - - const displayedFeed = useMemo( - () => filteredRest.slice(0, Math.max(0, showCount - displayedPins.length)), - [filteredRest, showCount, displayedPins.length] + pinEvents.map((e) => + nip19.neventEncode({ id: e.id, author: e.pubkey, kind: e.kind }) + ), + [pinEvents] ) - const totalVisible = displayedPins.length + displayedFeed.length - - useEffect(() => { - setShowCount(INITIAL_SHOW_COUNT) - }, [searchQuery, pubkey]) - - useEffect(() => { - if (!loadingPins && !loadingTimeline && !loadingZapPollVotes) { - setIsRefreshing(false) - } - }, [loadingPins, loadingTimeline, loadingZapPollVotes]) - const refreshAll = useCallback(() => { setIsRefreshing(true) refreshPins() - refreshTimeline() - reloadZapPollVotes() + refreshAuthorRelayLayers() + noteListRef.current?.refresh() void client.fetchDeletionEventsForPubkey(pubkey) - }, [refreshPins, refreshTimeline, reloadZapPollVotes, pubkey]) + }, [refreshPins, refreshAuthorRelayLayers, pubkey]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) useEffect(() => { - if (!bottomRef.current || totalVisible >= mergedDisplay.length) return - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting && totalVisible < mergedDisplay.length) { - setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length)) - } - }, - { threshold: 0.1 } - ) - observer.observe(bottomRef.current) - return () => observer.disconnect() - }, [totalVisible, mergedDisplay.length]) + if (!isRefreshing) return + const id = window.setTimeout(() => setIsRefreshing(false), 600) + return () => clearTimeout(id) + }, [isRefreshing]) + + const handleShowKindsChange = useCallback(() => { + noteListRef.current?.scrollToTop() + }, []) - // Pins and zap-poll votes can take longer than the timeline; do not block the whole tab on them. - // Show posts as soon as the timeline has delivered anything (or finished empty). - const showFullSkeleton = - mergedDisplay.length === 0 && loadingTimeline && timelineEvents.length === 0 + const showPinsOnlySkeleton = pinEvents.length === 0 && loadingPins && subRequests.length === 0 - if (showFullSkeleton) { + if (showPinsOnlySkeleton) { return (
-
- -
{Array.from({ length: 4 }).map((_, i) => ( @@ -245,94 +103,65 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string ) } - if (!mergedDisplay.length && !loadingPins && !loadingTimeline && !loadingZapPollVotes) { + if (!subRequests.length) { return (
-
- -
-
- {searchQuery.trim() ? t('No posts match your search') : t('No posts found')} -
+

{t('Nothing to load for this feed.')}

) } return ( -
-
- -
+
{isRefreshing && (
- {t('Refreshing posts...')}
)} - {searchQuery.trim() && ( -
- {t('Showing {{filtered}} of {{total}} items', { - filtered: totalVisible, - total: mergedDisplay.length - })} -
- )} -
- {displayedPins.length > 0 && ( -
- {displayedPins.map((event) => ( - - ))} -
- )} - {mergedDisplay.length === 0 && (loadingPins || loadingZapPollVotes) && ( -
- {t('Loading…')} -
- )} - {displayedPins.length > 0 && displayedFeed.length > 0 && ( -
- {t('Feed')} -
- )} - {displayedFeed.length > 0 && ( -
- {displayedFeed.map((row) => ( - - ))} -
- )} +
+ +
- {totalVisible < mergedDisplay.length && ( -
-
{t('Loading more...')}
+ {pinEvents.filter((e) => !isEventDeleted(e)).length > 0 && ( +
+ {pinEvents + .filter((e) => !isEventDeleted(e)) + .map((event) => ( + + ))} +
{t('Feed')}
)} +
+ +
) }) diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index 5e05477d..5020c042 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -20,7 +20,7 @@ import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' -import { CalendarDays, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' +import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' import { type Event } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -49,8 +49,6 @@ export default function SidebarCalendarWeekWidget() { const [weekOffset, setWeekOffset] = useState(0) const [rawEvents, setRawEvents] = useState([]) - /** True only until the first IndexedDB (+ session) snapshot for this week is applied — never while relay REQ runs. */ - const [loading, setLoading] = useState(true) const relayUrls = useMemo(() => { const base = getRelayUrlsWithFavoritesFastReadAndInbox( @@ -99,52 +97,70 @@ export default function SidebarCalendarWeekWidget() { useEffect(() => { let cancelled = false let lateMergeTimer: number | null = null - setLoading(true) - void (async () => { - try { - const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset) - const [fromIdb, fromArchive] = await Promise.all([ - indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs), - indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) - ]) + const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset) - const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange( - [...fromIdb, ...fromArchive], - weekStartMs, - weekEndExclusiveMs - ) - const sessionSnap = client.getSessionEventsMatchingSearch( + const fromSessionSync = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( + fromSessionSync, + weekStartMs, + weekEndExclusiveMs + ) + setRawEvents(sessionOnly) + + const scheduleLateSessionMerge = (mergeWithIdb: Event[]) => { + lateMergeTimer = window.setTimeout(() => { + lateMergeTimer = null + if (cancelled) return + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - const mergedLocal = dedupeCalendarEventsPreferringOccurrenceRange( - [...localBaseline, ...sessionSnap], - weekStartMs, - weekEndExclusiveMs + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...mergeWithIdb], ws, we) ) - /** Always paint IDB + session first; a superseded effect must not skip this (relayKey churn would leave the list blank). */ - if (!cancelled) { - setRawEvents(mergedLocal) - setLoading(false) - } + }, 2500) + } + + void (async () => { + try { + const idbP = Promise.all([ + indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs, 8000), + indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) + ]) + .then(([fromIdb, fromArchive]) => + dedupeCalendarEventsPreferringOccurrenceRange( + [...fromIdb, ...fromArchive], + weekStartMs, + weekEndExclusiveMs + ) + ) + .catch((): Event[] => []) + + void idbP.then((localBaseline) => { + if (cancelled) return + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + const s2 = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents( + dedupeCalendarEventsPreferringOccurrenceRange([...localBaseline, ...s2], ws, we) + ) + }) if (cancelled) return if (!relayUrls.length) { - lateMergeTimer = window.setTimeout(() => { - lateMergeTimer = null - if (cancelled) return - const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) - const later = client.getSessionEventsMatchingSearch( - '', - SESSION_CALENDAR_MERGE_CAP, - [...CALENDAR_EVENT_KINDS] - ) - setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we) - ) - }, 2500) + void idbP.then((lb) => { + if (!cancelled) scheduleLateSessionMerge(lb) + }) return } @@ -186,22 +202,21 @@ export default function SidebarCalendarWeekWidget() { ) ) - let batch: Event[] = [] - const fromFollowing: Event[] = [] - try { - const merged = await Promise.all([mainReq, ...chunkReqs]) - batch = merged[0] ?? [] - for (let i = 1; i < merged.length; i++) { - fromFollowing.push(...(merged[i] ?? [])) - } - } catch { - /** Relay REQ failed or timed out — keep the snapshot we already painted (re-apply in case of races). */ - if (!cancelled) { - setRawEvents(mergedLocal) - } - } + const relayMergedP = Promise.all([mainReq, ...chunkReqs]) + .then((merged) => { + const batch = merged[0] ?? [] + const fromFollowing: Event[] = [] + for (let i = 1; i < merged.length; i++) { + fromFollowing.push(...(merged[i] ?? [])) + } + return { batch, fromFollowing } + }) + .catch(() => ({ batch: [] as Event[], fromFollowing: [] as Event[] })) + + const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP]) if (cancelled) return + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) const fromSessionAfterNet = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, @@ -211,22 +226,22 @@ export default function SidebarCalendarWeekWidget() { setRawEvents( dedupeCalendarEventsPreferringOccurrenceRange( [...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing], - weekStartMs, - weekEndExclusiveMs + ws, + we ) ) } lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return - const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + const { weekStartMs: w2, weekEndExclusiveMs: w2e } = getLocalMondayWeekBounds(weekOffset) const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we) + dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], w2, w2e) ) }, 2500) } catch { @@ -247,10 +262,7 @@ export default function SidebarCalendarWeekWidget() { } catch { setRawEvents([]) } - setLoading(false) } - } finally { - if (!cancelled) setLoading(false) } })() return () => { @@ -310,12 +322,7 @@ export default function SidebarCalendarWeekWidget() {

{t('sidebarCalendarHeading')}

- {loading && sortedForWeek.length === 0 ? ( -
- - {t('sidebarCalendarLoading')} -
- ) : sortedForWeek.length > 0 ? ( + {sortedForWeek.length > 0 ? (
    {sortedForWeek.map((ev) => { const meta = getCalendarEventMeta(ev) diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index 70d080bc..e4fca99d 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -62,9 +62,9 @@ export function useFetchEvent( } } - // Check navigation event store first (events passed through navigation) + // Check navigation event store first (events passed through navigation) — peek so remounts still see it. if (!skipShortcuts) { - const navigationEvent = navigationEventStore.getEvent(eventId) + const navigationEvent = navigationEventStore.peekEvent(eventId) if (navigationEvent && !isEventDeleted(navigationEvent)) { setEvent(navigationEvent) addReplies([navigationEvent]) diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts new file mode 100644 index 00000000..e7b8215b --- /dev/null +++ b/src/hooks/useProfileAuthorFeedSubRequests.ts @@ -0,0 +1,139 @@ +import { buildProfileAuthorSubRequestsFromUrlGroups } from '@/lib/profile-author-subrequests' +import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' +import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' +import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' +import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostrOptional } from '@/providers/nostr-context' +import client from '@/services/client.service' +import type { TFeedSubRequest } from '@/types' +import { isSocialKindBlockedKind } from '@/constants' +import { useCallback, useEffect, useMemo, useState } from 'react' + +function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { + const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') + const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') + return `${fav}\u0000${blk}` +} + +const emptyAuthor = { + read: [] as string[], + write: [] as string[], + httpRead: [] as string[], + httpWrite: [] as string[] +} + +export type UseProfileAuthorFeedSubRequestsOptions = { + pubkey: string + /** REQ kinds (e.g. {@link PROFILE_POSTS_TAB_KINDS}) — stable for the Posts tab. */ + kinds: readonly number[] + limit?: number +} + +export function useProfileAuthorFeedSubRequests({ + pubkey, + kinds, + limit = 200 +}: UseProfileAuthorFeedSubRequestsOptions): { + subRequests: TFeedSubRequest[] + followingFeedDeltaSubRequests: TFeedSubRequest[] + feedSubscriptionKey: string + refresh: () => void +} { + const nostr = useNostrOptional() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + + const includeAuthorLocalRelays = useMemo(() => { + const me = nostr?.pubkey?.trim() + if (!me) return false + try { + return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pubkey)) + } catch { + return false + } + }, [nostr?.pubkey, pubkey]) + + const relayListsKey = useMemo( + () => relayListsContentKey(favoriteRelays, blockedRelays), + [favoriteRelays, blockedRelays] + ) + + const kindsKey = useMemo(() => [...kinds].join(','), [kinds]) + + const authorHex = useMemo(() => { + try { + return normalizeHexPubkey(pubkey) + } catch { + return pubkey.trim() + } + }, [pubkey]) + + const [refreshToken, setRefreshToken] = useState(0) + const [provisionalUrls, setProvisionalUrls] = useState([]) + const [fullUrls, setFullUrls] = useState(null) + + useEffect(() => { + let cancelled = false + const socialKinds = kinds.some(isSocialKindBlockedKind) + const provisional = buildProfilePageReadRelayUrls( + favoriteRelays, + blockedRelays, + emptyAuthor, + socialKinds, + includeAuthorLocalRelays, + kinds + ) + if (!cancelled) { + setProvisionalUrls(provisional) + setFullUrls(null) + } + + void client + .fetchRelayList(pubkey) + .catch(() => emptyAuthor) + .then((authorRl) => { + if (cancelled) return + const full = buildProfilePageReadRelayUrls( + favoriteRelays, + blockedRelays, + authorRl, + socialKinds, + includeAuthorLocalRelays, + kinds + ) + setFullUrls(full) + }) + + return () => { + cancelled = true + } + }, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, favoriteRelays, blockedRelays, includeAuthorLocalRelays]) + + const subRequests = useMemo(() => { + if (!provisionalUrls.length) return [] as TFeedSubRequest[] + return buildProfileAuthorSubRequestsFromUrlGroups([provisionalUrls], authorHex, [...kinds], limit) + }, [provisionalUrls, authorHex, kinds, limit]) + + const followingFeedDeltaSubRequests = useMemo(() => { + if (!fullUrls?.length || !provisionalUrls.length) return [] as TFeedSubRequest[] + const delta = subtractNormalizedRelayUrls(fullUrls, provisionalUrls) + if (!delta.length) return [] as TFeedSubRequest[] + return buildProfileAuthorSubRequestsFromUrlGroups([delta], authorHex, [...kinds], limit) + }, [fullUrls, provisionalUrls, authorHex, kinds, limit]) + + const feedSubscriptionKey = useMemo(() => { + const base = computeSpellSubRequestsIdentityKey(subRequests) + return `profile-posts-${authorHex}-${relayListsKey}-${base}` + }, [authorHex, relayListsKey, subRequests]) + + const refresh = useCallback(() => { + setRefreshToken((n) => n + 1) + }, []) + + return { + subRequests, + followingFeedDeltaSubRequests, + feedSubscriptionKey, + refresh + } +} diff --git a/src/lib/profile-author-subrequests.ts b/src/lib/profile-author-subrequests.ts new file mode 100644 index 00000000..bd69feae --- /dev/null +++ b/src/lib/profile-author-subrequests.ts @@ -0,0 +1,41 @@ +import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' +import type { TFeedSubRequest } from '@/types' + +/** + * REQ shards for a profile “posts” timeline: per-relay URL group, `authors` + `kinds`, plus optional + * calendar-invite filters (#p) when {@link kindsArg} includes NIP-52 calendar kinds. + * Same shape as {@link useProfileTimeline}’s internal {@code buildSubRequests}, for {@link NoteList} / {@link NormalFeed}. + */ +export function buildProfileAuthorSubRequestsFromUrlGroups( + groups: string[][], + authorPubkeyHex: string, + kindsArg: number[], + limit: number +): TFeedSubRequest[] { + const hasCalendarKinds = kindsArg.some((k) => + (CALENDAR_EVENT_KINDS as readonly number[]).includes(k) + ) + const authorRequests: TFeedSubRequest[] = groups + .map((urls) => ({ + urls, + filter: { + authors: [authorPubkeyHex], + kinds: kindsArg, + limit + } + })) + .filter((request) => request.urls.length > 0) + const calendarInviteRequests: TFeedSubRequest[] = hasCalendarKinds + ? groups + .map((urls) => ({ + urls, + filter: { + kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], + '#p': [authorPubkeyHex], + limit: 100 + } + })) + .filter((request) => request.urls.length > 0) + : [] + return [...authorRequests, ...calendarInviteRequests] +} diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index 7c89f797..86a82087 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -153,30 +153,43 @@ const CalendarPrimaryPage = forwardRef(funct useEffect(() => { let cancelled = false let lateMergeTimer: number | null = null - setLoading(true) - void (async () => { - const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => { - lateMergeTimer = window.setTimeout(() => { - lateMergeTimer = null - if (cancelled) return - const later = client.getSessionEventsMatchingSearch( - '', - SESSION_CALENDAR_MERGE_CAP, - [...CALENDAR_EVENT_KINDS] - ) - setRawEvents((prev) => - dedupeCalendarEventsPreferringOccurrenceRange( - [...prev, ...later, ...mergeWithIdb], - paddedMonthRange.rangeStartMs, - paddedMonthRange.rangeEndExclusiveMs - ) + const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange + + /** 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 + ) + setRawEvents(sessionOnly) + setLoading(false) + + const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => { + lateMergeTimer = window.setTimeout(() => { + lateMergeTimer = null + if (cancelled) return + const later = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange( + [...prev, ...later, ...mergeWithIdb], + rangeStartMs, + rangeEndExclusiveMs ) - }, 2500) - } + ) + }, 2500) + } + void (async () => { try { - const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange - const idbP = Promise.all([ indexedDb.getCalendarEventsForOccurrenceWindow( rangeStartMs, @@ -199,21 +212,6 @@ const CalendarPrimaryPage = forwardRef(funct ) .catch((): NostrEvent[] => []) - const fromSessionNow = client.getSessionEventsMatchingSearch( - '', - SESSION_CALENDAR_MERGE_CAP, - [...CALENDAR_EVENT_KINDS] - ) - const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( - fromSessionNow, - rangeStartMs, - rangeEndExclusiveMs - ) - if (!cancelled) { - setRawEvents(sessionOnly) - setLoading(false) - } - void idbP.then((localBaseline) => { if (cancelled) return const s2 = client.getSessionEventsMatchingSearch( @@ -323,7 +321,8 @@ const CalendarPrimaryPage = forwardRef(funct } catch { if (!cancelled) { try { - const { rangeStartMs: rs, rangeEndExclusiveMs: re } = paddedMonthRange + const rs = rangeStartMs + const re = rangeEndExclusiveMs const [idb, arc] = await Promise.all([ indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN), indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500) diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index a4f88065..ff0c18f4 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -1,6 +1,7 @@ import logger from '@/lib/logger' import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { buildLiveActivitiesRelayUrls } from '@/lib/live-activities' import { readRelayPulseActiveNpubsCache, writeRelayPulseActiveNpubsCache @@ -138,7 +139,7 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) { export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { pubkey: viewerPubkey, followListEvent } = useNostr() + const { pubkey: viewerPubkey, followListEvent, relayList } = useNostr() const followings = useMemo( () => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), [followListEvent] @@ -158,17 +159,32 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R orderedPubkeysRef.current = orderedPubkeys /** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */ const skipFirstEmptyNetworkOverwriteRef = useRef(false) - const relayKey = useMemo( - () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'), - [favoriteRelays, blockedRelays] + const pulseQueryUrls = useMemo( + () => + buildLiveActivitiesRelayUrls({ + loggedIn: !!viewerPubkey, + favoriteRelays, + blockedRelays, + relayListRead: userReadRelaysWithHttp(relayList), + relayListWrite: relayList?.write ?? [] + }), + [viewerPubkey, favoriteRelays, blockedRelays, relayList] ) + const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls]) + const fetchActive = useCallback( async (useDefaultRelays = false) => { const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null const urls = useDefaultRelays - ? getFavoritesFeedRelayUrls([], blockedRelays) - : getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + ? buildLiveActivitiesRelayUrls({ + loggedIn: false, + favoriteRelays: [], + blockedRelays, + relayListRead: [], + relayListWrite: [] + }) + : pulseQueryUrls if (urls.length === 0) { setLoading(false) setRelayActivityReady(true) @@ -220,7 +236,7 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R setRelayActivityReady(true) } }, - [favoriteRelays, blockedRelays, relayKey, viewerPubkey] + [favoriteRelays, blockedRelays, relayKey, viewerPubkey, pulseQueryUrls] ) const fetchRef = useRef(fetchActive) diff --git a/src/services/navigation-event-store.ts b/src/services/navigation-event-store.ts index 106c7690..8fcf4789 100644 --- a/src/services/navigation-event-store.ts +++ b/src/services/navigation-event-store.ts @@ -25,18 +25,6 @@ function candidateKeysForNoteUrlId(eventId: string): string[] { class NavigationEventStore { private eventMap = new Map() - private removeEventFromAllKeys(event: Event): void { - this.eventMap.delete(event.id) - try { - const urlId = getNoteBech32Id(event) - if (urlId !== event.id) { - this.eventMap.delete(urlId) - } - } catch { - /* ignore */ - } - } - /** * Store an event for navigation (hex id + same bech32 form as {@link toNote} / the URL). */ @@ -53,15 +41,13 @@ class NavigationEventStore { } /** - * Get an event by ID (removes it after retrieval to prevent memory leaks) + * Read an event by ID without removing it (safe for React Strict Mode / effect re-runs). + * Cleared on the next {@link clear} (e.g. when navigating to another note). */ - getEvent(eventId: string): Event | undefined { + peekEvent(eventId: string): Event | undefined { for (const key of candidateKeysForNoteUrlId(eventId)) { const event = this.eventMap.get(key) - if (event) { - this.removeEventFromAllKeys(event) - return event - } + if (event) return event } return undefined }