From 7236496a16a1525f1d84f6580e33b67dd2eb2e8d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:20:48 -0400 Subject: [PATCH] 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 --- js/channelProfile.js | 17 ++---- js/channelProfileVideos.js | 29 ++++++++++ tests/channel-profile-videos.test.mjs | 81 +++++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 16 +++--- 4 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 js/channelProfileVideos.js create mode 100644 tests/channel-profile-videos.test.mjs diff --git a/js/channelProfile.js b/js/channelProfile.js index c4449fd9..a348d47b 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -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) => { diff --git a/js/channelProfileVideos.js b/js/channelProfileVideos.js new file mode 100644 index 00000000..474d3de7 --- /dev/null +++ b/js/channelProfileVideos.js @@ -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); +} diff --git a/tests/channel-profile-videos.test.mjs b/tests/channel-profile-videos.test.mjs new file mode 100644 index 00000000..36c73809 --- /dev/null +++ b/tests/channel-profile-videos.test.mjs @@ -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"); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 85c817cf..a20e5b91 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -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.