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

+30 -3
View File
@@ -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,
+15 -3
View File
@@ -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