diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 0be79074..2e4b008c 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1531,6 +1531,9 @@ const NoteList = forwardRef( ? ALGO_LIMIT : LIMIT + // New REQ wave (incl. delta relays with same feed key): outcomes stay stale until this wave ends. + setFeedSubscribeRelayOutcomes([]) + timelineSubscribePromise = client.subscribeTimeline( mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>, { @@ -2528,6 +2531,23 @@ const NoteList = forwardRef( const listSourceEvents = timelineEventsForFilter const feedFullSearchActive = feedFullSearchEvents !== null + const showRelaySubscribeWavePendingBanner = + !oneShotFetch && + !feedFullSearchActive && + subRequests.length > 0 && + relayCapabilityReady && + timelineKey != null && + feedSubscribeRelayOutcomes.length === 0 && + feedTimelineEmptyUiReady + const relayWavePendingBannerEl = showRelaySubscribeWavePendingBanner ? ( +
+ {t('Looking for more events…')} +
+ ) : null const eventReasonLabelMap = useMemo(() => { const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0) if (!reqs.length || !clientFilteredEvents.length) return new Map() @@ -2548,6 +2568,7 @@ const NoteList = forwardRef( const list = (
+ {relayWavePendingBannerEl} {feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
{t('No loaded posts match your filters.')} diff --git a/src/constants.ts b/src/constants.ts index 8c927466..73bfe032 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -84,7 +84,7 @@ export const RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS = 45_000 export const RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS = 90_000 /** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */ -export const FIRST_RELAY_RESULT_GRACE_MS = 5000 +export const FIRST_RELAY_RESULT_GRACE_MS = 2000 /** Legacy name: was used to cap spell NoteList skeleton time; loading now ends on EOSE / first events / safety timeouts. Kept for forks. */ export const SPELL_FEED_LOADING_MAX_MS = 1000 diff --git a/src/hooks/useProfileAccordionData.tsx b/src/hooks/useProfileAccordionData.tsx index b2faa917..25ed9dc7 100644 --- a/src/hooks/useProfileAccordionData.tsx +++ b/src/hooks/useProfileAccordionData.tsx @@ -1,5 +1,6 @@ import { fetchProfileAccordionBundle, + mergeProfileAccordionBundles, profileAccordionBundleCacheKey, type ProfileAccordionBundle } from '@/lib/profile-accordion-fetch' @@ -7,10 +8,16 @@ import { profileAccordionGetCachedBadges, profileAccordionGetCachedFollowPacks, profileAccordionGetCachedInteractions, - profileAccordionGetCachedReports + profileAccordionGetCachedReports, + profileAccordionRelayUrlsKey, + profileAccordionSetBadges, + profileAccordionSetFollowPacks, + profileAccordionSetInteractions, + profileAccordionSetReports } from '@/lib/profile-accordion-session-cache' +import { subtractNormalizedRelayUrls } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const EMPTY: ProfileAccordionBundle = { zaps: [], @@ -59,12 +66,17 @@ export function useProfileAccordionData(opts: { const [data, setData] = useState(EMPTY) const [loading, setLoading] = useState(false) const reqId = useRef(0) + const lastSuccessfulRelayUrlsRef = useRef([]) const relayKey = useMemo( () => profileAccordionBundleCacheKey(relayUrls ?? []), [relayUrls] ) + useEffect(() => { + lastSuccessfulRelayUrlsRef.current = [] + }, [pubkey]) + const runFetch = useCallback( async (force: boolean, overrideUrls?: string[]) => { const urls = (overrideUrls?.length ? overrideUrls : relayUrls) ?? [] @@ -86,6 +98,7 @@ export function useProfileAccordionData(opts: { }) if (id !== reqId.current) return setData(bundle) + lastSuccessfulRelayUrlsRef.current = urls } finally { if (id === reqId.current) setLoading(false) } @@ -93,6 +106,46 @@ export function useProfileAccordionData(opts: { [pubkey, relayUrls, viewerPubkey, favoriteRelays, blockedRelays] ) + const runMergeFetch = useCallback( + async (fullRelayUrls: string[], deltaUrls: string[], base: ProfileAccordionBundle) => { + const pk = pubkey?.trim() + if (!pk || !deltaUrls.length) return + const id = ++reqId.current + setLoading(true) + try { + const deltaB = await fetchProfileAccordionBundle({ + pubkey: pk, + urls: deltaUrls, + viewerPubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays, + force: true, + onPartial: (partial) => { + if (id !== reqId.current) return + setData(mergeProfileAccordionBundles(base, partial)) + } + }) + if (id !== reqId.current) return + const merged = mergeProfileAccordionBundles(base, deltaB) + setData(merged) + const fullKey = profileAccordionBundleCacheKey(fullRelayUrls) + profileAccordionSetInteractions(pk, fullKey, { + zaps: merged.zaps, + reactions: merged.reactions, + comments: merged.comments + }) + profileAccordionSetBadges(pk, fullKey, merged.badges) + profileAccordionSetFollowPacks(pk, fullKey, merged.followPacks) + const viewer = viewerPubkey?.trim() + if (viewer) profileAccordionSetReports(pk, viewer, merged.reports) + lastSuccessfulRelayUrlsRef.current = fullRelayUrls + } finally { + if (id === reqId.current) setLoading(false) + } + }, + [pubkey, viewerPubkey, favoriteRelays, blockedRelays] + ) + const refresh = useCallback( (overrideUrls?: string[]) => { void runFetch(true, overrideUrls) @@ -109,11 +162,29 @@ export function useProfileAccordionData(opts: { if (cached) { setData(cached) setLoading(false) + lastSuccessfulRelayUrlsRef.current = relayUrls return } + + const prevSucc = lastSuccessfulRelayUrlsRef.current + if ( + prevSucc.length > 0 && + profileAccordionRelayUrlsKey(prevSucc) !== profileAccordionRelayUrlsKey(relayUrls) + ) { + const delta = subtractNormalizedRelayUrls(relayUrls, prevSucc) + if (delta.length > 0) { + const prevKey = profileAccordionBundleCacheKey(prevSucc) + const base = readFullCache(pk, prevKey, viewerPubkey) + if (base) { + void runMergeFetch(relayUrls, delta, base) + return + } + } + } + setLoading(true) void runFetch(false) - }, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch]) + }, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch, runMergeFetch]) return { ...data, diff --git a/src/hooks/useProfileRelayUrls.tsx b/src/hooks/useProfileRelayUrls.tsx index ed2b1e26..b2e8ad59 100644 --- a/src/hooks/useProfileRelayUrls.tsx +++ b/src/hooks/useProfileRelayUrls.tsx @@ -3,7 +3,7 @@ import { profileAccordionRelayUrlsKey, profileAccordionSetRelayUrls } from '@/lib/profile-accordion-session-cache' -import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' +import { buildProfileRelayUrls, getProfileRelayUrlsProvisional } from '@/lib/profile-relay-urls' import { useCallback, useEffect, useRef, useState } from 'react' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -37,8 +37,17 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean } } + const provisional = getProfileRelayUrlsProvisional(blockedRelaysRef.current) const revalidateWithVisibleUrls = force && relayUrlsRef.current.length > 0 if (!revalidateWithVisibleUrls) { + if (provisional.length > 0) { + profileAccordionSetRelayUrls(pubkey, provisional) + setRelayUrls(provisional) + setLoading(false) + } else { + setLoading(true) + } + } else { setLoading(true) } try { diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 43c2a19e..bc1ff1c8 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event } from 'nostr-tools' import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' -import { normalizeUrl } from '@/lib/url' +import { normalizeUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' type ProfileTimelineMemoryEntry = { @@ -202,15 +202,13 @@ export function useProfileTimeline({ } const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) - const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ - read: [] as string[], - write: [] as string[] - })) - const feedRelayUrls = buildProfilePageReadRelayUrls( + const socialKinds = kinds.some(isSocialKindBlockedKind) + const emptyAuthor = { read: [] as string[], write: [] as string[] } + const provisionalFeedUrls = buildProfilePageReadRelayUrls( favoriteRelays, blockedRelays, - authorRl, - kinds.some(isSocialKindBlockedKind) + emptyAuthor, + socialKinds ) const startWave = async (subRequests: ReturnType) => { @@ -240,12 +238,31 @@ export function useProfileTimeline({ } } - if (feedRelayUrls.length === 0) { + if (provisionalFeedUrls.length === 0) { if (!cancelled) setIsLoading(false) return } - void startWave(buildSubRequests([feedRelayUrls], pubkey, kinds, limit, hasCalendarKinds)) + void startWave( + buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds) + ) + + void (async () => { + const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ + read: [] as string[], + write: [] as string[] + })) + if (cancelled) return + const fullFeedUrls = buildProfilePageReadRelayUrls( + favoriteRelays, + blockedRelays, + authorRl, + socialKinds + ) + const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) + if (cancelled || deltaUrls.length === 0) return + await startWave(buildSubRequests([deltaUrls], pubkey, kinds, limit, hasCalendarKinds)) + })() } void subscribe() diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx index e2ed484d..354393dd 100644 --- a/src/hooks/useQuoteEvents.tsx +++ b/src/hooks/useQuoteEvents.tsx @@ -11,6 +11,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' +import type { TSubRequestFilter } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' @@ -99,7 +100,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { const highlightKinds = [kinds.Highlights] as const const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT] - const subRequests: { urls: string[]; filter: Filter }[] = [ + const subRequests: { urls: string[]; filter: TSubRequestFilter }[] = [ { urls: finalRelayUrls, filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9e17bba0..c46dc431 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -813,6 +813,7 @@ export default { 'Nothing to load for this feed.': 'Nothing to load for this feed.', 'No posts loaded for this feed. Try refreshing.': 'No posts loaded for this feed. Try refreshing.', + 'Looking for more events…': 'Looking for more events…', 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.': 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.', 'Per-relay timeline results ({{count}} connections)': diff --git a/src/lib/profile-accordion-fetch.ts b/src/lib/profile-accordion-fetch.ts index f35ff96c..4875c612 100644 --- a/src/lib/profile-accordion-fetch.ts +++ b/src/lib/profile-accordion-fetch.ts @@ -365,3 +365,58 @@ export async function fetchProfileAccordionBundle(args: { export function profileAccordionBundleCacheKey(urls: string[]): string { return profileAccordionRelayUrlsKey(urls) } + +function badgeMergeKey(b: TProfileBadge): string { + return `${b.a}|${b.awardId}` +} + +/** Merge two accordion bundles (e.g. provisional relays + delta-only second fetch). */ +export function mergeProfileAccordionBundles( + base: ProfileAccordionBundle, + add: ProfileAccordionBundle +): ProfileAccordionBundle { + const zapByPr = new Map(base.zaps.map((z) => [z.pr, z])) + for (const z of add.zaps) { + if (!zapByPr.has(z.pr)) zapByPr.set(z.pr, z) + } + const zaps = [...zapByPr.values()].sort((a, b) => b.amount - a.amount) + + const reactionsByPubkey = new Map() + for (const e of base.reactions) { + reactionsByPubkey.set(e.pubkey, e) + } + for (const e of add.reactions) { + const prev = reactionsByPubkey.get(e.pubkey) + if (!prev || e.created_at > prev.created_at) reactionsByPubkey.set(e.pubkey, e) + } + const reactions = [...reactionsByPubkey.values()].sort((a, b) => b.created_at - a.created_at) + + const commentById = new Map(base.comments.map((c) => [c.id, c])) + for (const c of add.comments) { + if (!commentById.has(c.id)) commentById.set(c.id, c) + } + const comments = [...commentById.values()].sort((a, b) => b.created_at - a.created_at) + + const packByKey = new Map(base.followPacks.map((p) => [replaceableEventDedupeKey(p.event), p])) + for (const p of add.followPacks) { + const k = replaceableEventDedupeKey(p.event) + const prev = packByKey.get(k) + if (!prev || p.event.created_at > prev.event.created_at) packByKey.set(k, p) + } + const followPacks = [...packByKey.values()].sort((a, b) => b.event.created_at - a.event.created_at) + + const badgeByKey = new Map(base.badges.map((b) => [badgeMergeKey(b), b])) + for (const b of add.badges) { + const k = badgeMergeKey(b) + if (!badgeByKey.has(k)) badgeByKey.set(k, b) + } + const badges = [...badgeByKey.values()] + + const reportById = new Map(base.reports.map((r) => [r.id, r])) + for (const r of add.reports) { + if (!reportById.has(r.id)) reportById.set(r.id, r) + } + const reports = [...reportById.values()].sort((a, b) => b.created_at - a.created_at) + + return { zaps, reactions, comments, badges, followPacks, reports } +} diff --git a/src/lib/profile-relay-urls.ts b/src/lib/profile-relay-urls.ts index a42165f8..77ba3788 100644 --- a/src/lib/profile-relay-urls.ts +++ b/src/lib/profile-relay-urls.ts @@ -7,6 +7,24 @@ import { E_TAG_FILTER_BLOCKED_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/con import client from '@/services/client.service' import { normalizeUrl } from '@/lib/url' +/** + * Immediate relay stack before NIP-65 outboxes resolve (accordion / fast first paint). + */ +export function getProfileRelayUrlsProvisional(blockedRelays: string[] = []): string[] { + const blocked = new Set( + [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase()) + ) + const out: string[] = [] + const seen = new Set() + for (const u of PROFILE_FETCH_RELAY_URLS) { + const n = normalizeUrl(u) || u + if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) continue + seen.add(n) + out.push(n) + } + return out +} + export async function buildProfileRelayUrls( pubkey: string, blockedRelays: string[] = [] diff --git a/src/lib/url.ts b/src/lib/url.ts index ab5452ae..54b73723 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -441,3 +441,21 @@ export function rewritePlainTextHttpUrls( } }) } + +/** + * Relays in `full` whose normalized URL is not in `provisional` (by {@link normalizeUrl}), preserving first-seen order. + */ +export function subtractNormalizedRelayUrls(full: string[], provisional: string[]): string[] { + const prov = new Set( + provisional.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean) + ) + const seen = new Set() + const out: string[] = [] + for (const u of full) { + const n = normalizeUrl(u) || u.trim() + if (!n || prov.has(n) || seen.has(n)) continue + seen.add(n) + out.push(u) + } + return out +} diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index a75e1925..19741ed8 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -69,11 +69,29 @@ const FollowingFeed = forwardRef< return } - let followings: string[] = [] + const augment = (raw: TFeedSubRequest[]) => + augmentSubRequestsWithFavoritesFastReadAndInbox( + raw, + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { userWriteRelays: relayList?.write ?? [] } + ) + + const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + const provisionalAuthors = [...new Set([pubkey, ...fromTags])] + + try { + const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) + if (!cancelled) setSubRequests(augment(rawProv)) + } catch (error) { + logger.warn('[FollowingFeed] provisional generateSubRequestsForPubkeys failed', { error }) + } + + let followings: string[] = fromTags try { followings = await client.fetchFollowings(pubkey) } catch (error) { - // Failsafe: keep follows feed usable when contacts fetch relay calls fail transiently. followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] logger.warn('[FollowingFeed] fetchFollowings failed; using cached follow list fallback', { error, @@ -81,16 +99,17 @@ const FollowingFeed = forwardRef< }) } + const fullAuthors = [...new Set([pubkey, ...followings])] + const sameSize = fullAuthors.length === provisionalAuthors.length + const sameSet = + sameSize && fullAuthors.every((p) => provisionalAuthors.includes(p)) && provisionalAuthors.every((p) => fullAuthors.includes(p)) + if (sameSet) { + return + } + try { - const raw = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey) - const augmented = augmentSubRequestsWithFavoritesFastReadAndInbox( - raw, - favoriteRelays, - blockedRelays, - relayList?.read ?? [], - { userWriteRelays: relayList?.write ?? [] } - ) - if (!cancelled) setSubRequests(augmented) + const raw = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) + if (!cancelled) setSubRequests(augment(raw)) } catch (error) { logger.error('[FollowingFeed] generateSubRequestsForPubkeys failed', error) if (!cancelled) setSubRequests([]) @@ -115,6 +134,8 @@ const FollowingFeed = forwardRef< { if (relayUrls.length === 0) { - setRelayAlgoReady(false) + setAreAlgoRelays(false) return } let cancelled = false - setRelayAlgoReady(false) const init = async () => { const timeoutPromise = new Promise((_, reject) => { @@ -62,8 +60,6 @@ const RelaysFeed = forwardRef< setAreAlgoRelays(areAlgo) } catch (_error) { if (!cancelled) setAreAlgoRelays(false) - } finally { - if (!cancelled) setRelayAlgoReady(true) } } @@ -148,7 +144,6 @@ const RelaysFeed = forwardRef< ref={ref} subRequests={subRequests} areAlgoRelays={areAlgoRelays} - relayCapabilityReady={relayAlgoReady} isMainFeed setSubHeader={setSubHeader} onSubHeaderRefresh={onSubHeaderRefresh} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index fa21311c..5d67963a 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -51,6 +51,7 @@ import { FIRST_RELAY_RESULT_GRACE_MS, } from '@/constants' import { filterEventsExcludingTombstones, isUserInEventMentions } from '@/lib/event' +import { getPubkeysFromPTags } from '@/lib/tag' import { formatPubkey, normalizeHexPubkey } from '@/lib/pubkey' import { augmentSubRequestsWithFavoritesFastReadAndInbox, @@ -314,7 +315,15 @@ const SpellsPage = forwardRef(function SpellsPage( ) { const { t } = useTranslation() const { navigate: navigatePrimary } = usePrimaryPage() - const { pubkey, account, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr() + const { + pubkey, + account, + relayList, + attemptDelete, + bookmarkListEvent, + interestListEvent, + followListEvent + } = useNostr() const { addBookmark, removeBookmark } = useBookmarks() const { hideUntrustedNotifications } = useUserTrust() const { isSmallScreen } = useScreenSize() @@ -399,9 +408,7 @@ const SpellsPage = forwardRef(function SpellsPage( }, [spellProp, logSpellFeedPickerSelection]) const [followingSubRequests, setFollowingSubRequests] = useState([]) - const [followingFeedLoading, setFollowingFeedLoading] = useState(false) const [favoritesSubRequests, setFavoritesSubRequests] = useState([]) - const [favoritesFeedLoading, setFavoritesFeedLoading] = useState(false) const loadSpells = useCallback(async () => { const [events, ids] = await Promise.all([ @@ -733,7 +740,6 @@ const SpellsPage = forwardRef(function SpellsPage( useEffect(() => { if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) { setFollowingSubRequests([]) - setFollowingFeedLoading(false) return } @@ -744,18 +750,46 @@ const SpellsPage = forwardRef(function SpellsPage( if (followSetD && followSetCatalogLoading) { setFollowingSubRequests([]) - setFollowingFeedLoading(true) return } let cancelled = false - setFollowingFeedLoading(true) void (async () => { + const augment = (raw: TFeedSubRequest[]) => + augmentSubRequestsWithFavoritesFastReadAndInbox( + raw, + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { userWriteRelays: relayList?.write ?? [] } + ) try { - let authorPubkeys: string[] if (selectedFauxSpell === 'following') { - const followings = await client.fetchFollowings(pubkey) - authorPubkeys = [pubkey, ...followings] + const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + const provisionalAuthors = [...new Set([pubkey, ...fromTags])] + try { + const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) + if (!cancelled) setFollowingSubRequests(augment(rawProv)) + } catch { + /* refined wave may still succeed */ + } + + let followings = fromTags + try { + followings = await client.fetchFollowings(pubkey) + } catch { + followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + } + const fullAuthors = [...new Set([pubkey, ...followings])] + const sameSet = + fullAuthors.length === provisionalAuthors.length && + fullAuthors.every((p) => provisionalAuthors.includes(p)) && + provisionalAuthors.every((p) => fullAuthors.includes(p)) + if (sameSet) { + return + } + const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) + if (!cancelled) setFollowingSubRequests(augment(req)) } else if (followSetD) { const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) if (!ev) { @@ -763,25 +797,14 @@ const SpellsPage = forwardRef(function SpellsPage( return } const listed = pubkeysFromFollowSetEvent(ev) - authorPubkeys = [pubkey, ...listed] + const authorPubkeys = [pubkey, ...listed] + const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) + if (!cancelled) setFollowingSubRequests(augment(req)) } else { if (!cancelled) setFollowingSubRequests([]) - return } - - const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) - const merged = augmentSubRequestsWithFavoritesFastReadAndInbox( - req, - favoriteRelays, - blockedRelays, - relayList?.read ?? [], - { userWriteRelays: relayList?.write ?? [] } - ) - if (!cancelled) setFollowingSubRequests(merged) } catch { if (!cancelled) setFollowingSubRequests([]) - } finally { - if (!cancelled) setFollowingFeedLoading(false) } })() return () => { @@ -794,7 +817,8 @@ const SpellsPage = forwardRef(function SpellsPage( sortedBlockedRelaysKey, relayMailboxStableKey, followSetCatalogLoading, - followSetListStableKey + followSetListStableKey, + followListEvent?.id ]) const favoritesShowKinds = useMemo(() => { @@ -810,12 +834,10 @@ const SpellsPage = forwardRef(function SpellsPage( useEffect(() => { if (selectedFauxSpell !== 'favorites' || !pubkey) { setFavoritesSubRequests([]) - setFavoritesFeedLoading(false) return } let cancelled = false - setFavoritesFeedLoading(true) void (async () => { try { const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( @@ -841,6 +863,39 @@ const SpellsPage = forwardRef(function SpellsPage( reasonLabel: t('Added from your web bookmarks') })) + const augmentFollow = (raw: TFeedSubRequest[]) => + augmentSubRequestsWithFavoritesFastReadAndInbox( + raw, + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { userWriteRelays: relayList?.write ?? [] } + ).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') })) + + const quickFollowRaw = await client.generateSubRequestsForPubkeys([pubkey], pubkey) + const quickFollowAug = augmentFollow(quickFollowRaw) + const followsWebQuick: TFeedSubRequest[] = [ + { + urls: feedUrls, + filter: { + authors: [pubkey], + kinds: [ExtendedKind.WEB_BOOKMARK], + limit: FAUX_SPELL_EVENT_LIMIT + }, + reasonLabel: t('Added from follows web bookmarks') + } + ] + + if (!cancelled) { + setFavoritesSubRequests([ + ...interestReqs, + ...idReqs, + ...ownWebReqs, + ...followsWebQuick, + ...quickFollowAug + ]) + } + const authorSet = new Set([pubkey, ...contacts]) for (const ev of followSetListEvents) { if (ev.pubkey !== pubkey) continue @@ -851,13 +906,7 @@ const SpellsPage = forwardRef(function SpellsPage( const followAndContactReqs = authorPubkeys.length ? await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) : [] - const followAndContactAugmented = augmentSubRequestsWithFavoritesFastReadAndInbox( - followAndContactReqs, - favoriteRelays, - blockedRelays, - relayList?.read ?? [], - { userWriteRelays: relayList?.write ?? [] } - ).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') })) + const followAndContactAugmented = augmentFollow(followAndContactReqs) const followsWebBookmarkReqs: TFeedSubRequest[] = authorPubkeys.length ? [ @@ -884,8 +933,6 @@ const SpellsPage = forwardRef(function SpellsPage( } } catch { if (!cancelled) setFavoritesSubRequests([]) - } finally { - if (!cancelled) setFavoritesFeedLoading(false) } })() @@ -1337,13 +1384,11 @@ const SpellsPage = forwardRef(function SpellsPage( return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) - const showAsyncFauxFeedLoading = !!( - pubkey && - selectedFauxSpell && - (selectedFauxSpell === 'favorites' - ? favoritesFeedLoading - : isFollowFeedFauxSpellId(selectedFauxSpell) && - (followingFeedLoading || (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading))) + const spellFauxMergeTimeline = useMemo( + () => + selectedFauxSpell === 'favorites' || + (!!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)), + [selectedFauxSpell] ) const spellStarAddTitle = t('Spell star add title') @@ -1788,8 +1833,6 @@ const SpellsPage = forwardRef(function SpellsPage(
{t('Please login to view favorites')}
- ) : showAsyncFauxFeedLoading ? ( -
{t('loading...')}
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
{fauxFeedEmptyMessage}
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( @@ -1808,6 +1851,8 @@ const SpellsPage = forwardRef(function SpellsPage( subRequests={subRequests} feedSubscriptionKey={spellFeedSubscriptionKey} hostPrimaryPageName="spells" + preserveTimelineOnSubRequestsChange={spellFauxMergeTimeline} + mergeTimelineWhenSubRequestFiltersMatch={spellFauxMergeTimeline} showKinds={ selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds }