From 2564627985a92e750926f80bd83c7a17a500fc5c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 31 Mar 2026 17:08:23 +0200 Subject: [PATCH] bug-fixes --- .../Note/MarkdownArticle/MarkdownArticle.tsx | 22 +++ .../PublicationIndex/PublicationIndex.tsx | 4 + src/components/NoteOptions/useMenuActions.tsx | 145 +++++++++++++++ src/hooks/usePublicationSectionLoader.ts | 79 +++++++- src/lib/publication-rendered-events.ts | 50 +++++ src/lib/publication-section-fetch.ts | 173 +++++++++++++++++- 6 files changed, 460 insertions(+), 13 deletions(-) create mode 100644 src/lib/publication-rendered-events.ts diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index ff660f8a..622ef913 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -3084,6 +3084,17 @@ function parseMarkdownContentMarked( if (/^https?:\/\/\S+$/i.test(line)) { const cleaned = cleanUrl(line) if (cleaned) { + if (isYouTubeUrl(cleaned)) { + return ( +
+ +
+ ) + } if (isVideo(cleaned) || isAudio(cleaned)) { const poster = videoPosterMap?.get(cleaned) return ( @@ -3210,6 +3221,17 @@ function parseMarkdownContentMarked( if (/^https?:\/\/\S+$/i.test(paragraphText)) { const cleaned = cleanUrl(paragraphText) if (cleaned) { + if (isYouTubeUrl(cleaned)) { + return ( +
+ +
+ ) + } if (isVideo(cleaned) || isAudio(cleaned)) { const poster = videoPosterMap?.get(cleaned) return ( diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index f490f2dc..bb8aac5c 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -17,6 +17,7 @@ import { extractBookMetadata } from '@/lib/bookstr-parser' import { dTagToTitleCase } from '@/lib/event-metadata' import Image from '@/components/Image' import NoteOptions from '@/components/NoteOptions' +import { upsertRenderedPublicationEvents } from '@/lib/publication-rendered-events' interface PublicationReference { coordinate?: string @@ -329,6 +330,9 @@ export default function PublicationIndex({ const loaded = referencesWithEvents .filter((r) => r.event) .map((r) => r.event!) + if (loaded.length > 0) { + upsertRenderedPublicationEvents(event.id, loaded) + } if (loaded.length === 0) return const t = window.setTimeout(() => { void indexedDb.putPublicationWithNestedEvents(event, loaded).catch((err) => { diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index e526062f..334bd14a 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -5,6 +5,12 @@ import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' import { toAlexandria } from '@/lib/link' import logger from '@/lib/logger' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' +import { + batchFetchPublicationSectionEvents, + buildPublicationSectionRelayUrls, + parsePublicationATagCoordinate, + type PublicationSectionRef +} from '@/lib/publication-section-fetch' import { normalizeUrl, simplifyUrl } from '@/lib/url' import { speakNoteReadAloud } from '@/lib/read-aloud' import { @@ -456,6 +462,136 @@ export function useMenuActions({ }, [isArticleType, event, dTag]) const menuActions: MenuAction[] = useMemo(() => { + const rebroadcastEntirePublication = () => { + const rootPublication = event + + closeDrawer() + + const promise = (async () => { + if (rootPublication.kind !== ExtendedKind.PUBLICATION) { + throw new Error(t('This action is only available for publications')) + } + if (allAvailableRelayUrls.length === 0) { + throw new Error(t('No relays available')) + } + + const MAX_NESTED_PUBLICATIONS = 128 + const MAX_TOTAL_REBROADCAST_EVENTS = 5000 + + const collectedById = new Map() + const visitedPublicationIds = new Set() + const queue: Event[] = [rootPublication] + let traversedPublications = 0 + + while (queue.length > 0) { + const currentPublication = queue.shift()! + if (visitedPublicationIds.has(currentPublication.id)) continue + visitedPublicationIds.add(currentPublication.id) + traversedPublications++ + collectedById.set(currentPublication.id, currentPublication) + + if (traversedPublications > MAX_NESTED_PUBLICATIONS) { + logger.warn('[NoteOptions] Rebroadcast publication traversal capped', { + rootId: rootPublication.id, + cap: MAX_NESTED_PUBLICATIONS + }) + break + } + + const refs: PublicationSectionRef[] = [] + for (const tag of currentPublication.tags) { + if (tag[0] === 'a' && tag[1]) { + const parsed = parsePublicationATagCoordinate(tag[1]) + if (!parsed) continue + refs.push({ + type: 'a', + coordinate: parsed.coordinate, + kind: parsed.kind, + pubkey: parsed.pubkey, + identifier: parsed.identifier, + relay: tag[2] + }) + } else if (tag[0] === 'e' && tag[1]) { + refs.push({ + type: 'e', + eventId: tag[1], + relay: tag[2] + }) + } + } + + if (refs.length === 0) continue + + const primaryRelays = await buildPublicationSectionRelayUrls(currentPublication, refs, 40, false) + const fallbackRelays = await buildPublicationSectionRelayUrls(currentPublication, refs, 80, true) + const relays = [...new Set([...primaryRelays, ...fallbackRelays, ...allAvailableRelayUrls])] + const resolved = await batchFetchPublicationSectionEvents(refs, relays) + + for (const ev of resolved.values()) { + if (collectedById.size >= MAX_TOTAL_REBROADCAST_EVENTS) break + collectedById.set(ev.id, ev) + if (ev.kind === ExtendedKind.PUBLICATION && !visitedPublicationIds.has(ev.id)) { + queue.push(ev) + } + } + + if (collectedById.size >= MAX_TOTAL_REBROADCAST_EVENTS) { + logger.warn('[NoteOptions] Rebroadcast event collection capped', { + rootId: rootPublication.id, + cap: MAX_TOTAL_REBROADCAST_EVENTS + }) + break + } + } + + const uniqueEvents = [...collectedById.values()] + if (uniqueEvents.length === 0) { + throw new Error(t('No publication events found for rebroadcast')) + } + + const BATCH_SIZE = 6 + let acceptedEvents = 0 + let failedEvents = 0 + let acceptedRelayAcks = 0 + + for (let i = 0; i < uniqueEvents.length; i += BATCH_SIZE) { + const batch = uniqueEvents.slice(i, i + BATCH_SIZE) + const batchResults = await Promise.allSettled( + batch.map(async (ev) => { + const result = await client.publishEvent(allAvailableRelayUrls, ev) + if (result.successCount > 0) { + acceptedEvents++ + acceptedRelayAcks += result.successCount + } else { + failedEvents++ + } + }) + ) + for (const res of batchResults) { + if (res.status === 'rejected') failedEvents++ + } + } + + if (acceptedEvents < 1) { + throw new Error(t('No publication events were accepted by any relay')) + } + + return { + acceptedEvents, + failedEvents, + totalEvents: uniqueEvents.length, + acceptedRelayAcks, + traversedPublications + } + })() + + toastPublishPromise(promise, { + loading: t('Rebroadcasting entire publication...'), + success: () => t('Rebroadcasted entire publication'), + error: (err) => t('Failed to rebroadcast entire publication: {{error}}', { error: err.message }) + }) + } + // Export functions for articles const exportAsMarkdown = () => { if (!isArticleType) return @@ -780,6 +916,15 @@ export function useMenuActions({ }) } } + + if (event.kind === ExtendedKind.PUBLICATION) { + actions.push({ + icon: SatelliteDish, + label: t('Rebroadcast entire publication'), + onClick: rebroadcastEntirePublication, + separator: true + }) + } } const isProtected = isProtectedEvent(event) diff --git a/src/hooks/usePublicationSectionLoader.ts b/src/hooks/usePublicationSectionLoader.ts index b678d21f..6edcb049 100644 --- a/src/hooks/usePublicationSectionLoader.ts +++ b/src/hooks/usePublicationSectionLoader.ts @@ -52,6 +52,7 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication const indexId = indexEvent.id const refsSignature = useMemo(() => signatureOfRefs(refs), [refs]) const [relayUrls, setRelayUrls] = useState([]) + const [fallbackRelayUrls, setFallbackRelayUrls] = useState([]) const [rows, setRows] = useState([]) const inflightKeysRef = useRef>(new Set()) const autoLoadedSignatureRef = useRef(null) @@ -79,15 +80,29 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication useEffect(() => { let cancelled = false ;(async () => { - const primary = await buildPublicationSectionRelayUrls(indexEvent, refs, 22, false) + const primary = await buildPublicationSectionRelayUrls(indexEvent, refs, 30, false) if (cancelled) return - if (primary.length > 0) { - setRelayUrls(primary) - return + if (import.meta.env.DEV) { + logger.info('[PublicationSection] relay_urls_primary', { + indexId, + count: primary.length, + relays: primary + }) } - const fallback = await buildPublicationSectionRelayUrls(indexEvent, refs, 30, true) + setRelayUrls(primary) + + const fallback = await buildPublicationSectionRelayUrls(indexEvent, refs, 60, true) if (cancelled) return - setRelayUrls(fallback) + if (import.meta.env.DEV) { + const uniqueExtra = fallback.filter((u) => !primary.includes(u)) + logger.info('[PublicationSection] relay_urls_searchable_fallback', { + indexId, + count: fallback.length, + extraCount: uniqueExtra.length, + relays: fallback + }) + } + setFallbackRelayUrls(fallback) })().catch((err) => { if (import.meta.env.DEV) { logger.warn('[PublicationSection] relay_build_failed', { @@ -95,7 +110,10 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication message: err instanceof Error ? err.message : String(err) }) } - if (!cancelled) setRelayUrls([]) + if (!cancelled) { + setRelayUrls([]) + setFallbackRelayUrls([]) + } }) return () => { cancelled = true @@ -131,6 +149,14 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication async (keys: string[]) => { const selectedRows = rows.filter((r) => keys.includes(r.key)) if (selectedRows.length === 0) return + if (import.meta.env.DEV) { + logger.info('[PublicationSection] run_fetch_start', { + indexId, + keyCount: selectedRows.length, + keys: selectedRows.map((r) => r.key), + relayCount: relayUrls.length + }) + } const byDb = new Map() const stillNeed: Row[] = [] @@ -169,7 +195,33 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication } const merged = new Map([...byDb, ...fromNet]) - const unresolved = stillNeed.filter((r) => !merged.has(r.key)) + let unresolved = stillNeed.filter((r) => !merged.has(r.key)) + + // Second pass: unresolved refs on broader searchable relay set. + if (unresolved.length > 0 && fallbackRelayUrls.length > 0) { + const fallbackOnly = fallbackRelayUrls.filter((u) => !relayUrls.includes(u)) + const relaysForFallback = fallbackOnly.length > 0 ? fallbackRelayUrls : [] + if (relaysForFallback.length > 0) { + if (import.meta.env.DEV) { + logger.info('[PublicationSection] searchable_fallback_start', { + unresolved: unresolved.map((r) => r.key), + relayCount: relaysForFallback.length + }) + } + const fromSearchFallback = await batchFetchPublicationSectionEvents( + unresolved, + relaysForFallback + ) + for (const [k, ev] of fromSearchFallback) merged.set(k, ev) + unresolved = unresolved.filter((r) => !merged.has(r.key)) + if (import.meta.env.DEV) { + logger.info('[PublicationSection] searchable_fallback_done', { + fromSearchFallback: fromSearchFallback.size, + stillNeed: unresolved.map((r) => r.key) + }) + } + } + } const bySingle = new Map() await Promise.all( @@ -219,9 +271,18 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication .map((r) => r.key) .filter((k) => !merged.has(k)) + if (import.meta.env.DEV) { + logger.info('[PublicationSection] run_fetch_done', { + indexId, + loadedCount: merged.size, + failedCount: failed.length, + failedKeys: failed + }) + } + applyLoadedAndFailed(merged, failed) }, - [applyLoadedAndFailed, relayUrls, rows] + [applyLoadedAndFailed, fallbackRelayUrls, relayUrls, rows] ) const requestKeys = useCallback( diff --git a/src/lib/publication-rendered-events.ts b/src/lib/publication-rendered-events.ts new file mode 100644 index 00000000..40aa7408 --- /dev/null +++ b/src/lib/publication-rendered-events.ts @@ -0,0 +1,50 @@ +import type { Event } from 'nostr-tools' + +const renderedByPublication = new Map>() + +function normId(id: string): string { + return id.trim().toLowerCase() +} + +export function upsertRenderedPublicationEvents(publicationId: string, events: Event[]): void { + const pubId = normId(publicationId) + let byId = renderedByPublication.get(pubId) + if (!byId) { + byId = new Map() + renderedByPublication.set(pubId, byId) + } + for (const ev of events) { + if (!ev?.id) continue + byId.set(normId(ev.id), ev) + } +} + +export function getRenderedPublicationEvents(publicationId: string): Event[] { + const pubId = normId(publicationId) + return [...(renderedByPublication.get(pubId)?.values() ?? [])] +} + +/** + * Deep collection for nested 30040 publications that were rendered in this session. + */ +export function getRenderedPublicationEventsDeep(publicationId: string, maxDepth = 6): Event[] { + const seenPublicationIds = new Set() + const outByEventId = new Map() + + const walk = (pubIdRaw: string, depth: number) => { + const pubId = normId(pubIdRaw) + if (depth > maxDepth || seenPublicationIds.has(pubId)) return + seenPublicationIds.add(pubId) + const direct = renderedByPublication.get(pubId) + if (!direct) return + for (const ev of direct.values()) { + outByEventId.set(normId(ev.id), ev) + if (ev.kind === 30040) { + walk(ev.id, depth + 1) + } + } + } + + walk(publicationId, 0) + return [...outByEventId.values()] +} diff --git a/src/lib/publication-section-fetch.ts b/src/lib/publication-section-fetch.ts index 124bdd0f..b48236e9 100644 --- a/src/lib/publication-section-fetch.ts +++ b/src/lib/publication-section-fetch.ts @@ -1,4 +1,5 @@ import logger from '@/lib/logger' +import { FAST_READ_RELAY_URLS } from '@/constants' import { publicationCoordinateLookupKeys, splitPublicationCoordinate } from '@/lib/publication-coordinate' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -69,10 +70,15 @@ export async function buildPublicationSectionRelayUrls( includeSearchableRelays = false ): Promise { const hints = collectRelayHints(refs) + const fastReadRelays = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter((u) => !!u) + const seenOnRelays = queryService + .getSeenEventRelayUrls(indexEvent.id) + .map((u) => normalizeUrl(u) || u) + .filter((u) => !!u) const urls = await buildComprehensiveRelayList({ authorPubkey: indexEvent.pubkey, userPubkey: client.pubkey || undefined, - relayHints: hints, + relayHints: [...hints, ...seenOnRelays], includeUserOwnRelays: true, includeProfileFetchRelays: true, includeFastReadRelays: true, @@ -80,16 +86,33 @@ export async function buildPublicationSectionRelayUrls( includeFavoriteRelays: true, includeLocalRelays: true }) - const prioritized = [...new Set([...hints, ...urls])] + // Keep fast-read relays pinned at the front so slicing can never drop them. + const prioritized = [...new Set([...fastReadRelays, ...hints, ...seenOnRelays, ...urls])] + if (import.meta.env.DEV) { + logger.info('[PublicationSection] relay_urls_built', { + indexId: indexEvent.id, + includeSearchableRelays, + fastReadCount: fastReadRelays.length, + relayHintsCount: hints.length, + seenOnRelaysCount: seenOnRelays.length, + totalBeforeSlice: prioritized.length, + maxRelays, + hasAggr: prioritized.includes(normalizeUrl('wss://aggr.nostr.land') || 'wss://aggr.nostr.land'), + hasTheCitadel: prioritized.includes( + normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' + ) + }) + } return prioritized.slice(0, maxRelays) } const IDS_CHUNK = 44 const D_CHUNK = 28 const ANY_KIND_LIMIT_PER_D = 12 +const AUTHOR_KIND_SCAN_LIMIT = 200 function dTagOf(ev: Event): string | undefined { - const d = ev.tags.find((t) => t[0] === 'd')?.[1] + const d = ev.tags.find((t) => (t[0] || '').trim().toLowerCase() === 'd')?.[1] return d && d.length > 0 ? d : undefined } @@ -153,6 +176,22 @@ export async function batchFetchPublicationSectionEvents( } } + if (import.meta.env.DEV) { + logger.info('[PublicationSection] batch_filters_prepared', { + relayCount: relayUrls.length, + refCount: refs.length, + aRefCount: aRefs.length, + eRefCount: eRefs.length, + filterCount: filters.length, + filterPreview: filters.slice(0, 3).map((f) => ({ + ids: Array.isArray(f.ids) ? f.ids.length : 0, + authors: Array.isArray(f.authors) ? f.authors.length : 0, + kinds: Array.isArray(f.kinds) ? f.kinds : [], + d: Array.isArray(f['#d']) ? f['#d'].slice(0, 4) : [] + })) + }) + } + let events: Event[] = [] if (filters.length > 0) { try { @@ -242,6 +281,14 @@ export async function batchFetchPublicationSectionEvents( } } if (hintFilters.length === 0) continue + if (import.meta.env.DEV) { + logger.info('[PublicationSection] relay_hint_pass_start', { + relay, + refCount: relayRefs.length, + filterCount: hintFilters.length, + sampleKeys: relayRefs.map((r) => publicationRefKey(r)).slice(0, 6) + }) + } try { const hintEvents = await queryService.fetchEvents([relay], hintFilters, { globalTimeout: 8_000, @@ -268,8 +315,22 @@ export async function batchFetchPublicationSectionEvents( } if (ev) out.set(key, ev) } + if (import.meta.env.DEV) { + const unresolvedAfterRelay = relayRefs + .map((r) => publicationRefKey(r)) + .filter((k) => !out.has(k)) + logger.info('[PublicationSection] relay_hint_pass_done', { + relay, + eventsReturned: hintEvents.length, + unresolvedAfterRelayCount: unresolvedAfterRelay.length, + unresolvedAfterRelay: unresolvedAfterRelay.slice(0, 8) + }) + } } catch { // ignore per-relay hint failures + if (import.meta.env.DEV) { + logger.warn('[PublicationSection] relay_hint_pass_error', { relay, filterCount: hintFilters.length }) + } } } @@ -301,6 +362,13 @@ export async function batchFetchPublicationSectionEvents( } } if (fallbackFilters.length > 0) { + if (import.meta.env.DEV) { + logger.info('[PublicationSection] any_kind_fallback_start', { + relayCount: relayUrls.length, + filterCount: fallbackFilters.length, + unresolvedBefore: unresolvedAfterHint.map((r) => publicationRefKey(r)).slice(0, 12) + }) + } try { const fallbackEvents = await queryService.fetchEvents(relayUrls, fallbackFilters, { globalTimeout: 10_000, @@ -330,8 +398,96 @@ export async function batchFetchPublicationSectionEvents( } out.set(key, newest) } + if (import.meta.env.DEV) { + const unresolvedAfterAnyKind = unresolvedAfterHint + .map((r) => publicationRefKey(r)) + .filter((k) => !out.has(k)) + logger.info('[PublicationSection] any_kind_fallback_done', { + eventsReturned: fallbackEvents.length, + unresolvedAfterAnyKindCount: unresolvedAfterAnyKind.length, + unresolvedAfterAnyKind: unresolvedAfterAnyKind.slice(0, 10) + }) + } } catch { // ignore fallback errors + if (import.meta.env.DEV) { + logger.warn('[PublicationSection] any_kind_fallback_error', { filterCount: fallbackFilters.length }) + } + } + } + } + + // Final robust fallback for relays that do not properly index `#d`: + // scan author + kind and match d-tag client-side. + const unresolvedAfterAll = aRefs.filter((r) => !out.has(publicationRefKey(r))) + if (unresolvedAfterAll.length > 0) { + const scanFilters: Filter[] = [] + const scanGroups = new Map() + for (const ref of unresolvedAfterAll) { + const key = `${ref.pubkey!.toLowerCase()}:${ref.kind!}` + if (!scanGroups.has(key)) { + scanGroups.set(key, { pubkey: ref.pubkey!.toLowerCase(), kind: ref.kind! }) + } + } + for (const g of scanGroups.values()) { + scanFilters.push({ + authors: [g.pubkey], + kinds: [g.kind], + limit: AUTHOR_KIND_SCAN_LIMIT + }) + } + if (scanFilters.length > 0) { + if (import.meta.env.DEV) { + logger.info('[PublicationSection] author_kind_scan_start', { + filterCount: scanFilters.length, + relayCount: relayUrls.length, + unresolvedCount: unresolvedAfterAll.length, + unresolvedKeys: unresolvedAfterAll.map((r) => publicationRefKey(r)).slice(0, 10) + }) + } + try { + const scanEvents = await queryService.fetchEvents(relayUrls, scanFilters, { + globalTimeout: 12_000, + eoseTimeout: 2_000, + firstRelayResultGraceMs: false + }) + const scanByCoord = new Map() + for (const ev of scanEvents) { + const coord = coordinateOfEvent(ev) + if (!coord) continue + for (const k of publicationCoordinateLookupKeys(coord)) { + const prev = scanByCoord.get(k) + if (!prev || ev.created_at > prev.created_at) scanByCoord.set(k, ev) + } + } + for (const ref of unresolvedAfterAll) { + const key = publicationRefKey(ref) + if (out.has(key)) continue + const coord = ref.coordinate! + let ev: Event | undefined + for (const lk of publicationCoordinateLookupKeys(coord)) { + ev = scanByCoord.get(lk) + if (ev) break + } + if (ev) out.set(key, ev) + } + if (import.meta.env.DEV) { + const unresolvedAfterScan = unresolvedAfterAll + .map((r) => publicationRefKey(r)) + .filter((k) => !out.has(k)) + logger.info('[PublicationSection] author_kind_scan_done', { + eventsReturned: scanEvents.length, + resolvedTotal: out.size, + unresolvedAfterScanCount: unresolvedAfterScan.length, + unresolvedAfterScan: unresolvedAfterScan.slice(0, 10) + }) + } + } catch { + if (import.meta.env.DEV) { + logger.warn('[PublicationSection] author_kind_scan_error', { + filterCount: scanFilters.length + }) + } } } } @@ -339,6 +495,14 @@ export async function batchFetchPublicationSectionEvents( if (import.meta.env.DEV) { const unmatchedA = aRefs.filter((r) => !out.has(publicationRefKey(r))) const unmatchedE = eRefs.filter((r) => !out.has(publicationRefKey(r))) + const sampleEvents = events.slice(0, 8).map((ev) => ({ + id: ev.id, + kind: ev.kind, + pubkey: ev.pubkey.slice(0, 12), + created_at: ev.created_at, + tagNames: ev.tags.slice(0, 8).map((t) => String(t[0] || '')), + dTag: dTagOf(ev) + })) logger.info('[PublicationSection] batch_fetch_result', { relayCount: relayUrls.length, filterCount: filters.length, @@ -347,7 +511,8 @@ export async function batchFetchPublicationSectionEvents( resolved: out.size, unmatchedACount: unmatchedA.length, unmatchedECount: unmatchedE.length, - unmatchedAKeys: unmatchedA.map((r) => publicationRefKey(r)).slice(0, 12) + unmatchedAKeys: unmatchedA.map((r) => publicationRefKey(r)).slice(0, 12), + sampleEvents }) }