diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 8cd532bf..77f4319e 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -141,6 +141,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, , ?], `['K', ]`, and `['P', , ?]` plus parent metadata `['a'\|`e`\|`i`, , ?, ], `['k', ]`, `['p', , ?]`; builder retains legacy lowercase fallbacks (`['a', ...]`/`['e', ...]`/`['p', ...]`) alongside the uppercase set for compatibility; inherits schema append tags | Plain text body sanitized to valid UTF-8 | > **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js). + +> **Compatibility tip:** When a comment anchors itself to a video definition address, emit both the uppercase root pointer and a legacy `['e', ]` tag. This mirrors the [NIP-22 addressable events example](./nips/22.md#addressable-events) so clients that only understand `#e` tags can still follow the thread. | NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', ]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. | | NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index e7b211a6..dfa513db 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -1160,12 +1160,17 @@ export function buildCommentEvent({ } } - const includeVideoEventTag = - !normalizedVideoDefinitionAddress && + const shouldAppendVideoEventTag = Boolean(videoEventTagName) && - Boolean(normalizedVideoEventId); + Boolean(normalizedVideoEventId) && + !tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === videoEventTagName && + tag[1] === normalizedVideoEventId, + ); - if (includeVideoEventTag) { + if (shouldAppendVideoEventTag) { appendPointerTag( videoEventTagName, normalizedVideoEventId, diff --git a/tests/comment-event-builder.test.mjs b/tests/comment-event-builder.test.mjs index 2bb12cef..eef58ede 100644 --- a/tests/comment-event-builder.test.mjs +++ b/tests/comment-event-builder.test.mjs @@ -25,6 +25,7 @@ test("buildCommentEvent emits NIP-22 root metadata while keeping legacy fallback ["A", "30078:deadbeefcafebabe:clip-1"], ["K", "30078"], ["P", "deadbeefcafebabe"], + ["e", "video-event-id"], ["a", "30078:deadbeefcafebabe:clip-1"], ["k", "30078"], ["p", "deadbeefcafebabe"], @@ -48,6 +49,7 @@ test("buildCommentEvent includes parent pointers, kinds, and authors for replies ["A", "30078:deadbeefcafebabe:clip-1"], ["K", "30078"], ["P", "deadbeefcafebabe"], + ["e", "video-event-id"], ["a", "30078:deadbeefcafebabe:clip-1"], ["e", "parent-comment-id", "parent-author"], ["k", String(VIDEO_COMMENT_KIND)], @@ -76,6 +78,7 @@ test("buildCommentEvent normalizes relays and preserves explicit overrides", () ["A", "30078:deadbeefcafebabe:clip-2", "wss://video.def"], ["K", "30078"], ["P", "deadbeefcafebabe", "wss://video.def"], + ["e", "event123", "wss://comments.main"], ["a", "30078:deadbeefcafebabe:clip-2", "wss://video.def"], ["e", "root-comment", "wss://parent", "cafecafe"], ["k", String(VIDEO_COMMENT_KIND)], diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 6090d1d8..723331ec 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -62,6 +62,24 @@ test( }), }; + assert.ok( + topLevelEvent.tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "e" && + tag[1] === videoEventId && + tag[2] === relayUrl, + ), + "builder should retain legacy #e tag for the root event when address pointer is present", + ); + assert.ok( + topLevelEvent.tags.some( + (tag) => + Array.isArray(tag) && tag[0] === "a" && tag[1] === videoDefinitionAddress, + ), + "builder should include #a pointer for the scoped video definition", + ); + const replyEvent = { id: replyCommentId, ...buildCommentEvent({ @@ -81,6 +99,24 @@ test( }), }; + assert.ok( + replyEvent.tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "e" && + tag[1] === videoEventId && + tag[2] === relayUrl, + ), + "reply builder should include root #e tag alongside #a pointer", + ); + assert.ok( + replyEvent.tags.some( + (tag) => + Array.isArray(tag) && tag[0] === "a" && tag[1] === videoDefinitionAddress, + ), + "reply builder should preserve #a pointer", + ); + const listCalls = []; const client = { relays: [relayUrl], diff --git a/tests/nostr/comment-events.test.mjs b/tests/nostr/comment-events.test.mjs index b8d47b96..87c08fe3 100644 --- a/tests/nostr/comment-events.test.mjs +++ b/tests/nostr/comment-events.test.mjs @@ -115,6 +115,7 @@ test("publishComment prefers active signer when available", async () => { ["A", "30078:deadbeef:clip", "wss://definition-relay"], ["K", "30078"], ["P", "deadbeef", "wss://definition-relay"], + ["e", "video-event-id", "wss://video-relay"], ["a", "30078:deadbeef:clip", "wss://definition-relay"], ["e", "parent-comment-id", "thread-participant"], ["k", String(COMMENT_EVENT_KIND)], @@ -239,6 +240,7 @@ test("publishComment derives root and parent metadata from parent comment tags", ["A", "30078:videopk:root", "wss://definition"], ["K", "30078"], ["P", "videopk", "wss://author"], + ["e", "video-event", "wss://root"], ["a", "30078:videopk:root", "wss://definition"], ["e", "parent-comment", "wss://parent", "parentpk"], ["k", String(COMMENT_EVENT_KIND)], diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 3eb22737..cf57db57 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -26,6 +26,17 @@ function createCommentEvent({ id, pubkey, content, createdAt, parentId = null }) if (parentId) { tags.push(["e", parentId]); } + assert.ok( + tags.some((tag) => Array.isArray(tag) && tag[0] === "e" && tag[1] === "video123"), + "comment fixture should include root #e tag", + ); + assert.ok( + tags.some( + (tag) => + Array.isArray(tag) && tag[0] === "a" && tag[1] === "30078:authorpk:video-root", + ), + "comment fixture should include root #a tag", + ); return { id, pubkey, @@ -213,12 +224,13 @@ test( }, }); - const video = { - id: "video123", - pubkey: "AUTHORPK", - enableComments: true, - kind: 30078, - }; + const video = { + id: "video123", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + tags: [["d", "video-root"]], + }; controller.load(video); controller.submit({ text: "Nice" }); @@ -229,6 +241,7 @@ test( const payload = publishCalls[0]; assert.equal(payload.videoEventId, "video123"); assert.equal(payload.videoAuthorPubkey, "AUTHORPK"); + assert.equal(payload.videoDefinitionAddress, "30078:AUTHORPK:video-root"); }, );