diff --git a/js/nostr/nip71Mirror.js b/js/nostr/nip71Mirror.js index ff1c4a5a..7a7faf74 100644 --- a/js/nostr/nip71Mirror.js +++ b/js/nostr/nip71Mirror.js @@ -46,6 +46,35 @@ export function deriveVideoMime(url) { return VIDEO_MIME_BY_EXT[ext] || "video/mp4"; } +// Hashtags can live in several places depending on how the video was authored: +// bitvid's own `hashtags`, the parsed NIP-71 metadata (`nip71.hashtags`), or the +// raw `t` tags on the 30078 (produced by the upload form's NIP-71 hashtag editor). +// Source from whichever is present so a shared video stays categorized elsewhere. +// Drops bitvid's internal "video" topic tag and dedupes (case-insensitive). +function resolveHashtags(video) { + let raw = []; + if (Array.isArray(video.hashtags) && video.hashtags.length) { + raw = video.hashtags; + } else if (Array.isArray(video.nip71?.hashtags) && video.nip71.hashtags.length) { + raw = video.nip71.hashtags; + } else if (Array.isArray(video.tags)) { + raw = video.tags + .filter((t) => Array.isArray(t) && t[0] === "t" && typeof t[1] === "string") + .map((t) => t[1]); + } + const out = []; + const seen = new Set(); + for (const value of raw) { + const tag = str(value).toLowerCase(); + if (!tag || tag === "video" || seen.has(tag)) { + continue; + } + seen.add(tag); + out.push(tag); + } + return out; +} + function normalizePublishedAt(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { @@ -114,9 +143,7 @@ export function buildNip71MirrorEvent(video, options = {}) { duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, }; - const hashtags = Array.isArray(video.hashtags) - ? video.hashtags.map(str).filter(Boolean) - : []; + const hashtags = resolveHashtags(video); const metadata = { title, diff --git a/js/nostr/videoPayloadBuilder.js b/js/nostr/videoPayloadBuilder.js index 430dcb52..db3da3af 100644 --- a/js/nostr/videoPayloadBuilder.js +++ b/js/nostr/videoPayloadBuilder.js @@ -474,6 +474,16 @@ export function prepareVideoEditPayload(params) { contentObject.xs = finalXs; } + // Prefer edited hashtags, else preserve the base event's so an edit that omits + // the field never strips them. + const hashtags = sanitizeVideoHashtags( + updatedData?.hashtags !== undefined ? updatedData.hashtags : baseEvent.hashtags, + ); + if (hashtags.length) { + contentObject.hashtags = hashtags; + } + const hashtagTags = hashtags.map((tag) => ["t", tag]); + let metadataForTags = nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null; if (!metadataForTags) { @@ -489,9 +499,11 @@ export function prepareVideoEditPayload(params) { const nip71Tags = buildNip71MetadataTags(metadataForTags); - const additionalTags = storagePointer - ? [["s", storagePointer], ...nip71Tags] - : nip71Tags; + const additionalTags = [ + ...(storagePointer ? [["s", storagePointer]] : []), + ...hashtagTags, + ...nip71Tags, + ]; // A replaceable event only wins if its created_at is strictly greater than // the version it replaces (NIP-01 keeps the highest created_at; ties go to diff --git a/tests/nip71-mirror.test.mjs b/tests/nip71-mirror.test.mjs index fb93a57b..2acaea27 100644 --- a/tests/nip71-mirror.test.mjs +++ b/tests/nip71-mirror.test.mjs @@ -139,6 +139,24 @@ test("explicit short flag overrides dimension inference", async () => { assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND); }); +test("mirror sources hashtags from nip71.hashtags when video.hashtags is absent", async () => { + const v = baseVideo({ hashtags: undefined, nip71: { hashtags: ["alpha", "beta"] } }); + const res = buildNip71MirrorEvent(v); + const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort(); + assert.deepEqual(tTags, ["alpha", "beta"]); +}); + +test("mirror sources hashtags from raw `t` tags, dropping the internal 'video' topic", async () => { + const v = baseVideo({ + hashtags: undefined, + tags: [["t", "video"], ["t", "Gamestr"], ["t", "gamestr"], ["d", "root-123"]], + }); + const res = buildNip71MirrorEvent(v); + const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]); + assert.ok(!tTags.includes("video"), "must not leak the bitvid 'video' topic tag"); + assert.deepEqual(tTags, ["gamestr"], "deduped + lowercased"); +}); + test("deriveVideoMime maps common extensions and defaults to mp4", async () => { assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm"); assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL"); diff --git a/tests/video-hashtags.test.mjs b/tests/video-hashtags.test.mjs index 49fe3241..a28bf1ed 100644 --- a/tests/video-hashtags.test.mjs +++ b/tests/video-hashtags.test.mjs @@ -9,6 +9,7 @@ import test from "node:test"; import { sanitizeVideoHashtags, prepareVideoPublishPayload, + prepareVideoEditPayload, } from "../js/nostr/videoPayloadBuilder.js"; import { convertEventToVideo } from "../js/nostr/nip71.js"; @@ -57,3 +58,31 @@ test("no hashtags → no content.hashtags", async () => { const content = JSON.parse(event.content); assert.equal(content.hashtags, undefined); }); + +function edit(baseHashtags, updatedData) { + return prepareVideoEditPayload({ + baseEvent: { + id: "e1", + videoRootId: "root-9", + title: "Old", + url: "https://cdn.example.com/v.mp4", + hashtags: baseHashtags, + created_at: 1000, + tags: [], + }, + originalEventStub: { id: "e1" }, + updatedData, + userPubkey: PUBKEY, + resolveEventDTag: () => "root-9", + }); +} + +test("editing WITHOUT a hashtags field preserves the existing hashtags", () => { + const { contentObject } = edit(["alpha", "beta"], { title: "New title" }); + assert.deepEqual(contentObject.hashtags, ["alpha", "beta"], "not stripped on edit"); +}); + +test("editing WITH hashtags overrides them", () => { + const { contentObject } = edit(["alpha"], { hashtags: ["gamma", "delta"] }); + assert.deepEqual(contentObject.hashtags, ["gamma", "delta"]); +});