diff --git a/src/lib/NostrBatcher.ts b/src/lib/NostrBatcher.ts index 89d640eb..8f905bf4 100644 --- a/src/lib/NostrBatcher.ts +++ b/src/lib/NostrBatcher.ts @@ -357,6 +357,35 @@ function getETagValue(filter: NostrFilter): string { return ((filter as Record)['#e'] as string[])[0]; } +/** + * A filter that queries by a single `#a` (addressable coordinate) tag with + * kinds and limit. e.g. `{ kinds: [8333], '#a': [aTag], limit: 500 }`. + * Must NOT have `authors` — that's a different pattern. + * + * Used by per-card hooks like `useCampaignDonations` (one card → one REQ), + * which fan out to N REQs when N cards mount in the same render. Batching + * collapses them into a single `'#a': [aTag1, aTag2, …]` REQ. + */ +function isATagFilter(filter: NostrFilter): boolean { + const keys = Object.keys(filter); + return ( + keys.every((k) => k === 'kinds' || k === '#a' || k === 'limit') && + Array.isArray(filter.kinds) && + filter.kinds.length > 0 && + !filter.authors && + (filter as Record)['#a'] !== undefined && + Array.isArray((filter as Record)['#a']) && + ((filter as Record)['#a'] as string[]).length === 1 + ); +} + +/** + * Extract the single `#a` value from a filter known to have one. + */ +function getATagValue(filter: NostrFilter): string { + return ((filter as Record)['#a'] as string[])[0]; +} + /** * Check if a multi-filter array can be batched: every filter must be an * e-tag or q-tag filter referencing the same single event ID. @@ -434,6 +463,8 @@ export class NostrBatcher { private dTagCollectors = new Map>(); /** Keyed by sorted kinds string for #e-tag batching. Returns arrays. */ private eTagCollectors = new Map>(); + /** Keyed by sorted kinds string for #a-tag batching. Returns arrays. */ + private aTagCollectors = new Map>(); /** Keyed by serialized filter shapes for multi-filter #e/#q batching. */ private multiFilterCollectors = new Map>(); @@ -516,6 +547,26 @@ export class NostrBatcher { return collector.request(eventId, opts?.signal); } + // { kinds: [...], '#a': [aTag] } (no authors) + // The dominant feed-page leak: each CampaignCard's `useCampaignDonations` + // fires `{ kinds: [8333], '#a': [aTag], limit: 500 }` independently, + // so 25 cards = 25 REQs. Batching collapses them per (kinds, limit) + // shape into one REQ. + if (isATagFilter(filter)) { + const aTag = getATagValue(filter); + const kindsKey = [...filter.kinds!].sort().join(','); + const limit = filter.limit ?? 50; + const collectorKey = `${kindsKey}:${limit}`; + let collector = this.aTagCollectors.get(collectorKey); + if (!collector) { + collector = new BatchCollector((aTags, signal) => + this.executeATagBatch(filter.kinds!, aTags, limit, signal), + ); + this.aTagCollectors.set(collectorKey, collector); + } + return collector.request(aTag, opts?.signal); + } + // { kinds: [k], authors: [a], '#d': [d] } if (isDTagFilter(filter)) { const kind = filter.kinds![0]; @@ -742,6 +793,46 @@ export class NostrBatcher { return results; } + private async executeATagBatch( + kinds: number[], + aTags: string[], + perItemLimit: number, + signal: AbortSignal, + ): Promise> { + const results = new Map(); + try { + const events = await this.pool.query( + [{ kinds, '#a': aTags, limit: aTags.length * perItemLimit }], + { signal }, + ); + + // Group results by which addressable coordinate they reference via a-tag. + // A single event may reference multiple coordinates (e.g. a zap receipt + // tagging both a campaign and a pledge); attribute it to each matching + // coord so every caller waiting on those aTags receives it. + const byATag = new Map(); + const aTagSet = new Set(aTags); + for (const event of events) { + for (const tag of event.tags) { + if (tag[0] === 'a' && aTagSet.has(tag[1])) { + const existing = byATag.get(tag[1]) ?? []; + existing.push(event); + byATag.set(tag[1], existing); + } + } + } + + for (const aTag of aTags) { + results.set(aTag, byATag.get(aTag) ?? []); + } + } catch { + for (const aTag of aTags) { + results.set(aTag, []); + } + } + return results; + } + private async executeMultiFilterBatch( templateFilters: NostrFilter[], eventIds: string[],