diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js index 475dc92a..ac7a8dc5 100644 --- a/js/nostr/reactionEvents.js +++ b/js/nostr/reactionEvents.js @@ -14,6 +14,119 @@ function sanitizeRelayList(primary, fallback) { return RELAY_URLS; } +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function extractEventIdFromPointerInput(pointerInput) { + if (!pointerInput) { + return ""; + } + + if (Array.isArray(pointerInput)) { + if ( + pointerInput.length >= 2 && + pointerInput[0] === "e" && + typeof pointerInput[1] === "string" + ) { + const candidate = pointerInput[1].trim(); + if (candidate) { + return candidate; + } + } + return ""; + } + + if (typeof pointerInput !== "object") { + return ""; + } + + const candidateKeys = ["eventId", "pointerEventId", "id"]; + for (const key of candidateKeys) { + const candidate = normalizeString(pointerInput[key]); + if (candidate) { + return candidate; + } + } + + const normalizedType = normalizeString(pointerInput.type); + if (normalizedType === "e") { + const candidate = normalizeString(pointerInput.value); + if (candidate) { + return candidate; + } + } + + if (pointerInput.pointer) { + const nested = extractEventIdFromPointerInput(pointerInput.pointer); + if (nested) { + return nested; + } + } + + if (pointerInput.tag) { + const nested = extractEventIdFromPointerInput(pointerInput.tag); + if (nested) { + return nested; + } + } + + return ""; +} + +function extractEventRelayFromPointerInput(pointerInput) { + if (!pointerInput) { + return ""; + } + + if (Array.isArray(pointerInput)) { + if ( + pointerInput.length >= 3 && + pointerInput[0] === "e" && + typeof pointerInput[2] === "string" + ) { + const candidate = pointerInput[2].trim(); + if (candidate) { + return candidate; + } + } + return ""; + } + + if (typeof pointerInput !== "object") { + return ""; + } + + const candidateRelay = normalizeString(pointerInput.eventRelay); + if (candidateRelay) { + return candidateRelay; + } + + const normalizedType = normalizeString(pointerInput.type); + if (normalizedType === "e") { + const relayCandidate = normalizeString(pointerInput.relay); + if (relayCandidate) { + return relayCandidate; + } + } + + if (pointerInput.pointer) { + const nested = extractEventRelayFromPointerInput(pointerInput.pointer); + if (nested) { + return nested; + } + } + + if (pointerInput.tag) { + const nested = extractEventRelayFromPointerInput(pointerInput.tag); + if (nested) { + return nested; + } + } + + return ""; +} + export async function publishVideoReaction( client, pointerInput, @@ -69,6 +182,51 @@ export async function publishVideoReaction( ? ["e", pointer.value, pointerRelay] : ["e", pointer.value]; + const pointerTags = [pointerTag]; + + let pointerEventId = normalizeString(pointer.type === "e" ? pointer.value : ""); + if (!pointerEventId) { + pointerEventId = extractEventIdFromPointerInput(pointerInput); + } + if (!pointerEventId) { + pointerEventId = normalizeString(options.pointerEventId); + } + if (!pointerEventId && options.video) { + pointerEventId = + extractEventIdFromPointerInput(options.video.pointerInfo) || + normalizeString(options.video.pointerEventId) || + normalizeString(options.video.eventId) || + normalizeString(options.video.id); + } + + let pointerEventRelay = normalizeString( + pointer.type === "e" ? pointer.relay : "" + ); + if (!pointerEventRelay) { + pointerEventRelay = extractEventRelayFromPointerInput(pointerInput); + } + if (!pointerEventRelay) { + pointerEventRelay = normalizeString(options.pointerEventRelay); + } + if (!pointerEventRelay && options.video) { + pointerEventRelay = + normalizeString(options.video.pointerInfo?.eventRelay) || + extractEventRelayFromPointerInput(options.video.pointerInfo) || + normalizeString(options.video.eventRelay) || + normalizeString(options.video.relay); + } + if (!pointerEventRelay && pointerRelay) { + pointerEventRelay = pointerRelay; + } + + if (pointerEventId) { + if (pointerEventRelay) { + pointerTags.push(["e", pointerEventId, pointerEventRelay]); + } else { + pointerTags.push(["e", pointerEventId]); + } + } + const normalizedPointer = { type: pointer.type, value: pointer.value, @@ -105,6 +263,7 @@ export async function publishVideoReaction( created_at: createdAt, pointerValue: pointer.value, pointerTag, + pointerTags, targetPointer: normalizedPointer, targetAuthorPubkey: explicitTargetAuthor, additionalTags, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 5af3c140..d0fed019 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -495,6 +495,99 @@ function collectPointerTags(schema, { return normalized; } +function mergePointerTags(pointerTags = []) { + if (!Array.isArray(pointerTags) || pointerTags.length === 0) { + return []; + } + + const merged = []; + const pointerIndexByKey = new Map(); + + const toTrimmedString = (value) => { + if (typeof value === "string") { + return value.trim(); + } + if (value === undefined || value === null) { + return ""; + } + return String(value).trim(); + }; + + pointerTags.forEach((tag) => { + if (!Array.isArray(tag) || tag.length < 2) { + return; + } + + const normalizedType = toTrimmedString(tag[0]); + if (!normalizedType) { + return; + } + + const canonicalType = + normalizedType === "a" ? "a" : normalizedType === "e" ? "e" : normalizedType; + + const normalizedValue = toTrimmedString(tag[1]); + if (!normalizedValue) { + return; + } + + const sanitizedTag = [canonicalType, normalizedValue]; + for (let index = 2; index < tag.length; index += 1) { + const extra = toTrimmedString(tag[index]); + if (extra) { + sanitizedTag.push(extra); + } + } + + if (canonicalType !== "a" && canonicalType !== "e") { + merged.push(sanitizedTag); + return; + } + + const dedupeKey = `${canonicalType}:${normalizedValue.toLowerCase()}`; + const relayHint = + sanitizedTag.length > 2 && typeof sanitizedTag[2] === "string" + ? sanitizedTag[2].trim() + : ""; + + if (!pointerIndexByKey.has(dedupeKey)) { + if (relayHint && sanitizedTag.length > 2) { + sanitizedTag[2] = relayHint; + } + const index = merged.length; + merged.push(sanitizedTag); + pointerIndexByKey.set(dedupeKey, { index, relay: relayHint }); + return; + } + + const existingEntry = pointerIndexByKey.get(dedupeKey); + const existingTag = merged[existingEntry.index]; + const existingRelay = + existingTag.length > 2 && typeof existingTag[2] === "string" + ? existingTag[2].trim() + : ""; + const hasExistingRelay = Boolean(existingRelay); + const hasNewRelay = Boolean(relayHint); + + if ( + (!hasExistingRelay && hasNewRelay) || + (hasExistingRelay && hasNewRelay && existingRelay !== relayHint) + ) { + const replacement = sanitizedTag.slice(); + if (relayHint && replacement.length > 2) { + replacement[2] = relayHint; + } + merged[existingEntry.index] = replacement; + pointerIndexByKey.set(dedupeKey, { + index: existingEntry.index, + relay: relayHint, + }); + } + }); + + return merged; +} + export function buildVideoPostEvent({ pubkey, created_at, @@ -766,8 +859,11 @@ export function buildReactionEvent({ pointerTag, pointerTags, }); - normalizedPointerTags.forEach((tag) => { - tags.push(tag); + const mergedPointerTags = mergePointerTags(normalizedPointerTags); + mergedPointerTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag); + } }); const resolvePointerDetails = (pointerCandidate) => { @@ -808,7 +904,7 @@ export function buildReactionEvent({ return explicitPointer; } - const pointerTagEntry = normalizedPointerTags.find( + const pointerTagEntry = mergedPointerTags.find( (tag) => Array.isArray(tag) && tag.length >= 2 && (tag[0] === "a" || tag[0] === "e") ); if (!pointerTagEntry) { diff --git a/js/utils/videoPointer.js b/js/utils/videoPointer.js index 9dd6b5b9..292ef31d 100644 --- a/js/utils/videoPointer.js +++ b/js/utils/videoPointer.js @@ -42,7 +42,18 @@ export function resolveVideoPointer({ for (const pointer of pointerCandidates) { const key = pointerArrayToKey(pointer); if (key) { - return { pointer, key }; + const pointerEventId = + pointer[0] === "e" && typeof pointer[1] === "string" + ? pointer[1].trim() + : fallbackId; + return { + pointer, + key, + eventId: + typeof pointerEventId === "string" && pointerEventId + ? pointerEventId + : "", + }; } } diff --git a/tests/reaction-event-builder.test.mjs b/tests/reaction-event-builder.test.mjs index 1d3100da..a5476d2b 100644 --- a/tests/reaction-event-builder.test.mjs +++ b/tests/reaction-event-builder.test.mjs @@ -21,19 +21,38 @@ test("buildReactionEvent includes pointer and author tags when pubkey provided", assert.deepEqual(authorTags, [["p", "author-pubkey", "wss://relay.example"]]); }); -test("buildReactionEvent derives author pubkey from address pointer", () => { - const event = buildReactionEvent({ - pubkey: "viewer", - created_at: 1700000001, - pointerValue: "30078:deadbeefcafebabe:clip-1", - pointerTag: ["a", "30078:deadbeefcafebabe:clip-1"], - targetPointer: { type: "a", value: "30078:deadbeefcafebabe:clip-1" }, - content: "+", - }); +test( + "buildReactionEvent merges address and event pointers for addressable targets", + () => { + const event = buildReactionEvent({ + pubkey: "viewer", + created_at: 1700000001, + pointerValue: "30078:deadbeefcafebabe:clip-1", + pointerTag: ["a", "30078:deadbeefcafebabe:clip-1"], + pointerTags: [ + ["a", "30078:deadbeefcafebabe:clip-1", "wss://relay.example"], + ["e", "event-id"], + ["e", "event-id", "wss://event.relay"], + ], + targetPointer: { + type: "a", + value: "30078:deadbeefcafebabe:clip-1", + relay: "wss://relay.example", + }, + content: "+", + }); - const addressTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "a"); - assert.deepEqual(addressTags, [["a", "30078:deadbeefcafebabe:clip-1"]]); + const addressTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "a"); + assert.deepEqual(addressTags, [[ + "a", + "30078:deadbeefcafebabe:clip-1", + "wss://relay.example", + ]]); - const authorTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p"); - assert.deepEqual(authorTags, [["p", "deadbeefcafebabe"]]); -}); + const eventTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "e"); + assert.deepEqual(eventTags, [["e", "event-id", "wss://event.relay"]]); + + const authorTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p"); + assert.deepEqual(authorTags, [["p", "deadbeefcafebabe", "wss://relay.example"]]); + } +);