Add video comment schema and builder

This commit is contained in:
thePR0M3TH3AN committed 2025-10-24 13:54:24 -04:00
1 parent 34dd506f05
commit bedc0fd5c6
3 files changed
+206

No files matched your search

+1
View File
@@ -138,6 +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`) | `1` | Mandatory `['e', <video event id>, <relay?>?]` and `['a', <kind:pubkey:identifier>, <relay?>?]` tags, optional parent pointers `['e', <parent comment id>, <relay?>?]`, and optional participant markers `['p', <pubkey>, <relay?>?]`; 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 |
+150
View File
@@ -17,6 +17,7 @@ export const NOTE_TYPES = Object.freeze({
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
VIDEO_REACTION: "videoReaction",
VIDEO_COMMENT: "videoComment",
WATCH_HISTORY_INDEX: "watchHistoryIndex",
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
SUBSCRIPTION_LIST: "subscriptionList",
@@ -64,6 +65,33 @@ function ensureValidUtf8Content(value) {
normalized = String(value);
}
if (normalized) {
const builder = [];
let mutated = false;
for (let index = 0; index < normalized.length; index += 1) {
const code = normalized.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = normalized.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
builder.push(normalized[index], normalized[index + 1]);
index += 1;
} else {
mutated = true;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
mutated = true;
} else {
builder.push(normalized[index]);
}
}
if (mutated) {
devLogger.warn(
"[nostrEventSchemas] Dropping unmatched surrogate characters from event content"
);
normalized = builder.join("");
}
}
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
cachedUtf8Encoder = new TextEncoder();
}
@@ -206,6 +234,20 @@ const BASE_SCHEMAS = {
"Reaction payload for the referenced event (e.g., '+', '-', or emoji).",
},
},
[NOTE_TYPES.VIDEO_COMMENT]: {
type: NOTE_TYPES.VIDEO_COMMENT,
label: "Video comment",
kind: 1,
videoEventTagName: "e",
videoDefinitionTagName: "a",
parentCommentTagName: "e",
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Plain text comment body sanitized for UTF-8 compatibility.",
},
},
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
label: "Watch history index",
@@ -814,6 +856,114 @@ export function buildReactionEvent({
};
}
export function buildCommentEvent({
pubkey,
created_at,
videoEventId = "",
videoEventRelay = "",
videoDefinitionAddress = "",
videoDefinitionRelay = "",
parentCommentId = "",
parentCommentRelay = "",
threadParticipantPubkey = "",
threadParticipantRelay = "",
additionalTags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const tags = [];
const videoEventTagName = schema?.videoEventTagName || "e";
const normalizedVideoEventId =
typeof videoEventId === "string" ? videoEventId.trim() : "";
const normalizedVideoEventRelay =
typeof videoEventRelay === "string" ? videoEventRelay.trim() : "";
if (videoEventTagName && normalizedVideoEventId) {
if (normalizedVideoEventRelay) {
tags.push([videoEventTagName, normalizedVideoEventId, normalizedVideoEventRelay]);
} else {
tags.push([videoEventTagName, normalizedVideoEventId]);
}
}
const videoDefinitionTagName = schema?.videoDefinitionTagName || "a";
const normalizedVideoDefinitionAddress =
typeof videoDefinitionAddress === "string"
? videoDefinitionAddress.trim()
: "";
const normalizedVideoDefinitionRelay =
typeof videoDefinitionRelay === "string" ? videoDefinitionRelay.trim() : "";
if (videoDefinitionTagName && normalizedVideoDefinitionAddress) {
if (normalizedVideoDefinitionRelay) {
tags.push([
videoDefinitionTagName,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
]);
} else {
tags.push([videoDefinitionTagName, normalizedVideoDefinitionAddress]);
}
}
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]);
}
}
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 (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
return {
kind: schema?.kind ?? 1,
pubkey,
created_at,
tags,
content: resolvedContent,
};
}
export function buildWatchHistoryIndexEvent({
pubkey,
created_at,
+55
View File
@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildCommentEvent } from "../js/nostrEventSchemas.js";
test("buildCommentEvent produces required tags for top-level comments", () => {
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, 1);
const eventTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
assert.deepEqual(eventTags, [["e", "video-event-id", "wss://relay.example"]]);
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.equal(event.content, "Great video!");
});
test("buildCommentEvent includes parent pointers for threaded replies", () => {
const event = buildCommentEvent({
pubkey: "commenter",
created_at: 1700000201,
videoEventId: "video-event-id",
videoDefinitionAddress: "30078:deadbeefcafebabe:clip-1",
parentCommentId: "parent-comment-id",
threadParticipantPubkey: "parent-author",
content: "Replying now",
});
assert.equal(event.kind, 1);
const eventTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "e");
assert.deepEqual(eventTags, [
["e", "video-event-id"],
["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"]]);
});