From a1bf7b151f3b4f236d9990804f2529ca169a17a9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:41:46 -0400 Subject: [PATCH] feat(video): hashtags data layer (content + t tags + parser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish toward NIP-71 parity + better native discovery. Hashtags now flow through the video data layer (the input UI in the upload/edit modals is the remaining step): - videoPayloadBuilder: sanitizeVideoHashtags (normalize/dedupe, drop reserved "video"); persists hashtags in 30078 content AND emits ["t", tag] so bitvid's own feed scoring (exploreScoring/kidsScoring already read t tags) and relay queries pick them up. - convertEventToVideo: exposes video.hashtags from content → the NIP-71 mirror builder already carries them as t tags, so a shared video is categorized on other apps too. tests/video-hashtags.test.mjs: sanitize rules, content+t-tag emission, round-trip to video.hashtags, empty case. Mutation-verified (t-tag emission load-bearing). Co-Authored-By: Claude Opus 4.8 --- js/nostr/nip71.js | 6 ++++ js/nostr/videoPayloadBuilder.js | 42 +++++++++++++++++++++-- tests/video-hashtags.test.mjs | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/video-hashtags.test.mjs diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index cea83e9c..069e8b43 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -1515,6 +1515,11 @@ export function convertEventToVideo(event = {}) { const isPrivate = parsedContent.isPrivate === true; const isNsfw = parsedContent.isNsfw === true; const isForKids = parsedContent.isForKids === true && !isNsfw; + const hashtags = Array.isArray(parsedContent.hashtags) + ? parsedContent.hashtags + .filter((value) => typeof value === "string" && value.trim()) + .map((value) => value.trim()) + : []; const videoRootId = safeTrim(parsedContent.videoRootId) || event.id; const wsField = safeTrim(parsedContent.ws); const xsField = safeTrim(parsedContent.xs); @@ -1618,6 +1623,7 @@ export function convertEventToVideo(event = {}) { description, mode, deleted, + hashtags, ws, xs, storagePointer, diff --git a/js/nostr/videoPayloadBuilder.js b/js/nostr/videoPayloadBuilder.js index 82361bca..430dcb52 100644 --- a/js/nostr/videoPayloadBuilder.js +++ b/js/nostr/videoPayloadBuilder.js @@ -5,8 +5,33 @@ import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js import { buildVideoPostEvent } from "../nostrEventSchemas.js"; import { computeSha256HexFromValue } from "../utils/cryptoUtils.js"; import { inferMimeTypeFromUrl } from "../utils/mime.js"; +import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { devLogger } from "../utils/logger.js"; +// Normalize free-form hashtag input (array or comma/space string) to a deduped, +// lowercased list. Drops the reserved "video" topic tag and caps the count. +export function sanitizeVideoHashtags(input) { + const raw = Array.isArray(input) + ? input + : typeof input === "string" + ? input.split(/[\s,]+/) + : []; + const out = []; + const seen = new Set(); + for (const item of raw) { + const tag = normalizeHashtag(item); + if (!tag || tag === "video" || seen.has(tag)) { + continue; + } + seen.add(tag); + out.push(tag); + if (out.length >= 20) { + break; + } + } + return out; +} + export function extractVideoPublishPayload(rawPayload) { let videoData = rawPayload; let nip71Metadata = null; @@ -161,13 +186,24 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta contentObject.xs = finalXs; } + const hashtags = sanitizeVideoHashtags(videoData.hashtags); + if (hashtags.length) { + contentObject.hashtags = hashtags; + } + const nip71Tags = buildNip71MetadataTags( nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null ); - const additionalTags = storagePointer - ? [["s", storagePointer], ...nip71Tags] - : nip71Tags; + // Emit hashtags as `t` tags on the 30078 so bitvid's own feed scoring (and + // relay queries) pick them up, in addition to persisting them in content. + const hashtagTags = hashtags.map((tag) => ["t", tag]); + + const additionalTags = [ + ...(storagePointer ? [["s", storagePointer]] : []), + ...hashtagTags, + ...nip71Tags, + ]; const event = buildVideoPostEvent({ pubkey: normalizedPubkey, diff --git a/tests/video-hashtags.test.mjs b/tests/video-hashtags.test.mjs new file mode 100644 index 00000000..49fe3241 --- /dev/null +++ b/tests/video-hashtags.test.mjs @@ -0,0 +1,59 @@ +// Hashtags polish: a video's hashtags are persisted in the 30078 content AND +// emitted as `t` tags (so bitvid's feed scoring + relay queries see them), and +// round-trip back through convertEventToVideo onto `video.hashtags` (which the +// NIP-71 mirror builder then carries as `t` tags too). + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + sanitizeVideoHashtags, + prepareVideoPublishPayload, +} from "../js/nostr/videoPayloadBuilder.js"; +import { convertEventToVideo } from "../js/nostr/nip71.js"; + +const PUBKEY = "a".repeat(64); + +function publish(extra) { + return prepareVideoPublishPayload( + { title: "Hello", url: "https://cdn.example.com/v.mp4", ...extra }, + PUBKEY, + { timestamp: 1000 }, + ); +} + +test("sanitizeVideoHashtags: normalizes, dedupes, drops the reserved 'video' tag", () => { + assert.deepEqual( + sanitizeVideoHashtags(["#GameStr", "gamestr", " Voxel ", "video", ""]), + ["gamestr", "voxel"], + ); + assert.deepEqual(sanitizeVideoHashtags("#ai, blender cartoon"), ["ai", "blender", "cartoon"]); + assert.deepEqual(sanitizeVideoHashtags(null), []); +}); + +test("publish payload persists hashtags in content AND emits matching `t` tags", async () => { + const { event } = await publish({ + videoRootId: "root-1", + hashtags: ["#GameStr", "voxel", "voxel"], + }); + + const content = JSON.parse(event.content); + assert.deepEqual(content.hashtags, ["gamestr", "voxel"], "stored in content (normalized/deduped)"); + + const tTags = event.tags.filter((t) => t[0] === "t").map((t) => t[1]); + assert.ok(tTags.includes("gamestr")); + assert.ok(tTags.includes("voxel")); +}); + +test("round-trip: convertEventToVideo exposes video.hashtags from content", async () => { + const { event } = await publish({ videoRootId: "root-2", hashtags: ["nostr", "p2p"] }); + const video = convertEventToVideo(event); + assert.equal(video.invalid, false); + assert.deepEqual(video.hashtags.sort(), ["nostr", "p2p"]); +}); + +test("no hashtags → no content.hashtags", async () => { + const { event } = await publish({ videoRootId: "root-3" }); + const content = JSON.parse(event.content); + assert.equal(content.hashtags, undefined); +});