nip71: show creators' NIP-71 videos on their channel-profile wall (17b)

The channel-profile grid fetched only kind-30078, so a creator's NIP-71
videos (incl. their Nostube cross-posts) appeared in the main feed via
ingest but were absent from their profile wall.

New js/channelProfileVideos.js:
- buildChannelVideoFilters(pubkey): native (kind 30078, #t=video) + NIP-71
  (kinds 21/22/34235/34236) filters for the author.
- convertChannelEvent(entry): routes NIP-71 kinds through
  buildVideoFromNip71Event (which rejects bitvid's own outbound mirrors so
  they don't duplicate the canonical note); kind-30078 uses the standard
  converter.

channelProfile.js now uses these (net fewer lines, stays under size cap).
Existing buildRenderableChannelVideos dedupe-by-root + canAccess handle
precedence and whitelist/blacklist gating, so foreign videos inherit the
same gating as native ones.

Closes pre-launch TODO 17b. Tests + mutation-verified (NIP-71 routing,
mirror skip, dual-filter build). 1253/1254 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 19:20:48 -04:00
1 parent 07b212943d
commit 7236496a16
4 files changed
+123 -20

No files matched your search

+6 -11
View File
@@ -1,7 +1,7 @@
// js/channelProfile.js
import { nostrClient } from "./nostrClientFacade.js";
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { convertChannelEvent, buildChannelVideoFilters } from "./channelProfileVideos.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { subscriptions } from "./subscriptions.js";
import { attachHealthBadges } from "./gridHealth.js";
@@ -4461,7 +4461,7 @@ function normalizeRenderableChannelVideo(entry) {
typeof entry.kind === "number";
if (looksLikeRawEvent) {
const converted = sharedConvertEventToVideo(entry);
const converted = convertChannelEvent(entry);
if (converted && !converted.invalid) {
return converted;
}
@@ -5382,13 +5382,8 @@ async function loadUserVideos(pubkey) {
});
try {
// 1) Build filter for videos from this pubkey
const filter = {
kinds: [30078],
authors: [pubkey],
"#t": ["video"],
limit: 200
};
// 1) Build filters for this pubkey's videos: native kind-30078 + NIP-71.
const filters = buildChannelVideoFilters(pubkey);
// 2) Collect raw events from all relays
const events = [];
@@ -5401,7 +5396,7 @@ async function loadUserVideos(pubkey) {
try {
const fallbackEvents = await nostrClient.pool.list(
Array.from(DEFAULT_RELAY_URLS),
[filter]
filters
);
if (Array.isArray(fallbackEvents)) {
events.push(...fallbackEvents);
@@ -5412,7 +5407,7 @@ async function loadUserVideos(pubkey) {
} else {
const settled = await pMapSettled(
relayList,
(url) => nostrClient.pool.list([url], [filter]),
(url) => nostrClient.pool.list([url], filters),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
);
settled.forEach((result, index) => {
+29
View File
@@ -0,0 +1,29 @@
// Channel-profile video sourcing: a creator's wall should show BOTH their
// bitvid-native kind-30078 videos AND the NIP-71 videos they published via other
// apps (Nostube etc.) — matching what the main feed shows via ingest. Previously
// the profile fetched only kind-30078, so cross-posted NIP-71 videos were absent.
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { buildVideoFromNip71Event, NIP71_KINDS } from "./nostr/nip71IngestAdapter.js";
// Two filters for one author: native video notes (kind 30078, tagged "video")
// and their NIP-71 videos (kinds 21/22/34235/34236).
export function buildChannelVideoFilters(pubkey, { limit = 200 } = {}) {
return [
{ kinds: [30078], authors: [pubkey], "#t": ["video"], limit },
{ kinds: Array.from(NIP71_KINDS), authors: [pubkey], limit },
];
}
// Convert a raw channel event to a bitvid video object. Foreign NIP-71 kinds go
// through the ingest adapter (which also rejects bitvid's own outbound mirrors so
// they don't duplicate the canonical kind-30078 note); everything else uses the
// standard kind-30078 converter. May return an { invalid: true } object — the
// caller filters those out (same contract as convertEventToVideo).
export function convertChannelEvent(entry) {
const kind = Number(entry?.kind);
if (NIP71_KINDS.has(kind)) {
return buildVideoFromNip71Event(entry);
}
return sharedConvertEventToVideo(entry);
}
+81
View File
@@ -0,0 +1,81 @@
// Channel-profile wall must source BOTH native kind-30078 videos and the
// creator's NIP-71 videos (cross-posted via other apps), while skipping bitvid's
// own outbound mirrors so they don't duplicate the canonical note.
import assert from "node:assert/strict";
import test from "node:test";
import {
buildChannelVideoFilters,
convertChannelEvent,
} from "../js/channelProfileVideos.js";
const PK = "a".repeat(64);
test("builds a native (30078) filter AND a NIP-71 filter for the author", () => {
const filters = buildChannelVideoFilters(PK);
assert.equal(filters.length, 2);
const native = filters.find((f) => f.kinds.includes(30078));
assert.deepEqual(native.authors, [PK]);
assert.deepEqual(native["#t"], ["video"]);
const nip71 = filters.find((f) => !f.kinds.includes(30078));
assert.deepEqual(nip71.kinds.slice().sort((a, b) => a - b), [21, 22, 34235, 34236]);
assert.deepEqual(nip71.authors, [PK]);
});
test("converts a foreign NIP-71 event into a renderable video", () => {
const v = convertChannelEvent({
id: "evt",
pubkey: PK,
kind: 34235,
created_at: 1000,
content: "desc",
tags: [
["d", "root"],
["title", "Nostube clip"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
});
assert.equal(v.invalid, false);
assert.equal(v.title, "Nostube clip");
assert.equal(v.source, "nip71-ingest");
});
test("skips bitvid's own mirror (returns invalid so the caller drops it)", () => {
const v = convertChannelEvent({
id: "mirror",
pubkey: PK,
kind: 34235,
created_at: 1000,
content: "",
tags: [
["d", "root"],
["client", "bitvid"],
["title", "Our mirror"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
});
assert.equal(v.invalid, true);
assert.equal(v.reason, "bitvid-mirror");
});
test("routes kind-30078 events through the standard converter", () => {
const v = convertChannelEvent({
id: "native",
pubkey: PK,
kind: 30078,
created_at: 2000,
content: JSON.stringify({
version: 3,
title: "Native bitvid video",
videoRootId: "vr",
url: "https://example.com/native.mp4",
}),
tags: [["d", "vr"], ["t", "video"]],
});
assert.equal(v.invalid, false);
assert.equal(v.title, "Native bitvid video");
// Not stamped as ingested — it's a native note.
assert.notEqual(v.source, "nip71-ingest");
});
+7 -9
View File
@@ -317,12 +317,10 @@ notes below. Summary:
- Verified live: Goblinbox (Nostube) content renders; liveness check works.
### 17b. Channel Profile wall shows only bitvid videos, not NIP-71 (LAUNCH-BLOCKER)
- [ ] The channel-profile grid (`js/channelProfile.js`, ~line 5387) fetches ONLY
`{ kinds:[30078], authors:[pubkey], "#t":["video"] }`, so a creator's
NIP-71 videos (kinds 21/22/34235/34236 — incl. their Nostube uploads) never
appear on their profile wall even though they show in the main feed via
ingest. Fix: also fetch the author's NIP-71 video kinds and merge them
(convert via `buildVideoFromNip71Event`, skip bitvid mirrors, dedupe by
videoRootId, native-30078 wins a shared root — same precedence as ingest).
Consider reusing the ingested videos already in `nostrClient.allEvents` for
that author rather than a fresh fetch.
- [x] **Fixed** (`js/channelProfileVideos.js`): the channel grid now fetches the
author's NIP-71 videos (kinds 21/22/34235/34236) alongside kind-30078 via
`buildChannelVideoFilters`, and `convertChannelEvent` routes NIP-71 events
through `buildVideoFromNip71Event` (skips bitvid mirrors). Existing
dedupe-by-root + canAccess in `buildRenderableChannelVideos` handle
precedence/gating. Logic extracted to a helper so channelProfile.js stayed
under its size cap. Tests + mutation-verified.