mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 03:12:31 +00:00
Add NIP-22 metadata to video comment events
This commit is contained in:
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user