From 259b493a2047811eec24f72fa0ff6d803556cb2b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:22:38 -0400 Subject: [PATCH] Add NSFW and kids flags to video schema --- docs/nostr-event-schemas.md | 2 +- js/nostr.js | 46 ++++++++++--- js/nostrEventSchemas.js | 2 + js/ui/views/VideoListView.js | 2 + tests/nip71-base-event-tags.test.mjs | 7 +- tests/video-schema-and-conversion.test.mjs | 78 ++++++++++++++++++++++ 6 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 tests/video-schema-and-conversion.test.mjs diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index f711caac..c065aa55 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -72,7 +72,7 @@ timestamps. | Note | Kind (default) | Tags | Content format | | --- | --- | --- | --- | -| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | +| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` 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', , ]` with optional address pointer `['a', , ]`, and `['p', ]` when the origin author is known; inherits schema append tags | Empty content | | 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. | diff --git a/js/nostr.js b/js/nostr.js index 4f99e4d2..548d4192 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -800,7 +800,13 @@ function extractVideoPublishPayload(rawPayload) { videoData = {}; } - return { videoData, nip71Metadata }; + const normalizedVideoData = { ...videoData }; + const isNsfw = videoData.isNsfw === true; + normalizedVideoData.isNsfw = isNsfw; + normalizedVideoData.isForKids = + videoData.isForKids === true && !isNsfw; + + return { videoData: normalizedVideoData, nip71Metadata }; } function buildVideoPointerValue(pubkey, videoRootId) { @@ -2916,6 +2922,8 @@ function convertEventToVideo(event = {}) { const mode = rawMode || "live"; const deleted = parsedContent.deleted === true; const isPrivate = parsedContent.isPrivate === true; + const isNsfw = parsedContent.isNsfw === true; + const isForKids = parsedContent.isForKids === true && !isNsfw; const videoRootId = safeTrim(parsedContent.videoRootId) || event.id; const wsField = safeTrim(parsedContent.ws); const xsField = safeTrim(parsedContent.xs); @@ -3018,6 +3026,8 @@ function convertEventToVideo(event = {}) { videoRootId, version, isPrivate, + isNsfw, + isForKids, title, url, magnet, @@ -6367,6 +6377,9 @@ export class NostrClient { const finalEnableComments = videoData.enableComments === false ? false : true; + const finalIsNsfw = videoData.isNsfw === true; + const finalIsForKids = + videoData.isForKids === true && !finalIsNsfw; const finalWs = typeof videoData.ws === "string" ? videoData.ws.trim() : ""; const finalXs = @@ -6383,6 +6396,8 @@ export class NostrClient { videoRootId, deleted: false, isPrivate: videoData.isPrivate ?? false, + isNsfw: finalIsNsfw, + isForKids: finalIsForKids, enableComments: finalEnableComments, }; @@ -6493,6 +6508,8 @@ export class NostrClient { thumbnail: contentObject.thumbnail, mode: contentObject.mode, isPrivate: contentObject.isPrivate, + isNsfw: contentObject.isNsfw, + isForKids: contentObject.isForKids, }; if (contentObject.ws) { @@ -6666,6 +6683,10 @@ export class NostrClient { // Determine if the updated note should be private const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false; + const wantNsfw = updatedData.isNsfw ?? baseEvent.isNsfw ?? false; + const wantForKids = updatedData.isForKids ?? baseEvent.isForKids ?? false; + const finalIsNsfw = wantNsfw === true; + const finalIsForKids = finalIsNsfw ? false : wantForKids === true; // Use the new magnet if provided; otherwise, fall back to the decrypted old magnet const magnetEdited = updatedData.magnetEdited === true; @@ -6709,6 +6730,8 @@ export class NostrClient { version: updatedData.version ?? baseEvent.version ?? 2, deleted: false, isPrivate: wantPrivate, + isNsfw: finalIsNsfw, + isForKids: finalIsForKids, title: updatedData.title ?? baseEvent.title, url: finalUrl, magnet: finalMagnet, @@ -6833,13 +6856,15 @@ export class NostrClient { baseEvent = { id: fetched.id, pubkey: fetched.pubkey, - content: JSON.stringify({ - version: fetched.version, - deleted: fetched.deleted, - isPrivate: fetched.isPrivate, - title: fetched.title, - url: fetched.url, - magnet: fetched.magnet, + content: JSON.stringify({ + version: fetched.version, + deleted: fetched.deleted, + isPrivate: fetched.isPrivate, + isNsfw: fetched.isNsfw, + isForKids: fetched.isForKids, + title: fetched.title, + url: fetched.url, + magnet: fetched.magnet, thumbnail: fetched.thumbnail, description: fetched.description, mode: fetched.mode, @@ -6869,11 +6894,16 @@ export class NostrClient { ? `LEGACY:${baseEvent.pubkey}:${existingD}` : baseEvent.id); + const oldIsNsfw = oldContent.isNsfw === true; + const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw; + const contentObject = { videoRootId: finalRootId, version: oldVersion, deleted: true, isPrivate: oldContent.isPrivate ?? false, + isNsfw: oldIsNsfw, + isForKids: oldIsForKids, title: oldContent.title || "", url: "", magnet: "", diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 7d60e477..2fffe883 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -118,6 +118,8 @@ const BASE_SCHEMAS = { { key: "videoRootId", type: "string", required: true }, { key: "deleted", type: "boolean", required: false }, { key: "isPrivate", type: "boolean", required: false }, + { key: "isNsfw", type: "boolean", required: false }, + { key: "isForKids", type: "boolean", required: false }, { key: "enableComments", type: "boolean", required: false }, { key: "ws", type: "string", required: false }, { key: "xs", type: "string", required: false }, diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 3bee2039..72b68e10 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -302,6 +302,8 @@ export class VideoListView { : Number(video?.created_at) || 0, deleted: Boolean(video?.deleted), isPrivate: Boolean(video?.isPrivate), + isNsfw: Boolean(video?.isNsfw), + isForKids: Boolean(video?.isForKids), thumbnail: typeof video?.thumbnail === "string" ? video.thumbnail : "", url: typeof video?.url === "string" ? video.url : "", magnet: typeof video?.magnet === "string" ? video.magnet : "", diff --git a/tests/nip71-base-event-tags.test.mjs b/tests/nip71-base-event-tags.test.mjs index 3f45ce9d..aec2e059 100644 --- a/tests/nip71-base-event-tags.test.mjs +++ b/tests/nip71-base-event-tags.test.mjs @@ -33,11 +33,17 @@ test("30078 events carry nip71 metadata tags and hydrate fallback metadata", () videoRootId: "root-123", deleted: false, isPrivate: false, + isNsfw: false, + isForKids: true, enableComments: true, }, additionalTags, }); + const parsedContent = JSON.parse(event.content); + assert.equal(parsedContent.isNsfw, false); + assert.equal(parsedContent.isForKids, true); + const hashtagValues = event.tags .filter((tag) => tag[0] === "t") .map((tag) => tag[1]) @@ -58,7 +64,6 @@ test("30078 events carry nip71 metadata tags and hydrate fallback metadata", () assert.deepEqual(referenceTag, ["r", "https://example.com/info"]); const client = new NostrClient(); - const parsedContent = JSON.parse(event.content); const video = { id: "event-123", videoRootId: parsedContent.videoRootId, diff --git a/tests/video-schema-and-conversion.test.mjs b/tests/video-schema-and-conversion.test.mjs new file mode 100644 index 00000000..facb4652 --- /dev/null +++ b/tests/video-schema-and-conversion.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + NOTE_TYPES, + getNostrEventSchema, +} from "../js/nostrEventSchemas.js"; +import { convertEventToVideo } from "../js/nostr.js"; + +test("video post schema documents nsfw and kids flags", () => { + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST); + const fieldKeys = Array.isArray(schema?.content?.fields) + ? schema.content.fields.map((field) => field?.key) + : []; + + assert.ok( + fieldKeys.includes("isNsfw"), + "schema should include isNsfw field" + ); + assert.ok( + fieldKeys.includes("isForKids"), + "schema should include isForKids field" + ); +}); + +test("convertEventToVideo normalizes nsfw and kids booleans", () => { + const nsfwEvent = { + id: "evt-nsfw", + content: JSON.stringify({ + version: 3, + title: "Flagged clip", + url: "https://cdn.example/nsfw.mp4", + videoRootId: "root-nsfw", + isNsfw: true, + isForKids: true, + }), + tags: [], + }; + + const nsfwParsed = convertEventToVideo(nsfwEvent); + assert.equal(nsfwParsed.isNsfw, true); + assert.equal( + nsfwParsed.isForKids, + false, + "isForKids should be suppressed when nsfw is true" + ); + + const kidsEvent = { + id: "evt-kids", + content: JSON.stringify({ + version: 3, + title: "Kids clip", + url: "https://cdn.example/kids.mp4", + videoRootId: "root-kids", + isForKids: true, + }), + tags: [], + }; + + const kidsParsed = convertEventToVideo(kidsEvent); + assert.equal(kidsParsed.isNsfw, false); + assert.equal(kidsParsed.isForKids, true); + + const defaultEvent = { + id: "evt-default", + content: JSON.stringify({ + version: 3, + title: "Default clip", + url: "https://cdn.example/default.mp4", + videoRootId: "root-default", + }), + tags: [], + }; + + const defaultParsed = convertEventToVideo(defaultEvent); + assert.equal(defaultParsed.isNsfw, false); + assert.equal(defaultParsed.isForKids, false); +});