feat(nip71): mirror carries the video's hashtags (from t tags / nip71 metadata)

Closes the real hashtag gap. Verified the upload modal's existing NIP-71 hashtag
editor (Nip71FormManager → nip71.hashtags → buildNip71MetadataTags) ALREADY emits
`t` tags on the 30078, which bitvid's feed scoring already reads — so publish+feed
hashtags work today. The gap was the 34235 mirror reading only `video.hashtags`
(empty for form-authored videos).

buildNip71MirrorEvent now resolves hashtags from whichever source is present —
video.hashtags, video.nip71.hashtags, or the raw `t` tags on video.tags — deduped,
lowercased, with bitvid's internal "video" topic tag dropped. So a shared video
stays categorized on Nostube/Amethyst regardless of how its tags were authored.

(The earlier content.hashtags builder path from a1bf7b15 stays as a harmless
secondary input; the form/t-tag path is the one users actually exercise.)

tests/nip71-mirror.test.mjs: hashtags sourced from nip71.hashtags; from raw t tags
with the 'video' topic dropped + deduped. Mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 11:30:12 -04:00
1 parent a1bf7b151f
commit e793b359c3
4 files changed
+92 -6

No files matched your search

+18
View File
@@ -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");
+29
View File
@@ -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"]);
});