diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 0fa95ffd..816deca8 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -78,6 +78,10 @@ import type { TPrimaryPageName } from '@/PageManager' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays' +import { + getProfileAuthorWarmupRelayUrls, + getProfileAuthorWarmupSpec +} from '@/lib/profile-author-warmup-spec' import type { TProfile } from '@/types' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -592,36 +596,6 @@ function tightestSinceFromSpellFilters(shardFilters: Filter[]): number | undefin return sinceCandidates.length > 0 ? Math.max(...sinceCandidates) : undefined } -/** - * Profile Posts / Media feeds shard by relay but share one author + kinds REQ. Session + IDB author scans are keyed - * only on that author/kinds pair. Timeline rows may live under per-shard persist keys; profile async warmup merges - * {@link ClientService.getTimelineDiskSnapshotEvents} with the author archive scan so both layers paint together. - */ -function getProfileSingleAuthorWarmupSpec( - mapped: Array<{ urls: string[]; filter: TSubRequestFilter }> -): { author: string; kinds: number[] } | null { - if (mapped.length === 0) return null - let normAuthor: string | null = null - const kindUnion = new Set() - for (const { filter: f } of mapped) { - const authors = Array.isArray(f.authors) ? f.authors : undefined - if (!authors || authors.length !== 1) return null - let pk: string - try { - pk = normalizeHexPubkey(authors[0]) - } catch { - return null - } - if (normAuthor === null) normAuthor = pk - else if (normAuthor !== pk) return null - const ks = Array.isArray(f.kinds) ? f.kinds : undefined - if (!ks || ks.length === 0) return null - for (const k of ks) kindUnion.add(k) - } - if (normAuthor === null) return null - return { author: normAuthor, kinds: Array.from(kindUnion).sort((a, b) => a - b) } -} - /** Union of `filter.kinds` across mapped REQ shards; empty if any shard omits kinds (caller should not use fallback). */ function filterEvsToMappedTimelineReqKinds( evs: Event[], @@ -2397,9 +2371,11 @@ const NoteList = forwardRef( } })() } else { - const profileAuthorWarmSpec = getProfileSingleAuthorWarmupSpec( - mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> - ) + const profileMapped = mappedSubRequests as Array<{ + urls: string[] + filter: TSubRequestFilter + }> + const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped) if ( hostPrimaryPageName === 'profile' && profileAuthorWarmSpec && @@ -2477,6 +2453,44 @@ const NoteList = forwardRef( setFeedEmptyToastGateTick((n) => n + 1) setFeedTimelineEmptyUiReady(true) } + + const relayUrls = getProfileAuthorWarmupRelayUrls(profileMapped) + if (relayUrls.length > 0) { + const fetched = await client.fetchEvents( + relayUrls, + { + authors: [profileAuthorWarmSpec.author], + kinds: profileAuthorWarmSpec.kinds, + limit: 200 + }, + { + cache: true, + eoseTimeout: 4500, + globalTimeout: 18_000, + replaceableRace: true + } + ) + if (!effectActive || timelineEffectStale()) return + if (fetched.length === 0) return + const narrowedFetch = narrowLiveBatch(fetched) + if (narrowedFetch.length === 0) return + setEvents((prev) => { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + timelineMergeBootstrapRef.current = merged.slice() + } + lastEventsForTimelinePrefetchRef.current = merged + return merged + }) + feedRelayReturnedAnyEventRef.current = true + if (!feedPaintLiveRelayDoneRef.current) { + setLoading(false) + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } + } } catch { /* profile local archive is best-effort */ } @@ -3614,7 +3628,26 @@ const NoteList = forwardRef( publicReadFallbackAttemptedRef.current = true - const filter: Filter = { ...(mapped[0]!.filter as Filter) } + const profileWarm = + hostPrimaryPageNameRef.current === 'profile' + ? getProfileAuthorWarmupSpec( + mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + : null + const profileRelayUrls = + profileWarm != null + ? getProfileAuthorWarmupRelayUrls( + mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + : [] + + const filter: Filter = profileWarm + ? { + authors: [profileWarm.author], + kinds: profileWarm.kinds, + limit: LIMIT + } + : { ...(mapped[0]!.filter as Filter) } if (!filter.kinds?.length) { filter.kinds = effectiveShowKinds.length > 0 ? [...effectiveShowKinds] : [kinds.ShortTextNote] } @@ -3626,9 +3659,12 @@ const NoteList = forwardRef( ? ALGO_LIMIT : LIMIT + const fallbackRelays = + profileRelayUrls.length > 0 ? profileRelayUrls : FAST_READ_RELAY_URLS + void (async () => { try { - const raw = await client.fetchEvents(FAST_READ_RELAY_URLS, filter, { + const raw = await client.fetchEvents(fallbackRelays, filter, { cache: true, globalTimeout: 22_000, eoseTimeout: 3500, diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 4b70f247..b74d1c93 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -72,8 +72,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R const repost = createRepostDraftEvent(event) const evt = await publish(repost, { - addClientTag: storage.getAddClientTag(), - companionSourceEvent: event + addClientTag: storage.getAddClientTag() }) // Show publishing feedback diff --git a/src/lib/companion-publish.test.ts b/src/lib/companion-publish.test.ts deleted file mode 100644 index 9f27e0d1..00000000 --- a/src/lib/companion-publish.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { kinds, nip19 } from 'nostr-tools' -import { - COMPANION_PUBLISH_CAP, - collectCompanionRefsInPublishOrder -} from './companion-publish' - -const HEX_A = 'a'.repeat(64) -const HEX_B = 'b'.repeat(64) -const HEX_C = 'c'.repeat(64) -const HEX_D = 'd'.repeat(64) -const HEX_PUB = 'e'.repeat(64) - -describe('collectCompanionRefsInPublishOrder', () => { - it('orders embedded before q before a before e', () => { - const note1 = nip19.noteEncode(HEX_B) - const ev = { - id: HEX_A, - kind: kinds.ShortTextNote, - content: `see nostr:${note1}`, - tags: [ - ['q', HEX_B], - ['a', `30023:${HEX_PUB}:doc`, HEX_C], - ['e', HEX_D] - ], - pubkey: HEX_PUB, - created_at: 1, - sig: 's'.repeat(128) - } - - const refs = collectCompanionRefsInPublishOrder(ev as never) - const tiers = refs.map((r) => r.tier) - const firstEmbedded = tiers.indexOf('embedded') - const firstQ = tiers.indexOf('q') - const firstA = tiers.indexOf('a') - const firstE = tiers.indexOf('e') - - expect(firstEmbedded).toBeGreaterThanOrEqual(0) - expect(firstQ).toBeGreaterThan(firstEmbedded) - expect(firstA).toBeGreaterThan(firstQ) - expect(firstE).toBeGreaterThan(firstA) - }) -}) - -describe('COMPANION_PUBLISH_CAP', () => { - it('is 5', () => { - expect(COMPANION_PUBLISH_CAP).toBe(5) - }) -}) diff --git a/src/lib/companion-publish.ts b/src/lib/companion-publish.ts deleted file mode 100644 index 83703ec6..00000000 --- a/src/lib/companion-publish.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns' -import { relayHintWssUrlsFromEvent } from '@/lib/event' -import { findTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json' -import client from '@/services/client.service' -import type { TPublishEventExtras } from '@/types' -import { nip19, type Event } from 'nostr-tools' - -export const COMPANION_PUBLISH_CAP = 5 - -type CompanionRef = - | { tier: 'embedded'; hexId: string } - | { tier: 'embedded'; nip19: string } - | { tier: 'embedded'; inline: Event } - | { tier: 'q'; hexId: string } - | { tier: 'q'; coordinate: string } - | { tier: 'a'; hexId: string } - | { tier: 'a'; coordinate: string } - | { tier: 'e'; hexId: string } - -function normalizeHex(id: string | undefined): string | undefined { - if (!id) return undefined - const t = id.trim().toLowerCase() - return /^[0-9a-f]{64}$/.test(t) ? t : undefined -} - -function parseQOrATagValue(raw: string | undefined): { hexId?: string; coordinate?: string } | undefined { - if (raw == null) return undefined - let s0 = raw.trim() - if (s0.toLowerCase().startsWith('nostr:')) s0 = s0.slice(6).trim() - if (!s0) return undefined - - const hex = normalizeHex(s0) - if (hex) return { hexId: hex } - - const coordMatch = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(s0) - if (coordMatch) { - return { - coordinate: `${Number(coordMatch[1])}:${coordMatch[2].toLowerCase()}:${coordMatch[3]}` - } - } - - if (/^n(?:ote|event|addr)1/i.test(s0)) { - try { - const { type, data } = nip19.decode(s0) - if (type === 'note') return { hexId: normalizeHex(typeof data === 'string' ? data : (data as { id?: string }).id) } - if (type === 'nevent') return { hexId: normalizeHex((data as { id: string }).id) } - if (type === 'naddr') { - const d = data as { kind: number; pubkey: string; identifier: string } - return { - coordinate: `${d.kind}:${d.pubkey.toLowerCase()}:${d.identifier ?? ''}` - } - } - } catch { - /* ignore */ - } - } - - return undefined -} - -function collectEmbeddedRefsFromContent(ev: Event, out: CompanionRef[]): void { - for (const full of ev.content.match(EMBEDDED_EVENT_REGEX) ?? []) { - const colon = full.indexOf(':') - if (colon < 0) continue - const bech32 = full.slice(colon + 1) - try { - const { type, data } = nip19.decode(bech32) - if (type === 'note') { - const hex = normalizeHex(typeof data === 'string' ? data : (data as { id?: string }).id) - if (hex) out.push({ tier: 'embedded', hexId: hex }) - } else if (type === 'nevent') { - const hex = normalizeHex((data as { id: string }).id) - if (hex) out.push({ tier: 'embedded', hexId: hex }) - } else if (type === 'naddr') { - out.push({ tier: 'embedded', nip19: bech32 }) - } - } catch { - /* ignore */ - } - } - - const trailing = findTrailingStringifiedNostrEvent(ev.content) - if (trailing) { - out.push({ tier: 'embedded', inline: trailing.event }) - collectEmbeddedRefsFromContent(trailing.event, out) - } -} - -/** Ordered refs: embedded (content + trailing JSON), then all `q`, then `a`, then `e`. */ -export function collectCompanionRefsInPublishOrder(event: Event): CompanionRef[] { - const embedded: CompanionRef[] = [] - const qRefs: CompanionRef[] = [] - const aRefs: CompanionRef[] = [] - const eRefs: CompanionRef[] = [] - - collectEmbeddedRefsFromContent(event, embedded) - - for (const tag of event.tags) { - const name = tag[0] - if (name === 'q' || name === 'Q') { - const parsed = parseQOrATagValue(tag[1]) - if (parsed?.hexId) qRefs.push({ tier: 'q', hexId: parsed.hexId }) - else if (parsed?.coordinate) qRefs.push({ tier: 'q', coordinate: parsed.coordinate }) - continue - } - if (name === 'a' || name === 'A') { - const snap = normalizeHex(tag[3]) - if (snap) { - aRefs.push({ tier: 'a', hexId: snap }) - continue - } - const parsed = parseQOrATagValue(tag[1]) - if (parsed?.hexId) aRefs.push({ tier: 'a', hexId: parsed.hexId }) - else if (parsed?.coordinate) aRefs.push({ tier: 'a', coordinate: parsed.coordinate }) - continue - } - if (name === 'e' || name === 'E') { - const hex = normalizeHex(tag[1]) - if (hex) eRefs.push({ tier: 'e', hexId: hex }) - } - } - - return [...embedded, ...qRefs, ...aRefs, ...eRefs] -} - -/** - * Resolve referenced events for companion republish (boost target, quotes, replies with embeds). - * Order preserved: embedded → q → a → e; capped at {@link COMPANION_PUBLISH_CAP}. - */ -export async function resolveCompanionEventsForPublish( - source: Event, - opts?: { excludeIds?: string[] } -): Promise { - const exclude = new Set( - [source.id, ...(opts?.excludeIds ?? [])].map((id) => id.trim().toLowerCase()).filter(Boolean) - ) - const relayHints = relayHintWssUrlsFromEvent(source) - const fetchOpts = relayHints.length > 0 ? { relayHints } : undefined - - const refs = collectCompanionRefsInPublishOrder(source) - const resolved: Event[] = [] - const seen = new Set() - - const tryAdd = (ev: Event | undefined) => { - if (!ev) return false - const k = ev.id.toLowerCase() - if (exclude.has(k) || seen.has(k)) return false - seen.add(k) - resolved.push(ev) - return resolved.length >= COMPANION_PUBLISH_CAP - } - - const resolveRef = async (ref: CompanionRef): Promise => { - if ('inline' in ref) return ref.inline - if ('nip19' in ref) { - try { - return await client.fetchEvent(ref.nip19, fetchOpts) - } catch { - return undefined - } - } - if ('coordinate' in ref) { - try { - return await client.fetchEvent(ref.coordinate, fetchOpts) - } catch { - return undefined - } - } - if ('hexId' in ref) { - try { - return await client.fetchEvent(ref.hexId, fetchOpts) - } catch { - return undefined - } - } - return undefined - } - - for (const ref of refs) { - if (resolved.length >= COMPANION_PUBLISH_CAP) break - if ('inline' in ref) { - if (tryAdd(ref.inline)) break - continue - } - if ('hexId' in ref) { - const k = ref.hexId - if (exclude.has(k) || seen.has(k)) continue - } - const ev = await resolveRef(ref) - if (tryAdd(ev)) break - } - - return resolved -} - -/** Fire-and-forget friendly: publish companions to the same relays; never throws. */ -export async function publishCompanionEventsBestEffort( - relayUrls: string[], - companions: readonly Event[], - extras: TPublishEventExtras -): Promise { - if (!relayUrls.length || !companions.length) return - - for (const companion of companions) { - try { - await client.publishEvent(relayUrls, companion, { - ...extras, - publishBatchLabel: 'companion' - }) - } catch { - /* best-effort */ - } - } -} diff --git a/src/lib/nostr-event-json.test.ts b/src/lib/nostr-event-json.test.ts index 721343cf..12c987dd 100644 --- a/src/lib/nostr-event-json.test.ts +++ b/src/lib/nostr-event-json.test.ts @@ -50,4 +50,9 @@ describe('nostr event JSON helpers', () => { expect(findTrailingStringifiedNostrEvent(content)).toBeNull() expect(stripTrailingStringifiedNostrEvent(content)).toBe(content) }) + + it('returns quickly when content has many braces but no trailing event', () => { + const content = 'x'.repeat(20_000) + '{'.repeat(10_000) + '}' + expect(findTrailingStringifiedNostrEvent(content)).toBeNull() + }) }) diff --git a/src/lib/nostr-event-json.ts b/src/lib/nostr-event-json.ts index 6a5c942e..8fc1f125 100644 --- a/src/lib/nostr-event-json.ts +++ b/src/lib/nostr-event-json.ts @@ -44,35 +44,47 @@ function parseNostrEventJson(raw: string): Event | null { } } +/** Only scan the tail — trailing serialized events are never megabytes into the body. */ +const MAX_TRAILING_SCAN_LEN = 256 * 1024 +/** Profile metadata and prose can contain many `{`; cap work per call. */ +const MAX_BRACE_ITERATIONS = 64 + /** * Some clients append a full serialized event after quote/repost text. Treat a trailing event JSON * object as structured data instead of showing it as prose. */ export function findTrailingStringifiedNostrEvent(content: string): StringifiedNostrEventMatch | null { const trimmed = content.trimEnd() - if (!trimmed) return null + if (!trimmed || !trimmed.endsWith('}')) return null + + const windowStart = Math.max(0, trimmed.length - MAX_TRAILING_SCAN_LEN) + const window = trimmed.slice(windowStart) + const windowOffset = windowStart - const whole = parseNostrEventJson(trimmed) + const whole = parseNostrEventJson(window) if (whole) { return { event: whole, - textBefore: '', - jsonText: trimmed + textBefore: trimmed.slice(0, windowOffset).trimEnd(), + jsonText: window } } - let start = trimmed.lastIndexOf('{') - while (start >= 0) { - const jsonText = trimmed.slice(start) + let start = window.lastIndexOf('{') + let iterations = 0 + while (start >= 0 && iterations < MAX_BRACE_ITERATIONS) { + iterations += 1 + const jsonText = window.slice(start) const event = parseNostrEventJson(jsonText) if (event) { + const absStart = windowOffset + start return { event, - textBefore: trimmed.slice(0, start).trimEnd(), + textBefore: trimmed.slice(0, absStart).trimEnd(), jsonText } } - start = trimmed.lastIndexOf('{', start - 1) + start = window.lastIndexOf('{', start - 1) } return null diff --git a/src/lib/profile-author-warmup-spec.test.ts b/src/lib/profile-author-warmup-spec.test.ts new file mode 100644 index 00000000..9632d472 --- /dev/null +++ b/src/lib/profile-author-warmup-spec.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { ExtendedKind } from '@/constants' +import { getProfileAuthorWarmupSpec } from './profile-author-warmup-spec' + +describe('getProfileAuthorWarmupSpec', () => { + const authorHex = 'a'.repeat(64) + + it('returns spec when calendar #p shards omit authors', () => { + const spec = getProfileAuthorWarmupSpec([ + { + urls: ['wss://relay.example'], + filter: { authors: [authorHex], kinds: [1], limit: 200 } + }, + { + urls: ['wss://relay.example'], + filter: { + kinds: [ExtendedKind.CALENDAR_EVENT_DATE], + '#p': [authorHex], + limit: 100 + } + } + ]) + expect(spec).toEqual({ author: authorHex, kinds: [1] }) + }) + + it('returns null when no author shards', () => { + expect( + getProfileAuthorWarmupSpec([ + { + urls: ['wss://relay.example'], + filter: { kinds: [ExtendedKind.CALENDAR_EVENT_DATE], '#p': [authorHex], limit: 100 } + } + ]) + ).toBeNull() + }) +}) diff --git a/src/lib/profile-author-warmup-spec.ts b/src/lib/profile-author-warmup-spec.ts new file mode 100644 index 00000000..7c324e1a --- /dev/null +++ b/src/lib/profile-author-warmup-spec.ts @@ -0,0 +1,59 @@ +import type { TSubRequestFilter } from '@/types' +import { normalizeHexPubkey } from '@/lib/pubkey' +import type { Filter } from 'nostr-tools' + +/** + * Profile feeds may include calendar invite shards (`#p`) without `authors`. Local session/IDB + * warmup and relay fallback only need the single-author + kinds REQ shards. + */ +export function getProfileAuthorWarmupSpec( + mapped: Array<{ urls: string[]; filter: TSubRequestFilter }> +): { author: string; kinds: number[] } | null { + const authorShards = mapped.filter((m) => { + const authors = (m.filter as Filter).authors + return Array.isArray(authors) && authors.length === 1 + }) + if (authorShards.length === 0) return null + + let normAuthor: string | null = null + const kindUnion = new Set() + + for (const { filter: f } of authorShards) { + const authors = (f as Filter).authors! + let pk: string + try { + pk = normalizeHexPubkey(authors[0]!) + } catch { + return null + } + if (normAuthor === null) normAuthor = pk + else if (normAuthor !== pk) return null + + const ks = (f as Filter).kinds + if (!Array.isArray(ks) || ks.length === 0) return null + for (const k of ks) kindUnion.add(k) + } + + if (normAuthor === null || kindUnion.size === 0) return null + return { author: normAuthor, kinds: Array.from(kindUnion).sort((a, b) => a - b) } +} + +/** Relay URLs from author shards only (for profile one-shot fetch). */ +export function getProfileAuthorWarmupRelayUrls( + mapped: Array<{ urls: string[]; filter: TSubRequestFilter }> +): string[] { + const authorShards = mapped.filter((m) => { + const authors = (m.filter as Filter).authors + return Array.isArray(authors) && authors.length === 1 + }) + const seen = new Set() + const out: string[] = [] + for (const shard of authorShards) { + for (const u of shard.urls) { + if (!u || seen.has(u)) continue + seen.add(u) + out.push(u) + } + } + return out +} diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index ea87e3b4..e8db6eff 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -274,7 +274,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { return } const draft = createPaymentInfoDraftEvent(contentStr, tags) - const published = await publish(draft, { skipCompanionPublish: true }) + const published = await publish(draft) await client.updatePaymentInfoCache(published) setPaymentInfoEvent(published) setPaymentInfoEditOpen(false) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 26282339..64d52130 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1624,14 +1624,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { skipOutboxRetry: (options.specifiedRelayUrls?.length ?? 0) > 0 } const publishResult = await client.publishEvent(relays, event, publishExtras) - if (publishResult.successCount >= 1 && !options.skipCompanionPublish) { - const companionSource = options.companionSourceEvent ?? event - void import('@/lib/companion-publish').then(({ resolveCompanionEventsForPublish, publishCompanionEventsBestEffort }) => - resolveCompanionEventsForPublish(companionSource, { excludeIds: [event.id] }).then((companions) => - publishCompanionEventsBestEffort(relays, companions, publishExtras) - ) - ) - } logger.debug('[Publish] publishEvent completed', { success: publishResult.success, successCount: publishResult.successCount, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 6b295a5b..f909d1bb 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -201,13 +201,6 @@ export type TPublishOptions = { disableFallbacks?: boolean // If true, don't use fallback relays when publishing fails /** Override global "Add client tag" preference for this publish (default: read from localStorage) */ addClientTag?: boolean - /** - * Resolve companion republishes from this event (default: the event being published). - * Use when the published event is a wrapper (e.g. boost) but embeds live on the target note. - */ - companionSourceEvent?: Event - /** Skip automatic companion republish of embedded / q / a / e references. */ - skipCompanionPublish?: boolean } /** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */