Add NIP-22 metadata to video comment events

This commit is contained in:
thePR0M3TH3AN committed 2025-10-29 14:38:59 -04:00
1 parent 4878cadfff
commit eeee978134
6 files changed
+453 -126

No files matched your search

@@ -8,10 +8,10 @@ To implement a decentralized comment system for videos shared on a Nostr-based p
### **Features**
1. **Post Comments**:
- Users can post comments on videos.
- Comments are associated with a specific video event using the `e` tag.
- Comments scope to the video definition or root event with NIP-22 uppercase tags (`A`/`E`/`I`, `K`, `P`) while still emitting lowercase fallbacks for legacy clients.
2. **Structured Threading**:
- Comments support threading by referencing parent comments using NIP-27 conventions.
- Comments support threading by referencing parent comments with the lowercase NIP-22 set (`a`/`e`/`i`, `k`, `p`).
- Threaded replies are visually nested.
3. **Reactions**:
@@ -40,18 +40,21 @@ To implement a decentralized comment system for videos shared on a Nostr-based p
Each component (comments, reactions, live chat) is represented as a Nostr event.
##### **Comment Event**:
bitvid uses standard kind `1` text note replies so they interoperate with the moderation flows documented in [`docs/moderation/nips.md`](../../docs/moderation/nips.md). Each reply keeps both the video event ID and the root video definition tag so downstream services can resolve lineage.
bitvid publishes kind `1111` comments following [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md). Each event declares its root scope with uppercase tags and keeps the lowercase fallbacks so legacy clients continue to render threads.
```json
{
"kind": 1,
"kind": 1111,
"pubkey": "abcdef1234567890...",
"created_at": 1675000000,
"tags": [
["e", "video-event-id"], // Reference to the specific video event
["a", "30078:video-author-pubkey:video-root-id"], // Reference to the video definition (kind 30078)
["e", "parent-comment-id"], // Optional: reference to the parent comment for replies
["p", "commenter-pubkey"] // Optional: commenter or video author pubkey for threading UX
["A", "30078:video-author-pubkey:video-root-id", "wss://video.example"],
["K", "30078"],
["P", "video-author-pubkey", "wss://video.example"],
["a", "30078:video-author-pubkey:video-root-id", "wss://video.example"],
["e", "parent-comment-id", "parent-author-pubkey"],
["k", "1111"],
["p", "parent-author-pubkey"]
],
"content": "This is a great video!"
}
@@ -89,7 +92,7 @@ bitvid uses standard kind `1` text note replies so they interoperate with the mo
Metadata tags are added to comments or live chat events as needed:
```json
{
"kind": 1,
"kind": 1111,
"pubkey": "abcdef1234567890...",
"created_at": 1675000000,
"tags": [
@@ -108,8 +111,8 @@ Metadata tags are added to comments or live chat events as needed:
#### **1. Posting a Comment**
To post a comment:
1. The client constructs a kind `1` comment event using NIP-22 for `created_at` validation.
2. The event includes both the `['e', <videoEventId>]` and `['a', <30078:pubkey:videoRootId>]` tags (mandatory) plus parent comment references when replying. This mirrors the implementation in `js/services/discussionCountService.js` so reply counts and moderation filters remain aligned.
1. The client constructs a kind `1111` comment event following NIP-22 threading semantics.
2. The event includes uppercase root scope tags (`['A'\|`E`\|`I`, <pointer>], `['K', <root kind>]`, `['P', <root author>]`) and carries lowercase fallbacks (`['a', ...]`, `['e', ...]`, `['k', ...]`, `['p', ...]`) for older relays and clients. Parent references append `['e', <parent id>, <relay?>?, <author?>]`, `['k', <parent kind>]`, and `['p', <parent author>]` when available.
3. The event is signed and published to relays.
##### API Example:
@@ -120,13 +123,21 @@ async function postComment(
parentCommentId = null
) {
const event = {
kind: 1,
kind: 1111,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', videoEventId],
['A', `30078:${videoAuthorPubkey}:${videoRootId}`],
['K', '30078'],
['P', videoAuthorPubkey],
['a', `30078:${videoAuthorPubkey}:${videoRootId}`],
...(parentCommentId ? [['e', parentCommentId]] : []),
...(parentCommentId
? [
['e', parentCommentId, videoAuthorPubkey],
['k', '1111'],
['p', videoAuthorPubkey],
]
: [['k', '30078'], ['p', videoAuthorPubkey]]),
],
content: commentText
};
@@ -162,7 +173,7 @@ Comments are retrieved using `REQ` messages filtered by the `e` tag for the vide
```javascript
async function fetchComments({ videoEventId, videoAuthorPubkey, videoRootId }) {
const filter = {
kinds: [1],
kinds: [1111],
'#e': [videoEventId],
'#a': [`30078:${videoAuthorPubkey}:${videoRootId}`],
limit: 100
+1 -1
View File
@@ -138,7 +138,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | Mandatory address pointer `['a', <kind:pubkey:identifier>, <relay?>?]`, optional parent pointers `['e', <parent comment id>, <relay?>?]`, and optional participant markers `['p', <pubkey>, <relay?>?]`; legacy clients may still emit `['e', <video event id>, <relay?>?]` and the ingestion pipeline accepts them; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['a'\|`e`\|`i`, <parent pointer>, <relay?>?, <author?>], `['k', <parent kind>]`, `['p', <parent author>, <relay?>?]`; 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 |
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <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 |
+154
View File
@@ -242,6 +242,140 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) {
? threadParticipantRelay.trim()
: "";
const rootIdentifier = pickString(
options.rootIdentifier,
target.rootIdentifier,
options.rootPointer,
target.rootPointer,
);
const rootIdentifierRelay = pickString(
options.rootIdentifierRelay,
target.rootIdentifierRelay,
);
const parentIdentifier = pickString(
options.parentIdentifier,
target.parentIdentifier,
);
const parentIdentifierRelay = pickString(
options.parentIdentifierRelay,
target.parentIdentifierRelay,
);
const normalizedRootIdentifier =
typeof rootIdentifier === "string" ? rootIdentifier.trim() : "";
const normalizedRootIdentifierRelay =
typeof rootIdentifierRelay === "string" ? rootIdentifierRelay.trim() : "";
const normalizedParentIdentifier =
typeof parentIdentifier === "string" ? parentIdentifier.trim() : "";
const normalizedParentIdentifierRelay =
typeof parentIdentifierRelay === "string"
? parentIdentifierRelay.trim()
: "";
const definitionSegments = normalizedVideoDefinitionAddress
? normalizedVideoDefinitionAddress.split(":")
: [];
const derivedDefinitionKind = definitionSegments[0] || "";
const derivedDefinitionPubkey = definitionSegments[1] || "";
const rootKindCandidate = pickString(
options.rootKind,
target.rootKind,
options.videoKind,
target.videoKind,
derivedDefinitionKind,
);
let normalizedRootKind =
typeof rootKindCandidate === "string" ? rootKindCandidate.trim() : "";
const parentKindCandidate = pickString(
options.parentKind,
target.parentKind,
);
let normalizedParentKind =
typeof parentKindCandidate === "string" ? parentKindCandidate.trim() : "";
if (!normalizedParentKind) {
if (normalizedParentCommentId) {
normalizedParentKind = String(COMMENT_EVENT_KIND);
} else if (normalizedRootKind) {
normalizedParentKind = normalizedRootKind;
} else if (derivedDefinitionKind) {
normalizedParentKind = derivedDefinitionKind;
}
}
if (!normalizedRootKind) {
normalizedRootKind = normalizedParentKind;
}
const rootAuthorCandidate = pickString(
options.rootAuthorPubkey,
target.rootAuthorPubkey,
derivedDefinitionPubkey,
);
let normalizedRootAuthorPubkey =
typeof rootAuthorCandidate === "string" ? rootAuthorCandidate.trim() : "";
const parentAuthorCandidate = pickString(
options.parentAuthorPubkey,
target.parentAuthorPubkey,
);
let normalizedParentAuthorPubkey =
typeof parentAuthorCandidate === "string"
? parentAuthorCandidate.trim()
: "";
if (!normalizedParentAuthorPubkey) {
normalizedParentAuthorPubkey = normalizedThreadParticipantPubkey;
}
if (!normalizedRootAuthorPubkey) {
if (!normalizedParentCommentId && normalizedParentAuthorPubkey) {
normalizedRootAuthorPubkey = normalizedParentAuthorPubkey;
}
}
const rootAuthorRelayCandidate = pickString(
options.rootAuthorRelay,
target.rootAuthorRelay,
);
let normalizedRootAuthorRelay =
typeof rootAuthorRelayCandidate === "string"
? rootAuthorRelayCandidate.trim()
: "";
if (!normalizedRootAuthorRelay && normalizedVideoDefinitionRelay) {
normalizedRootAuthorRelay = normalizedVideoDefinitionRelay;
}
const parentAuthorRelayCandidate = pickString(
options.parentAuthorRelay,
target.parentAuthorRelay,
);
let normalizedParentAuthorRelay =
typeof parentAuthorRelayCandidate === "string"
? parentAuthorRelayCandidate.trim()
: "";
if (!normalizedParentAuthorRelay) {
normalizedParentAuthorRelay = normalizedThreadParticipantRelay;
}
if (!normalizedRootAuthorRelay && !normalizedParentCommentId) {
normalizedRootAuthorRelay = normalizedParentAuthorRelay;
}
if (!normalizedRootAuthorPubkey && derivedDefinitionPubkey) {
normalizedRootAuthorPubkey = derivedDefinitionPubkey;
}
if (!normalizedParentAuthorPubkey && !normalizedParentCommentId) {
normalizedParentAuthorPubkey = normalizedRootAuthorPubkey;
}
if (!normalizedParentAuthorRelay && !normalizedParentCommentId) {
normalizedParentAuthorRelay = normalizedRootAuthorRelay;
}
if (!normalizedVideoEventId) {
return null;
}
@@ -255,6 +389,16 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) {
parentCommentRelay: normalizedParentCommentRelay,
threadParticipantPubkey: normalizedThreadParticipantPubkey,
threadParticipantRelay: normalizedThreadParticipantRelay,
rootIdentifier: normalizedRootIdentifier,
rootIdentifierRelay: normalizedRootIdentifierRelay,
parentIdentifier: normalizedParentIdentifier,
parentIdentifierRelay: normalizedParentIdentifierRelay,
rootKind: normalizedRootKind,
rootAuthorPubkey: normalizedRootAuthorPubkey,
rootAuthorRelay: normalizedRootAuthorRelay,
parentKind: normalizedParentKind,
parentAuthorPubkey: normalizedParentAuthorPubkey,
parentAuthorRelay: normalizedParentAuthorRelay,
};
}
@@ -453,10 +597,20 @@ export async function publishComment(
videoEventRelay: descriptor.videoEventRelay,
videoDefinitionAddress: descriptor.videoDefinitionAddress,
videoDefinitionRelay: descriptor.videoDefinitionRelay,
rootIdentifier: descriptor.rootIdentifier,
rootIdentifierRelay: descriptor.rootIdentifierRelay,
parentCommentId: descriptor.parentCommentId,
parentCommentRelay: descriptor.parentCommentRelay,
threadParticipantPubkey: descriptor.threadParticipantPubkey,
threadParticipantRelay: descriptor.threadParticipantRelay,
rootKind: descriptor.rootKind,
rootAuthorPubkey: descriptor.rootAuthorPubkey,
rootAuthorRelay: descriptor.rootAuthorRelay,
parentKind: descriptor.parentKind,
parentAuthorPubkey: descriptor.parentAuthorPubkey,
parentAuthorRelay: descriptor.parentAuthorRelay,
parentIdentifier: descriptor.parentIdentifier,
parentIdentifierRelay: descriptor.parentIdentifierRelay,
additionalTags,
content,
});
+189 -59
View File
@@ -242,7 +242,13 @@ const BASE_SCHEMAS = {
videoEventTagName: "e",
videoDefinitionTagName: "a",
parentCommentTagName: "e",
participantTagName: "p",
parentAuthorTagName: "p",
rootEventPointerTagName: "E",
rootDefinitionPointerTagName: "A",
rootIdentifierPointerTagName: "I",
rootKindTagName: "K",
rootAuthorTagName: "P",
parentKindTagName: "k",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
@@ -879,88 +885,212 @@ export function buildCommentEvent({
videoEventRelay = "",
videoDefinitionAddress = "",
videoDefinitionRelay = "",
rootIdentifier = "",
rootIdentifierRelay = "",
parentCommentId = "",
parentCommentRelay = "",
threadParticipantPubkey = "",
threadParticipantRelay = "",
rootKind,
rootAuthorPubkey = "",
rootAuthorRelay = "",
parentKind,
parentAuthorPubkey = "",
parentAuthorRelay = "",
parentIdentifier = "",
parentIdentifierRelay = "",
additionalTags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const tags = [];
const normalizedVideoEventId =
typeof videoEventId === "string" ? videoEventId.trim() : "";
const normalizedVideoEventRelay =
typeof videoEventRelay === "string" ? videoEventRelay.trim() : "";
const normalizeString = (value) =>
typeof value === "string" ? value.trim() : "";
const normalizedVideoEventId = normalizeString(videoEventId);
const normalizedVideoEventRelay = normalizeString(videoEventRelay);
const normalizedVideoDefinitionAddress = normalizeString(videoDefinitionAddress);
const normalizedVideoDefinitionRelay = normalizeString(videoDefinitionRelay);
const normalizedRootIdentifier = normalizeString(rootIdentifier);
const normalizedRootIdentifierRelay = normalizeString(rootIdentifierRelay);
const normalizedParentCommentId = normalizeString(parentCommentId);
const normalizedParentCommentRelay = normalizeString(parentCommentRelay);
const normalizedParentIdentifier = normalizeString(parentIdentifier);
const normalizedParentIdentifierRelay = normalizeString(parentIdentifierRelay);
const normalizedThreadParticipantPubkey = normalizeString(
threadParticipantPubkey,
);
const normalizedThreadParticipantRelay = normalizeString(
threadParticipantRelay,
);
let resolvedRootKind = normalizeString(rootKind);
let resolvedRootAuthorPubkey = normalizeString(rootAuthorPubkey);
let resolvedRootAuthorRelay = normalizeString(rootAuthorRelay);
if (normalizedVideoDefinitionAddress) {
const definitionSegments = normalizedVideoDefinitionAddress.split(":");
if (!resolvedRootKind && definitionSegments[0]) {
resolvedRootKind = definitionSegments[0];
}
if (!resolvedRootAuthorPubkey && definitionSegments[1]) {
resolvedRootAuthorPubkey = definitionSegments[1];
}
if (!resolvedRootAuthorRelay && normalizedVideoDefinitionRelay) {
resolvedRootAuthorRelay = normalizedVideoDefinitionRelay;
}
}
let resolvedParentAuthorPubkey = normalizeString(parentAuthorPubkey);
if (!resolvedParentAuthorPubkey) {
resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey;
}
let resolvedParentAuthorRelay = normalizeString(parentAuthorRelay);
if (!resolvedParentAuthorRelay) {
resolvedParentAuthorRelay = normalizedThreadParticipantRelay;
}
if (!resolvedRootAuthorPubkey && !normalizedParentCommentId) {
resolvedRootAuthorPubkey = resolvedParentAuthorPubkey;
}
if (!resolvedRootAuthorRelay && !normalizedParentCommentId) {
resolvedRootAuthorRelay = resolvedParentAuthorRelay;
}
if (!resolvedParentAuthorPubkey && !normalizedParentCommentId) {
resolvedParentAuthorPubkey = resolvedRootAuthorPubkey;
}
if (!resolvedParentAuthorRelay && !normalizedParentCommentId) {
resolvedParentAuthorRelay = resolvedRootAuthorRelay;
}
let resolvedParentKind = normalizeString(parentKind);
if (!resolvedParentKind) {
if (normalizedParentCommentId) {
resolvedParentKind = String(schema?.kind ?? 1111);
} else if (resolvedRootKind) {
resolvedParentKind = resolvedRootKind;
}
}
if (!resolvedRootKind) {
resolvedRootKind = resolvedParentKind;
}
const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A";
const rootEventPointerTagName = schema?.rootEventPointerTagName || "E";
const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I";
const rootKindTagName = schema?.rootKindTagName || "K";
const rootAuthorTagName = schema?.rootAuthorTagName || "P";
const videoDefinitionTagName = schema?.videoDefinitionTagName || "a";
const normalizedVideoDefinitionAddress =
typeof videoDefinitionAddress === "string"
? videoDefinitionAddress.trim()
: "";
const normalizedVideoDefinitionRelay =
typeof videoDefinitionRelay === "string" ? videoDefinitionRelay.trim() : "";
const videoEventTagName = schema?.videoEventTagName || "e";
const parentCommentTagName = schema?.parentCommentTagName || "e";
const parentAuthorTagName = schema?.parentAuthorTagName || "p";
const parentKindTagName = schema?.parentKindTagName || "k";
const appendPointerTag = (tagName, value, relay, authorHint) => {
if (!tagName || !value) {
return;
}
const tag = [tagName, value];
if (relay) {
tag.push(relay);
}
if (authorHint) {
tag.push(authorHint);
}
tags.push(tag);
};
if (normalizedRootIdentifier) {
appendPointerTag(
rootIdentifierPointerTagName,
normalizedRootIdentifier,
normalizedRootIdentifierRelay,
);
} else if (normalizedVideoDefinitionAddress) {
appendPointerTag(
rootDefinitionPointerTagName,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
);
} else if (normalizedVideoEventId) {
appendPointerTag(
rootEventPointerTagName,
normalizedVideoEventId,
normalizedVideoEventRelay,
resolvedRootAuthorPubkey,
);
}
if (rootKindTagName && resolvedRootKind) {
tags.push([rootKindTagName, resolvedRootKind]);
}
if (rootAuthorTagName && resolvedRootAuthorPubkey) {
if (resolvedRootAuthorRelay) {
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey, resolvedRootAuthorRelay]);
} else {
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey]);
}
}
const includeVideoEventTag =
!normalizedVideoDefinitionAddress &&
Boolean(schema?.videoEventTagName || "e") &&
Boolean(videoEventTagName) &&
Boolean(normalizedVideoEventId);
if (includeVideoEventTag) {
const videoEventTagName = schema?.videoEventTagName || "e";
if (normalizedVideoEventRelay) {
tags.push([videoEventTagName, normalizedVideoEventId, normalizedVideoEventRelay]);
} else {
tags.push([videoEventTagName, normalizedVideoEventId]);
}
appendPointerTag(
videoEventTagName,
normalizedVideoEventId,
normalizedVideoEventRelay,
);
}
if (videoDefinitionTagName && normalizedVideoDefinitionAddress) {
if (normalizedVideoDefinitionRelay) {
tags.push([
videoDefinitionTagName,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
]);
} else {
tags.push([videoDefinitionTagName, normalizedVideoDefinitionAddress]);
}
appendPointerTag(
videoDefinitionTagName,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
);
}
if (normalizedParentIdentifier) {
appendPointerTag("i", normalizedParentIdentifier, normalizedParentIdentifierRelay);
}
const parentCommentTagName = schema?.parentCommentTagName || "e";
const normalizedParentCommentId =
typeof parentCommentId === "string" ? parentCommentId.trim() : "";
const normalizedParentCommentRelay =
typeof parentCommentRelay === "string" ? parentCommentRelay.trim() : "";
if (parentCommentTagName && normalizedParentCommentId) {
if (normalizedParentCommentRelay) {
tags.push([
parentCommentTagName,
normalizedParentCommentId,
normalizedParentCommentRelay,
]);
} else {
tags.push([parentCommentTagName, normalizedParentCommentId]);
}
appendPointerTag(
parentCommentTagName,
normalizedParentCommentId,
normalizedParentCommentRelay,
resolvedParentAuthorPubkey,
);
}
const participantTagName = schema?.participantTagName || "p";
const normalizedThreadParticipantPubkey =
typeof threadParticipantPubkey === "string"
? threadParticipantPubkey.trim()
: "";
const normalizedThreadParticipantRelay =
typeof threadParticipantRelay === "string"
? threadParticipantRelay.trim()
: "";
if (participantTagName && normalizedThreadParticipantPubkey) {
if (normalizedThreadParticipantRelay) {
tags.push([
participantTagName,
normalizedThreadParticipantPubkey,
normalizedThreadParticipantRelay,
]);
} else {
tags.push([participantTagName, normalizedThreadParticipantPubkey]);
if (parentKindTagName && resolvedParentKind) {
tags.push([parentKindTagName, resolvedParentKind]);
}
if (parentAuthorTagName && resolvedParentAuthorPubkey) {
const existingParentAuthorTag = tags.find(
(tag) => Array.isArray(tag) && tag[0] === parentAuthorTagName && tag[1] === resolvedParentAuthorPubkey,
);
if (!existingParentAuthorTag) {
if (resolvedParentAuthorRelay) {
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey, resolvedParentAuthorRelay]);
} else {
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey]);
}
} else if (resolvedParentAuthorRelay) {
if (existingParentAuthorTag.length < 3) {
existingParentAuthorTag.push(resolvedParentAuthorRelay);
} else if (existingParentAuthorTag[2] !== resolvedParentAuthorRelay) {
existingParentAuthorTag[2] = resolvedParentAuthorRelay;
}
}
}
+61 -28
View File
@@ -10,29 +10,29 @@ import {
const VIDEO_COMMENT_KIND =
getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT)?.kind ?? 1111;
test("buildCommentEvent references the video definition when available", () => {
test("buildCommentEvent emits NIP-22 root metadata while keeping legacy fallbacks", () => {
const event = buildCommentEvent({
pubkey: "commenter",
created_at: 1700000200,
videoEventId: "video-event-id",
videoEventRelay: "wss://relay.example",
videoDefinitionAddress: "30078:deadbeefcafebabe:clip-1",
threadParticipantPubkey: "deadbeefcafebabe",
content: "Great \ud800video!",
});
assert.equal(event.kind, VIDEO_COMMENT_KIND);
const addressTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "a");
assert.deepEqual(addressTags, [["a", "30078:deadbeefcafebabe:clip-1"]]);
const participantTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p");
assert.deepEqual(participantTags, [["p", "deadbeefcafebabe"]]);
assert.deepStrictEqual(event.tags, [
["A", "30078:deadbeefcafebabe:clip-1"],
["K", "30078"],
["P", "deadbeefcafebabe"],
["a", "30078:deadbeefcafebabe:clip-1"],
["k", "30078"],
["p", "deadbeefcafebabe"],
]);
assert.equal(event.content, "Great video!");
});
test("buildCommentEvent includes parent pointers for threaded replies", () => {
test("buildCommentEvent includes parent pointers, kinds, and authors for replies", () => {
const event = buildCommentEvent({
pubkey: "commenter",
created_at: 1700000201,
@@ -44,18 +44,18 @@ test("buildCommentEvent includes parent pointers for threaded replies", () => {
});
assert.equal(event.kind, VIDEO_COMMENT_KIND);
const eventTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
assert.deepEqual(eventTags, [["e", "parent-comment-id"]]);
const addressTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "a");
assert.deepEqual(addressTags, [["a", "30078:deadbeefcafebabe:clip-1"]]);
const participantTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p");
assert.deepEqual(participantTags, [["p", "parent-author"]]);
assert.deepStrictEqual(event.tags, [
["A", "30078:deadbeefcafebabe:clip-1"],
["K", "30078"],
["P", "deadbeefcafebabe"],
["a", "30078:deadbeefcafebabe:clip-1"],
["e", "parent-comment-id", "parent-author"],
["k", String(VIDEO_COMMENT_KIND)],
["p", "parent-author"],
]);
});
test("buildCommentEvent normalizes optional relays and preserves additional tags", () => {
test("buildCommentEvent normalizes relays and preserves explicit overrides", () => {
const event = buildCommentEvent({
pubkey: "author",
created_at: 1700000300,
@@ -73,8 +73,12 @@ test("buildCommentEvent normalizes optional relays and preserves additional tags
assert.equal(event.kind, VIDEO_COMMENT_KIND);
assert.deepStrictEqual(event.tags, [
["A", "30078:deadbeefcafebabe:clip-2", "wss://video.def"],
["K", "30078"],
["P", "deadbeefcafebabe", "wss://video.def"],
["a", "30078:deadbeefcafebabe:clip-2", "wss://video.def"],
["e", "root-comment", "wss://parent"],
["e", "root-comment", "wss://parent", "cafecafe"],
["k", String(VIDEO_COMMENT_KIND)],
["p", "cafecafe", "wss://profile"],
["client", "bitvid"],
["p", "cafecafe", "wss://override"],
@@ -82,21 +86,50 @@ test("buildCommentEvent normalizes optional relays and preserves additional tags
assert.equal(event.content, " Appreciated! ");
});
test("buildCommentEvent falls back to the video event when no definition address is provided", () => {
test("buildCommentEvent falls back to event pointers when no address is supplied", () => {
const event = buildCommentEvent({
pubkey: "commenter",
created_at: 1700000400,
videoEventId: "legacy-event",
videoEventRelay: "wss://legacy.example",
videoEventId: "legacy-event",
videoEventRelay: "wss://legacy.example",
threadParticipantPubkey: "legacy-pubkey",
rootKind: "1063",
rootAuthorPubkey: "legacy-author",
content: "Legacy thread support",
});
assert.equal(event.kind, VIDEO_COMMENT_KIND);
assert.deepStrictEqual(event.tags, [
["E", "legacy-event", "wss://legacy.example", "legacy-author"],
["K", "1063"],
["P", "legacy-author"],
["e", "legacy-event", "wss://legacy.example"],
["k", "1063"],
["p", "legacy-pubkey"],
]);
});
const eventTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
assert.deepEqual(eventTags, [["e", "legacy-event", "wss://legacy.example"]]);
test("buildCommentEvent accepts partial metadata for parent overrides", () => {
const event = buildCommentEvent({
pubkey: "reply-author",
created_at: 1700000500,
videoEventId: "file-event",
rootKind: "1063",
parentCommentId: "parent-comment",
parentCommentRelay: "wss://parent",
parentKind: "1111",
parentAuthorPubkey: "parent-only",
parentAuthorRelay: "wss://parent-author",
content: "Reply with sparse data",
});
const participantTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p");
assert.deepEqual(participantTags, [["p", "legacy-pubkey"]]);
assert.equal(event.kind, VIDEO_COMMENT_KIND);
assert.deepStrictEqual(event.tags, [
["E", "file-event"],
["K", "1063"],
["e", "file-event"],
["e", "parent-comment", "wss://parent", "parent-only"],
["k", "1111"],
["p", "parent-only", "wss://parent-author"],
]);
});
+22 -23
View File
@@ -131,17 +131,18 @@ test("publishComment prefers active signer when available", async () => {
assert.equal(signerCalls.length, 1, "active signer should sign the event once");
const eventTags = buildCommentEventTags(signerCalls[0]);
const eTags = eventTags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
const aTags = eventTags.filter((tag) => Array.isArray(tag) && tag[0] === "a");
assert.deepEqual(
eTags,
[["e", "parent-comment-id"]],
"comment event should include parent pointer but omit the addressable video id",
);
assert.deepEqual(
aTags,
[["a", "30078:deadbeef:clip", "wss://definition-relay"]],
"comment event should target the video definition via #a tag",
eventTags,
[
["A", "30078:deadbeef:clip", "wss://definition-relay"],
["K", "30078"],
["P", "deadbeef", "wss://definition-relay"],
["a", "30078:deadbeef:clip", "wss://definition-relay"],
["e", "parent-comment-id", "thread-participant"],
["k", String(COMMENT_EVENT_KIND)],
["p", "thread-participant"],
],
"comment event should include NIP-22 root tags while preserving fallback markers",
);
});
@@ -216,18 +217,17 @@ test("publishComment accepts legacy targets with only an event id", async () =>
const publishedEvent = publishCalls[0]?.event;
const tags = buildCommentEventTags(publishedEvent);
const eTags = tags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
const aTags = tags.filter((tag) => Array.isArray(tag) && tag[0] === "a");
assert.deepEqual(
eTags,
tags,
[
["E", "legacy-event-id"],
["K", String(COMMENT_EVENT_KIND)],
["e", "legacy-event-id"],
["e", "parent-legacy"],
["k", String(COMMENT_EVENT_KIND)],
],
"legacy publish should target the video event id and parent comment",
"legacy publish should emit uppercase root metadata with lowercase fallbacks",
);
assert.deepEqual(aTags, [], "no address tag should be emitted when unavailable");
});
test("publishComment emits only video event #e tag when address and parent are absent", async () => {
@@ -254,15 +254,14 @@ test("publishComment emits only video event #e tag when address and parent are a
const publishedEvent = publishCalls[0]?.event;
const tags = buildCommentEventTags(publishedEvent);
const eTags = tags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
const aTags = tags.filter((tag) => Array.isArray(tag) && tag[0] === "a");
assert.deepEqual(
eTags,
[["e", "video-only"]],
"event should carry the video pointer via #e",
tags,
[
["E", "video-only"],
["e", "video-only"],
],
"event should include an uppercase root pointer along with the legacy #e tag",
);
assert.deepEqual(aTags, [], "event should omit #a tags when no address is provided");
});
test("listVideoComments builds filters with #e primary and #a secondary", async () => {