Browse Source

speed up feeds

imwald
Silberengel 4 weeks ago
parent
commit
ebb7b4ad60
  1. 21
      src/components/NoteList/index.tsx
  2. 2
      src/constants.ts
  3. 77
      src/hooks/useProfileAccordionData.tsx
  4. 11
      src/hooks/useProfileRelayUrls.tsx
  5. 37
      src/hooks/useProfileTimeline.tsx
  6. 3
      src/hooks/useQuoteEvents.tsx
  7. 1
      src/i18n/locales/en.ts
  8. 55
      src/lib/profile-accordion-fetch.ts
  9. 18
      src/lib/profile-relay-urls.ts
  10. 18
      src/lib/url.ts
  11. 43
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  12. 7
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  13. 133
      src/pages/primary/SpellsPage/index.tsx

21
src/components/NoteList/index.tsx

@ -1531,6 +1531,9 @@ const NoteList = forwardRef(
? ALGO_LIMIT ? ALGO_LIMIT
: LIMIT : LIMIT
// New REQ wave (incl. delta relays with same feed key): outcomes stay stale until this wave ends.
setFeedSubscribeRelayOutcomes([])
timelineSubscribePromise = client.subscribeTimeline( timelineSubscribePromise = client.subscribeTimeline(
mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>, mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{ {
@ -2528,6 +2531,23 @@ const NoteList = forwardRef(
const listSourceEvents = timelineEventsForFilter const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null const feedFullSearchActive = feedFullSearchEvents !== null
const showRelaySubscribeWavePendingBanner =
!oneShotFetch &&
!feedFullSearchActive &&
subRequests.length > 0 &&
relayCapabilityReady &&
timelineKey != null &&
feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady
const relayWavePendingBannerEl = showRelaySubscribeWavePendingBanner ? (
<div
className="mb-2 rounded border border-border/40 bg-muted/15 px-3 py-1.5 text-center text-xs text-muted-foreground"
role="status"
aria-live="polite"
>
{t('Looking for more events…')}
</div>
) : null
const eventReasonLabelMap = useMemo(() => { const eventReasonLabelMap = useMemo(() => {
const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0) const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0)
if (!reqs.length || !clientFilteredEvents.length) return new Map<string, string>() if (!reqs.length || !clientFilteredEvents.length) return new Map<string, string>()
@ -2548,6 +2568,7 @@ const NoteList = forwardRef(
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{relayWavePendingBannerEl}
{feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? ( {feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground"> <div className="px-2 py-8 text-center text-sm text-muted-foreground">
{t('No loaded posts match your filters.')} {t('No loaded posts match your filters.')}

2
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 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. */ /** 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. */ /** 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 export const SPELL_FEED_LOADING_MAX_MS = 1000

77
src/hooks/useProfileAccordionData.tsx

@ -1,5 +1,6 @@
import { import {
fetchProfileAccordionBundle, fetchProfileAccordionBundle,
mergeProfileAccordionBundles,
profileAccordionBundleCacheKey, profileAccordionBundleCacheKey,
type ProfileAccordionBundle type ProfileAccordionBundle
} from '@/lib/profile-accordion-fetch' } from '@/lib/profile-accordion-fetch'
@ -7,10 +8,16 @@ import {
profileAccordionGetCachedBadges, profileAccordionGetCachedBadges,
profileAccordionGetCachedFollowPacks, profileAccordionGetCachedFollowPacks,
profileAccordionGetCachedInteractions, profileAccordionGetCachedInteractions,
profileAccordionGetCachedReports profileAccordionGetCachedReports,
profileAccordionRelayUrlsKey,
profileAccordionSetBadges,
profileAccordionSetFollowPacks,
profileAccordionSetInteractions,
profileAccordionSetReports
} from '@/lib/profile-accordion-session-cache' } from '@/lib/profile-accordion-session-cache'
import { subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 = { const EMPTY: ProfileAccordionBundle = {
zaps: [], zaps: [],
@ -59,12 +66,17 @@ export function useProfileAccordionData(opts: {
const [data, setData] = useState<ProfileAccordionBundle>(EMPTY) const [data, setData] = useState<ProfileAccordionBundle>(EMPTY)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const reqId = useRef(0) const reqId = useRef(0)
const lastSuccessfulRelayUrlsRef = useRef<string[]>([])
const relayKey = useMemo( const relayKey = useMemo(
() => profileAccordionBundleCacheKey(relayUrls ?? []), () => profileAccordionBundleCacheKey(relayUrls ?? []),
[relayUrls] [relayUrls]
) )
useEffect(() => {
lastSuccessfulRelayUrlsRef.current = []
}, [pubkey])
const runFetch = useCallback( const runFetch = useCallback(
async (force: boolean, overrideUrls?: string[]) => { async (force: boolean, overrideUrls?: string[]) => {
const urls = (overrideUrls?.length ? overrideUrls : relayUrls) ?? [] const urls = (overrideUrls?.length ? overrideUrls : relayUrls) ?? []
@ -86,6 +98,7 @@ export function useProfileAccordionData(opts: {
}) })
if (id !== reqId.current) return if (id !== reqId.current) return
setData(bundle) setData(bundle)
lastSuccessfulRelayUrlsRef.current = urls
} finally { } finally {
if (id === reqId.current) setLoading(false) if (id === reqId.current) setLoading(false)
} }
@ -93,6 +106,46 @@ export function useProfileAccordionData(opts: {
[pubkey, relayUrls, viewerPubkey, favoriteRelays, blockedRelays] [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( const refresh = useCallback(
(overrideUrls?: string[]) => { (overrideUrls?: string[]) => {
void runFetch(true, overrideUrls) void runFetch(true, overrideUrls)
@ -109,11 +162,29 @@ export function useProfileAccordionData(opts: {
if (cached) { if (cached) {
setData(cached) setData(cached)
setLoading(false) setLoading(false)
lastSuccessfulRelayUrlsRef.current = relayUrls
return 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) setLoading(true)
void runFetch(false) void runFetch(false)
}, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch]) }, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch, runMergeFetch])
return { return {
...data, ...data,

11
src/hooks/useProfileRelayUrls.tsx

@ -3,7 +3,7 @@ import {
profileAccordionRelayUrlsKey, profileAccordionRelayUrlsKey,
profileAccordionSetRelayUrls profileAccordionSetRelayUrls
} from '@/lib/profile-accordion-session-cache' } 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 { useCallback, useEffect, useRef, useState } from 'react'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 const revalidateWithVisibleUrls = force && relayUrlsRef.current.length > 0
if (!revalidateWithVisibleUrls) { if (!revalidateWithVisibleUrls) {
if (provisional.length > 0) {
profileAccordionSetRelayUrls(pubkey, provisional)
setRelayUrls(provisional)
setLoading(false)
} else {
setLoading(true)
}
} else {
setLoading(true) setLoading(true)
} }
try { try {

37
src/hooks/useProfileTimeline.tsx

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
type ProfileTimelineMemoryEntry = { type ProfileTimelineMemoryEntry = {
@ -202,15 +202,13 @@ export function useProfileTimeline({
} }
const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k))
const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ const socialKinds = kinds.some(isSocialKindBlockedKind)
read: [] as string[], const emptyAuthor = { read: [] as string[], write: [] as string[] }
write: [] as string[] const provisionalFeedUrls = buildProfilePageReadRelayUrls(
}))
const feedRelayUrls = buildProfilePageReadRelayUrls(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
authorRl, emptyAuthor,
kinds.some(isSocialKindBlockedKind) socialKinds
) )
const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => { const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => {
@ -240,12 +238,31 @@ export function useProfileTimeline({
} }
} }
if (feedRelayUrls.length === 0) { if (provisionalFeedUrls.length === 0) {
if (!cancelled) setIsLoading(false) if (!cancelled) setIsLoading(false)
return 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() void subscribe()

3
src/hooks/useQuoteEvents.tsx

@ -11,6 +11,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import type { TSubRequestFilter } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' 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 highlightKinds = [kinds.Highlights] as const
const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT] const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT]
const subRequests: { urls: string[]; filter: Filter }[] = [ const subRequests: { urls: string[]; filter: TSubRequestFilter }[] = [
{ {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT }

1
src/i18n/locales/en.ts

@ -813,6 +813,7 @@ export default {
'Nothing to load for this feed.': 'Nothing to load for this feed.', '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.':
'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.':
'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)': 'Per-relay timeline results ({{count}} connections)':

55
src/lib/profile-accordion-fetch.ts

@ -365,3 +365,58 @@ export async function fetchProfileAccordionBundle(args: {
export function profileAccordionBundleCacheKey(urls: string[]): string { export function profileAccordionBundleCacheKey(urls: string[]): string {
return profileAccordionRelayUrlsKey(urls) 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<string, Event>()
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 }
}

18
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 client from '@/services/client.service'
import { normalizeUrl } from '@/lib/url' 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<string>()
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( export async function buildProfileRelayUrls(
pubkey: string, pubkey: string,
blockedRelays: string[] = [] blockedRelays: string[] = []

18
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<string>()
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
}

43
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -69,11 +69,29 @@ const FollowingFeed = forwardRef<
return 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 { try {
followings = await client.fetchFollowings(pubkey) followings = await client.fetchFollowings(pubkey)
} catch (error) { } catch (error) {
// Failsafe: keep follows feed usable when contacts fetch relay calls fail transiently.
followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
logger.warn('[FollowingFeed] fetchFollowings failed; using cached follow list fallback', { logger.warn('[FollowingFeed] fetchFollowings failed; using cached follow list fallback', {
error, 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 { try {
const raw = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey) const raw = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
const augmented = augmentSubRequestsWithFavoritesFastReadAndInbox( if (!cancelled) setSubRequests(augment(raw))
raw,
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
)
if (!cancelled) setSubRequests(augmented)
} catch (error) { } catch (error) {
logger.error('[FollowingFeed] generateSubRequestsForPubkeys failed', error) logger.error('[FollowingFeed] generateSubRequestsForPubkeys failed', error)
if (!cancelled) setSubRequests([]) if (!cancelled) setSubRequests([])
@ -115,6 +134,8 @@ const FollowingFeed = forwardRef<
<NormalFeed <NormalFeed
ref={ref} ref={ref}
subRequests={subRequests} subRequests={subRequests}
preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch
isMainFeed isMainFeed
setSubHeader={setSubHeader} setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh} onSubHeaderRefresh={onSubHeaderRefresh}

7
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -23,7 +23,6 @@ const RelaysFeed = forwardRef<
const { feedInfo, relayUrls } = useFeed() const { feedInfo, relayUrls } = useFeed()
const { showKinds } = useKindFilterOrDefaults() const { showKinds } = useKindFilterOrDefaults()
const [areAlgoRelays, setAreAlgoRelays] = useState(false) const [areAlgoRelays, setAreAlgoRelays] = useState(false)
const [relayAlgoReady, setRelayAlgoReady] = useState(false)
/** After kindless single-relay REQ EOSEs with no events, re-subscribe with the normal kind list. */ /** After kindless single-relay REQ EOSEs with no events, re-subscribe with the normal kind list. */
const [singleRelayKindFallback, setSingleRelayKindFallback] = useState(false) const [singleRelayKindFallback, setSingleRelayKindFallback] = useState(false)
@ -39,11 +38,10 @@ const RelaysFeed = forwardRef<
useEffect(() => { useEffect(() => {
if (relayUrls.length === 0) { if (relayUrls.length === 0) {
setRelayAlgoReady(false) setAreAlgoRelays(false)
return return
} }
let cancelled = false let cancelled = false
setRelayAlgoReady(false)
const init = async () => { const init = async () => {
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
@ -62,8 +60,6 @@ const RelaysFeed = forwardRef<
setAreAlgoRelays(areAlgo) setAreAlgoRelays(areAlgo)
} catch (_error) { } catch (_error) {
if (!cancelled) setAreAlgoRelays(false) if (!cancelled) setAreAlgoRelays(false)
} finally {
if (!cancelled) setRelayAlgoReady(true)
} }
} }
@ -148,7 +144,6 @@ const RelaysFeed = forwardRef<
ref={ref} ref={ref}
subRequests={subRequests} subRequests={subRequests}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
relayCapabilityReady={relayAlgoReady}
isMainFeed isMainFeed
setSubHeader={setSubHeader} setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh} onSubHeaderRefresh={onSubHeaderRefresh}

133
src/pages/primary/SpellsPage/index.tsx

@ -51,6 +51,7 @@ import {
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
} from '@/constants' } from '@/constants'
import { filterEventsExcludingTombstones, isUserInEventMentions } from '@/lib/event' import { filterEventsExcludingTombstones, isUserInEventMentions } from '@/lib/event'
import { getPubkeysFromPTags } from '@/lib/tag'
import { formatPubkey, normalizeHexPubkey } from '@/lib/pubkey' import { formatPubkey, normalizeHexPubkey } from '@/lib/pubkey'
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
@ -314,7 +315,15 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
) { ) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate: navigatePrimary } = usePrimaryPage() 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 { addBookmark, removeBookmark } = useBookmarks()
const { hideUntrustedNotifications } = useUserTrust() const { hideUntrustedNotifications } = useUserTrust()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -399,9 +408,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [spellProp, logSpellFeedPickerSelection]) }, [spellProp, logSpellFeedPickerSelection])
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([]) const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const [followingFeedLoading, setFollowingFeedLoading] = useState(false)
const [favoritesSubRequests, setFavoritesSubRequests] = useState<TFeedSubRequest[]>([]) const [favoritesSubRequests, setFavoritesSubRequests] = useState<TFeedSubRequest[]>([])
const [favoritesFeedLoading, setFavoritesFeedLoading] = useState(false)
const loadSpells = useCallback(async () => { const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([ const [events, ids] = await Promise.all([
@ -733,7 +740,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
useEffect(() => { useEffect(() => {
if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) { if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) {
setFollowingSubRequests([]) setFollowingSubRequests([])
setFollowingFeedLoading(false)
return return
} }
@ -744,18 +750,46 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (followSetD && followSetCatalogLoading) { if (followSetD && followSetCatalogLoading) {
setFollowingSubRequests([]) setFollowingSubRequests([])
setFollowingFeedLoading(true)
return return
} }
let cancelled = false let cancelled = false
setFollowingFeedLoading(true)
void (async () => { void (async () => {
const augment = (raw: TFeedSubRequest[]) =>
augmentSubRequestsWithFavoritesFastReadAndInbox(
raw,
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
)
try { try {
let authorPubkeys: string[]
if (selectedFauxSpell === 'following') { if (selectedFauxSpell === 'following') {
const followings = await client.fetchFollowings(pubkey) const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
authorPubkeys = [pubkey, ...followings] 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) { } else if (followSetD) {
const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD)
if (!ev) { if (!ev) {
@ -763,25 +797,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return return
} }
const listed = pubkeysFromFollowSetEvent(ev) const listed = pubkeysFromFollowSetEvent(ev)
authorPubkeys = [pubkey, ...listed] const authorPubkeys = [pubkey, ...listed]
const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
} else { } else {
if (!cancelled) setFollowingSubRequests([]) 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 { } catch {
if (!cancelled) setFollowingSubRequests([]) if (!cancelled) setFollowingSubRequests([])
} finally {
if (!cancelled) setFollowingFeedLoading(false)
} }
})() })()
return () => { return () => {
@ -794,7 +817,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
sortedBlockedRelaysKey, sortedBlockedRelaysKey,
relayMailboxStableKey, relayMailboxStableKey,
followSetCatalogLoading, followSetCatalogLoading,
followSetListStableKey followSetListStableKey,
followListEvent?.id
]) ])
const favoritesShowKinds = useMemo(() => { const favoritesShowKinds = useMemo(() => {
@ -810,12 +834,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
useEffect(() => { useEffect(() => {
if (selectedFauxSpell !== 'favorites' || !pubkey) { if (selectedFauxSpell !== 'favorites' || !pubkey) {
setFavoritesSubRequests([]) setFavoritesSubRequests([])
setFavoritesFeedLoading(false)
return return
} }
let cancelled = false let cancelled = false
setFavoritesFeedLoading(true)
void (async () => { void (async () => {
try { try {
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
@ -841,6 +863,39 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
reasonLabel: t('Added from your web bookmarks') 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<string>([pubkey, ...contacts]) const authorSet = new Set<string>([pubkey, ...contacts])
for (const ev of followSetListEvents) { for (const ev of followSetListEvents) {
if (ev.pubkey !== pubkey) continue if (ev.pubkey !== pubkey) continue
@ -851,13 +906,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const followAndContactReqs = authorPubkeys.length const followAndContactReqs = authorPubkeys.length
? await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) ? await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey)
: [] : []
const followAndContactAugmented = augmentSubRequestsWithFavoritesFastReadAndInbox( const followAndContactAugmented = augmentFollow(followAndContactReqs)
followAndContactReqs,
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') }))
const followsWebBookmarkReqs: TFeedSubRequest[] = authorPubkeys.length const followsWebBookmarkReqs: TFeedSubRequest[] = authorPubkeys.length
? [ ? [
@ -884,8 +933,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} }
} catch { } catch {
if (!cancelled) setFavoritesSubRequests([]) if (!cancelled) setFavoritesSubRequests([])
} finally {
if (!cancelled) setFavoritesFeedLoading(false)
} }
})() })()
@ -1337,13 +1384,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return t('Nothing to load for this feed.') return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t]) }, [selectedFauxSpell, fauxSubRequests.length, t])
const showAsyncFauxFeedLoading = !!( const spellFauxMergeTimeline = useMemo(
pubkey && () =>
selectedFauxSpell && selectedFauxSpell === 'favorites' ||
(selectedFauxSpell === 'favorites' (!!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)),
? favoritesFeedLoading [selectedFauxSpell]
: isFollowFeedFauxSpellId(selectedFauxSpell) &&
(followingFeedLoading || (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading)))
) )
const spellStarAddTitle = t('Spell star add title') const spellStarAddTitle = t('Spell star add title')
@ -1788,8 +1833,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
{t('Please login to view favorites')} {t('Please login to view favorites')}
</div> </div>
) : showAsyncFauxFeedLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t('loading...')}</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div> <div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length > 0 ? (
@ -1808,6 +1851,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey} feedSubscriptionKey={spellFeedSubscriptionKey}
hostPrimaryPageName="spells" hostPrimaryPageName="spells"
preserveTimelineOnSubRequestsChange={spellFauxMergeTimeline}
mergeTimelineWhenSubRequestFiltersMatch={spellFauxMergeTimeline}
showKinds={ showKinds={
selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds
} }

Loading…
Cancel
Save