diff --git a/docs/nips/18.md b/docs/nips/18.md index 7967a59c..83de12e7 100644 --- a/docs/nips/18.md +++ b/docs/nips/18.md @@ -12,6 +12,8 @@ that a `kind 1` text note is worth reading. The `content` of a repost event is _the stringified JSON of the reposted note_. It MAY also be empty, but that is not recommended. Reposts of [NIP-70](70.md)-protected events SHOULD always have an empty `content`. +bitvid follows this guidance by serializing the full source event payload (including non-kind-1 targets) whenever it can be fetched from a relay, and falls back to an empty `content` only when the original note is protected or otherwise unavailable. + The repost event MUST include an `e` tag with the `id` of the note that is being reposted. That tag MUST include a relay URL as its third entry to indicate where it can be fetched. diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index c86010eb..393c92a5 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -521,6 +521,21 @@ export async function repostEvent({ const cachedVideo = client?.allEvents?.get(normalizedId) || null; const cachedRaw = client?.rawEvents?.get(normalizedId) || null; let resolvedRawEvent = cachedRaw; + const serializeEvent = (event) => { + if (!event || typeof event !== "object") { + return ""; + } + try { + return JSON.stringify(event); + } catch (error) { + devLogger.warn( + `[nostr] Failed to serialize repost target ${normalizedId}`, + error, + ); + return ""; + } + }; + let serializedSourceEvent = serializeEvent(resolvedRawEvent); let authorPubkey = typeof options.authorPubkey === "string" && options.authorPubkey.trim() @@ -789,6 +804,7 @@ export async function repostEvent({ }); if (fetched) { resolvedRawEvent = fetched; + serializedSourceEvent = serializeEvent(fetched); } } catch (error) { devLogger.warn( @@ -809,6 +825,10 @@ export async function repostEvent({ } } + if (resolvedRawEvent && !serializedSourceEvent) { + serializedSourceEvent = serializeEvent(resolvedRawEvent); + } + if (resolvedRawEvent && !eventRelay) { const rawRelay = deriveRelayFromRawEvent(resolvedRawEvent); if (rawRelay) { @@ -893,6 +913,8 @@ export async function repostEvent({ additionalTags, repostKind, targetKind, + targetEvent: resolvedRawEvent, + serializedEvent: serializedSourceEvent, }); try { @@ -924,6 +946,10 @@ export async function repostEvent({ normalizedSigner !== normalizedLogged && normalizedSigner === sessionPubkey; + const sourceInfo = serializedSourceEvent + ? { raw: resolvedRawEvent, serialized: serializedSourceEvent } + : null; + return { ok: true, event: signedEvent, @@ -931,6 +957,7 @@ export async function repostEvent({ relays, sessionActor: usedSessionActor, signerPubkey, + source: sourceInfo, }; } catch (error) { devLogger.warn("[nostr] Repost publish failed:", error); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index f9cecd10..2e3f0ecc 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -659,6 +659,8 @@ export function buildRepostEvent({ additionalTags = [], repostKind, targetKind, + targetEvent = null, + serializedEvent = "", }) { const schema = getNostrEventSchema(NOTE_TYPES.REPOST); const tags = []; @@ -722,12 +724,45 @@ export function buildRepostEvent({ tags.push(["k", String(normalizedTargetKind)]); } + const normalizedSerializedEvent = + typeof serializedEvent === "string" ? serializedEvent : ""; + let normalizedTargetEvent = + targetEvent && typeof targetEvent === "object" ? targetEvent : null; + + if (!normalizedTargetEvent && normalizedSerializedEvent) { + try { + normalizedTargetEvent = JSON.parse(normalizedSerializedEvent); + } catch (error) { + void error; + } + } + + const hasProtectedTag = Array.isArray(normalizedTargetEvent?.tags) + ? normalizedTargetEvent.tags.some( + (tag) => Array.isArray(tag) && tag.length && tag[0] === "-", + ) + : false; + + let content = ""; + + if (!hasProtectedTag) { + if (normalizedSerializedEvent) { + content = normalizedSerializedEvent; + } else if (normalizedTargetEvent) { + try { + content = JSON.stringify(normalizedTargetEvent); + } catch (error) { + void error; + } + } + } + return { kind: resolvedKind, pubkey, created_at, tags, - content: "", + content, }; } diff --git a/tests/nostr-boost-actions.test.mjs b/tests/nostr-boost-actions.test.mjs index 4d34f269..c44201d1 100644 --- a/tests/nostr-boost-actions.test.mjs +++ b/tests/nostr-boost-actions.test.mjs @@ -228,6 +228,92 @@ await (async function testRepostEventFailsWhenRelayMissing() { assert.ok(Array.isArray(result.details?.relays)); })(); +await (async function testRepostEventSerializesKindOneTargetsWhenSourceAvailable() { + const eventId = "repost-kind1-content"; + const authorPubkey = "d".repeat(64); + const sessionPubkey = "f".repeat(64); + const { client } = createPublishClient({ sessionPubkey }); + + const rawEvent = { + id: eventId, + pubkey: authorPubkey, + created_at: 1_700_000_100, + kind: 1, + tags: [], + content: "hello from kind1", + }; + + client.rawEvents.set(eventId, rawEvent); + + const result = await repostEvent({ + client, + eventId, + options: { + pointer: ["e", eventId, "wss://origin"], + authorPubkey, + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + eventToAddressPointer: eventToAddressPointerStub, + }); + + assert.equal(result.ok, true); + const expectedContent = JSON.stringify(rawEvent); + assert.equal(result.event?.kind, 6); + assert.equal(result.event?.content, expectedContent); + assert.equal(result.source?.serialized, expectedContent); + assert.equal(result.source?.raw, rawEvent); +})(); + +await (async function testRepostEventSerializesGenericTargetsWhenSourceAvailable() { + const eventId = "repost-kind30078-content"; + const authorPubkey = "e".repeat(64); + const sessionPubkey = "g".repeat(64); + const { client } = createPublishClient({ sessionPubkey }); + + const identifier = "video-identifier"; + const rawEvent = { + id: eventId, + pubkey: authorPubkey, + created_at: 1_700_000_200, + kind: 30078, + tags: [["d", identifier]], + content: JSON.stringify({ version: 3, title: "Demo" }), + }; + + client.rawEvents.set(eventId, rawEvent); + + const address = `30078:${authorPubkey}:${identifier}`; + + const result = await repostEvent({ + client, + eventId, + options: { + pointer: ["e", eventId, "wss://origin"], + authorPubkey, + address, + addressRelay: "wss://origin", + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + eventToAddressPointer: eventToAddressPointerStub, + }); + + assert.equal(result.ok, true); + const expectedContent = JSON.stringify(rawEvent); + assert.equal(result.event?.kind, 16); + assert.equal(result.event?.content, expectedContent); + assert.equal(result.source?.serialized, expectedContent); + assert.equal(result.source?.raw, rawEvent); + + const kTag = result.event?.tags?.find( + (tag) => Array.isArray(tag) && tag[0] === "k", + ); + assert.deepEqual(kTag, ["k", String(rawEvent.kind)]); +})(); + await (async function testMirrorEventIncludesHostedMetadata() { const eventId = "mirror-event"; const actorPubkey = "1".repeat(64);