From 974fdc836d1fc011578509d84b8f06ef87fff318 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:27:00 -0400 Subject: [PATCH 01/83] =?UTF-8?q?charts:=20dual-line=20popularity=20chart?= =?UTF-8?q?=20=E2=80=94=20views=20(red)=20+=20zaps=20(orange)=20over=20tim?= =?UTF-8?q?e=20(#47=20=C2=A75.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-video Popularity modal now plots a second, orange zaps-over-time line beside the red views line, with a legend and — new — VISIBLE x-axis date ticks (the old chart only put the range in the aria-label). - buildZapSatsTimeSeries: buckets cumulative SATS from 9735 receipts + verified bitvid tallies, deduped by payment_hash (same rule as the badge store) so the chart and badge agree. - buildPopularityChartSvg: two token-colored cumulative lines on a shared time x-axis, each with its OWN y scale (independent per-series — D5), plus date-tick labels. Views = .text-accent, zaps = .text-zap (new --color-zap utility). Legend is plain DOM in the modal; headline gains "· N sats zapped". - js/zapEventsFacade.js: lists a pointer's [9735, ZAP_TALLY_KIND] events via the L1 SubscriptionManager for the chart; modal re-renders on onZapTotalsChanged. - All flag-gated (FEATURE_ZAP_TALLY); the existing views-only chart + its test are untouched (buildViewCountChartSvg kept). Verified live earlier that imports resolve (app builds/runs). Tests: tests/zap-time-series-chart.test.mjs (5) + existing view-count-chart green. Lint + build green. Co-Authored-By: Claude Opus 4.8 --- css/tailwind.source.css | 28 +++ docs/zap-tally-plan.md | 12 +- js/viewCountChart.js | 291 ++++++++++++++++++++++++++- js/zapEventsFacade.js | 71 +++++++ tests/zap-time-series-chart.test.mjs | 110 ++++++++++ 5 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 js/zapEventsFacade.js create mode 100644 tests/zap-time-series-chart.test.mjs diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 4392010a..1cf66059 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2923,6 +2923,34 @@ font-variant-numeric: tabular-nums; } + /* Zap orange as a currentColor source for the popularity chart's zaps line + + legend swatch (mirrors how the views line uses .text-accent). */ + .text-zap { + color: var(--color-zap); + } + + .popularity-legend { + display: flex; + gap: var(--space-md); + align-items: center; + font-size: var(--font-size-2xs); + color: var(--color-text-muted); + margin-block-start: var(--space-2xs); + } + + .popularity-legend__item { + display: inline-flex; + align-items: center; + gap: var(--space-3xs); + } + + .popularity-legend__swatch { + inline-size: var(--space-sm); + block-size: var(--size-3px, 0.1875rem); + border-radius: var(--radius-full); + background-color: currentColor; + } + /* #43: always-visible active-identity pill. Rendered after the header in flow and pulled up with a negative block-start margin so it sits half-on / half-off the header divider — every pane shows who you're acting as. */ diff --git a/docs/zap-tally-plan.md b/docs/zap-tally-plan.md index 068dbb1a..ae6a2b29 100644 --- a/docs/zap-tally-plan.md +++ b/docs/zap-tally-plan.md @@ -471,12 +471,16 @@ tallies simply stop being counted. No data migration. - [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify. - [x] `lint` + `build` + suites green; pushed to `unstable`. - [ ] **Remaining v1 work:** - - [ ] **Profile-zap counting:** the store queries `#a`/`#e` only — add - `#p` batches so a profile-only tally (no video pointer) is fetched, - and a channel-page total UI surface (D2 chose video+profile). + - [ ] **Profile-zap counting (remaining):** the store queries `#a`/`#e` + only — add `#p` batches so a profile-only tally (no video pointer) is + fetched, and a channel-page total UI surface (D2 chose video+profile). - [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess` so retried shares publish tallies too. (commit afed041f) - - [ ] **Popularity chart §5.9:** orange zaps line + legend + date axis. + - [x] **Popularity chart §5.9 DONE:** orange zaps-over-time line beside + the red views line, legend (Views/Zaps), visible date-axis ticks, + and a "· N sats zapped" headline. buildZapSatsTimeSeries (dedup by + payment_hash) + buildPopularityChartSvg + zapEventsFacade. Tests: + tests/zap-time-series-chart.test.mjs. - [ ] Live end-to-end on unstable (real zap → tally on relay → second browser counts it); rollout per §9. diff --git a/js/viewCountChart.js b/js/viewCountChart.js index c7c337d3..8de8ef0f 100644 --- a/js/viewCountChart.js +++ b/js/viewCountChart.js @@ -15,7 +15,20 @@ import { subscribeToVideoViewCount, unsubscribeFromVideoViewCount, } from "./viewCounter.js"; +import { listVideoZapEventsWithDefaultClient } from "./zapEventsFacade.js"; +import { + getVideoZapTotalSnapshot, + requestVideoZapTotal, + onZapTotalsChanged, + ZAP_RECEIPT_KIND, +} from "./zapTotals.js"; +import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js"; +import { + verifyBitvidZapTally, + extractBolt11Fields, +} from "./payments/zapReceiptValidator.js"; import { VIEW_COUNT_BACKFILL_MAX_DAYS } from "./config.js"; +import { FEATURE_ZAP_TALLY } from "./constants.js"; import { devLogger } from "./utils/logger.js"; export const VIEW_CHART_WINDOW_SECONDS = 86400; // 1 day, matches the counter dedupe window @@ -58,6 +71,73 @@ export function buildViewCountTimeSeries( return { series, total: cumulative }; } +// Read the authoritative sats for one zap event (9735 receipt or bitvid tally), +// plus a dedup key (payment_hash preferred, else event id). Returns null when +// the event isn't a countable zap (invalid tally, no amount, …). +function readZapEvent(event, tools) { + const getSats = tools?.nip57?.getSatoshisAmountFromBolt11; + if (event?.kind === ZAP_TALLY_KIND) { + const verdict = verifyBitvidZapTally(event, { getSats }); + if (!verdict?.ok || !(verdict.sats > 0)) { + return null; + } + return { sats: verdict.sats, dedup: verdict.paymentHash || event.id }; + } + if (event?.kind === ZAP_RECEIPT_KIND && event?.id) { + const bolt11 = + (Array.isArray(event.tags) + ? event.tags.find((t) => Array.isArray(t) && t[0] === "bolt11")?.[1] + : "") || ""; + let sats = 0; + try { + const parsed = typeof getSats === "function" ? Number(getSats(bolt11)) : NaN; + sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0; + } catch (error) { + sats = 0; + } + if (sats <= 0) { + return null; + } + const dedup = extractBolt11Fields(bolt11).paymentHash || event.id; + return { sats, dedup }; + } + return null; +} + +// Bucket zap events into a cumulative SATS-over-time series, deduped by +// payment_hash across 9735s + tallies (same rule as the zap-totals store), so +// the chart and the badge agree. Returns ordered buckets + the total. +export function buildZapSatsTimeSeries( + events, + { windowSeconds = VIEW_CHART_WINDOW_SECONDS, tools = null } = {}, +) { + const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS); + const seenPayments = new Set(); + const perBucket = new Map(); + for (const event of Array.isArray(events) ? events : []) { + const createdAt = Number(event?.created_at); + if (!Number.isFinite(createdAt) || createdAt <= 0) { + continue; + } + const read = readZapEvent(event, tools); + if (!read || seenPayments.has(read.dedup)) { + continue; + } + seenPayments.add(read.dedup); + const bucket = Math.floor(createdAt / w); + perBucket.set(bucket, (perBucket.get(bucket) || 0) + read.sats); + } + const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b); + const series = []; + let cumulative = 0; + for (const bucket of buckets) { + const sats = perBucket.get(bucket) || 0; + cumulative += sats; + series.push({ bucketStart: bucket * w, sats, cumulative }); + } + return { series, total: cumulative }; +} + function formatDay(seconds) { try { return new Date(seconds * 1000).toLocaleDateString(undefined, { @@ -138,6 +218,128 @@ export function buildViewCountChartSvg(doc, series, { width = 320, height = 120 return svg; } +// Draw one cumulative series (area + line) into `svg` using a color class on a +// wrapping (currentColor). x()/y() map data → pixels. Independent per-series +// Y scaling (D5): the caller passes this series' own maxY. +function drawSeries(doc, svg, points, { x, y, baseY, colorClass }) { + if (!points.length) return; + const coords = + points.length === 1 + ? [ + [x(points[0].bucketStart), y(points[0].cumulative)], + [x(points[points.length - 1].bucketStart) + 1, y(points[0].cumulative)], + ] + : points.map((p) => [x(p.bucketStart), y(p.cumulative)]); + const linePoints = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(" "); + const areaPoints = + `${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` + + linePoints + + ` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`; + + const group = doc.createElementNS(SVG_NS, "g"); + if (colorClass) group.classList.add(colorClass); + + const area = doc.createElementNS(SVG_NS, "polygon"); + area.setAttribute("points", areaPoints); + area.setAttribute("fill", "currentColor"); + area.setAttribute("fill-opacity", "0.15"); + area.setAttribute("stroke", "none"); + group.appendChild(area); + + const line = doc.createElementNS(SVG_NS, "polyline"); + line.setAttribute("points", linePoints); + line.setAttribute("fill", "none"); + line.setAttribute("stroke", "currentColor"); + line.setAttribute("stroke-width", "2"); + line.setAttribute("stroke-linejoin", "round"); + line.setAttribute("stroke-linecap", "round"); + group.appendChild(line); + + svg.appendChild(group); +} + +// Popularity chart with TWO cumulative series on a shared TIME x-axis, each with +// its OWN y scale (views in counts, zaps in sats — independent per-series +// scaling shows each trend honestly; the legend names the units). Adds VISIBLE +// x-axis date ticks. Views = accent (red), zaps = --color-zap (orange). +export function buildPopularityChartSvg( + doc, + { views = [], zaps = [] } = {}, + { width = 320, height = 132 } = {}, +) { + const pad = { top: 8, right: 8, bottom: 26, left: 8 }; + const svg = doc.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute("width", "100%"); + svg.setAttribute("role", "img"); + svg.setAttribute("preserveAspectRatio", "none"); + + const viewsPts = Array.isArray(views) ? views : []; + const zapsPts = Array.isArray(zaps) ? zaps : []; + if (!viewsPts.length && !zapsPts.length) { + svg.setAttribute("aria-label", "No views or zaps yet"); + return svg; + } + + const innerW = Math.max(1, width - pad.left - pad.right); + const innerH = Math.max(1, height - pad.top - pad.bottom); + const baseY = pad.top + innerH; + + // Shared time domain across both series. + const allX = [...viewsPts, ...zapsPts].map((p) => p.bucketStart); + const minX = Math.min(...allX); + const maxX = Math.max(...allX); + const spanX = maxX - minX || 1; + const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW; + + // Per-series y scale (each fills the height against its own max). + const makeY = (pts) => { + const maxY = (pts[pts.length - 1]?.cumulative) || 1; + return (cumulative) => pad.top + (1 - cumulative / maxY) * innerH; + }; + + drawSeries(doc, svg, viewsPts, { + x, + y: makeY(viewsPts), + baseY, + colorClass: "text-accent", + }); + drawSeries(doc, svg, zapsPts, { + x, + y: makeY(zapsPts), + baseY, + colorClass: "text-zap", + }); + + // Visible x-axis date ticks (min → max, a few evenly spaced) so the reader + // knows the time range — the old chart only put dates in the aria-label. + const tickCount = spanX > 1 ? 3 : 1; + for (let i = 0; i < tickCount; i += 1) { + const t = tickCount === 1 ? minX : minX + (spanX * i) / (tickCount - 1); + const label = doc.createElementNS(SVG_NS, "text"); + const px = x(t); + label.setAttribute("x", px.toFixed(1)); + label.setAttribute("y", (height - 8).toFixed(1)); + label.setAttribute("font-size", "9"); + label.setAttribute("fill", "currentColor"); + label.setAttribute("fill-opacity", "0.6"); + label.setAttribute( + "text-anchor", + i === 0 ? "start" : i === tickCount - 1 ? "end" : "middle", + ); + label.textContent = formatDay(t); + svg.appendChild(label); + } + + const viewsTotal = viewsPts[viewsPts.length - 1]?.cumulative || 0; + const zapsTotal = zapsPts[zapsPts.length - 1]?.cumulative || 0; + svg.setAttribute( + "aria-label", + `${viewsTotal} views and ${zapsTotal} sats zapped from ${formatDay(minX)} to ${formatDay(maxX)}`, + ); + return svg; +} + // ---- Modal ------------------------------------------------------------------- function pointerForVideo(video) { @@ -173,6 +375,11 @@ export function openPopularityModal({ video, listViewEvents = listVideoViewEventsWithDefaultClient, subscribeViewEvents = subscribeVideoViewEventsWithDefaultClient, + listZapEvents = listVideoZapEventsWithDefaultClient, + getTools = () => + (typeof globalThis !== "undefined" && + (globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ || globalThis.NostrTools)) || + null, mount = null, } = {}) { if (!doc) return null; @@ -216,11 +423,28 @@ export function openPopularityModal({ const chartHost = el(doc, "div", "popularity-modal__chart"); dialog.appendChild(chartHost); + // Legend: red = Views, orange = Zaps (sats). Token colors via .text-accent / + // .text-zap on the swatch (currentColor). Only shown when the feature is on. + const legend = el(doc, "div", "popularity-legend"); + const legendItem = (colorClass, label) => { + const item = el(doc, "span", "popularity-legend__item"); + item.appendChild(el(doc, "span", `popularity-legend__swatch ${colorClass}`)); + item.appendChild(el(doc, "span", "", label)); + return item; + }; + legend.appendChild(legendItem("text-accent", "Views")); + if (FEATURE_ZAP_TALLY) { + legend.appendChild(legendItem("text-zap", "Zaps (sats)")); + } + dialog.appendChild(legend); + const note = el( doc, "p", "text-2xs text-muted mt-2", - "Public view data · updates as more views load.", + FEATURE_ZAP_TALLY + ? "Public view + zap data · updates as more load." + : "Public view data · updates as more views load.", ); dialog.appendChild(note); @@ -229,8 +453,10 @@ export function openPopularityModal({ // ---- data + render ---- const eventsById = new Map(); + const zapEventsById = new Map(); let closed = false; let unsubscribe = null; + let unsubscribeZapTotals = null; let countToken = null; let counterTotal = null; let rerenderTimer = null; @@ -242,6 +468,13 @@ export function openPopularityModal({ return true; }; + const addZapEvent = (event) => { + const id = typeof event?.id === "string" ? event.id : ""; + if (!id || zapEventsById.has(id)) return false; + zapEventsById.set(id, event); + return true; + }; + // The headline number must match what the card / counter shows everywhere // (the authoritative, deduped, cached viewCounter total) — NOT a separate count // re-derived from a one-off relay list (which varies by relay response and @@ -254,17 +487,33 @@ export function openPopularityModal({ if (!Number.isFinite(total)) { return; } - totalLine.textContent = + const viewsText = total > 0 ? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}` : "No views recorded yet."; + // Zap total from the shared store (matches the badge), appended when on. + if (FEATURE_ZAP_TALLY) { + let zapSats = 0; + try { + zapSats = getVideoZapTotalSnapshot(pointer) || 0; + } catch (error) { + zapSats = 0; + } + totalLine.textContent = + zapSats > 0 ? `${viewsText} · ${formatViewCount(zapSats)} sats zapped` : viewsText; + } else { + totalLine.textContent = viewsText; + } }; const renderChart = () => { if (closed) return; - const { series } = buildViewCountTimeSeries([...eventsById.values()]); + const { series: views } = buildViewCountTimeSeries([...eventsById.values()]); + const { series: zaps } = FEATURE_ZAP_TALLY + ? buildZapSatsTimeSeries([...zapEventsById.values()], { tools: getTools() }) + : { series: [] }; chartHost.textContent = ""; - chartHost.appendChild(buildViewCountChartSvg(doc, series)); + chartHost.appendChild(buildPopularityChartSvg(doc, { views, zaps })); renderHeadline(); }; @@ -297,6 +546,13 @@ export function openPopularityModal({ // best effort } } + if (typeof unsubscribeZapTotals === "function") { + try { + unsubscribeZapTotals(); + } catch (error) { + // best effort + } + } doc.removeEventListener("keydown", onKeydown, true); backdrop.remove(); if (activePopularityModal === handle) { @@ -362,6 +618,33 @@ export function openPopularityModal({ devLogger.warn("[popularity] Failed to subscribe to view events:", error); } + // Zaps-over-time (orange line): fetch the pointer's zap events (9735 + tally) + // for the chart, and (headline) request the shared total + re-render when the + // zap-totals store changes. Gated by the feature flag. + if (FEATURE_ZAP_TALLY) { + try { + requestVideoZapTotal(pointer); // primes the store for the headline + unsubscribeZapTotals = onZapTotalsChanged(() => { + if (!closed) renderHeadline(); + }); + } catch (error) { + devLogger.warn("[popularity] Failed to subscribe to zap totals:", error); + } + Promise.resolve() + .then(() => listZapEvents(pointer, { since })) + .then((events) => { + if (closed) return; + let added = false; + for (const event of Array.isArray(events) ? events : []) { + if (addZapEvent(event)) added = true; + } + if (added) renderChart(); + }) + .catch((error) => { + devLogger.warn("[popularity] Failed to load zap events:", error); + }); + } + const handle = { close }; activePopularityModal = handle; return handle; diff --git a/js/zapEventsFacade.js b/js/zapEventsFacade.js new file mode 100644 index 00000000..659240ef --- /dev/null +++ b/js/zapEventsFacade.js @@ -0,0 +1,71 @@ +// js/zapEventsFacade.js +// +// Lists the zap events for a video/profile pointer so the Popularity chart can +// draw a zaps-over-time series (docs/zap-tally-plan.md §5.9). Fetches BOTH real +// NIP-57 receipts (kind 9735) and bitvid tallies (ZAP_TALLY_KIND); the chart's +// buildZapSatsTimeSeries verifies tallies + dedups by payment_hash, so this just +// needs to return the raw events. Routed through the L1 SubscriptionManager +// (relay caps + in-flight dedupe), matching the zap-totals store. + +import { nostrClient } from "./nostrClientFacade.js"; +import { ZAP_RECEIPT_KIND } from "./zapTotals.js"; +import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js"; +import { devLogger } from "./utils/logger.js"; + +const LIMIT = 500; + +// pointer: the tag-style ["a"|"e", value, relay?] array resolveVideoPointer +// produces (or an { type, value } object). +function pointerFilterKey(pointer) { + const type = Array.isArray(pointer) + ? pointer[0] + : pointer && typeof pointer === "object" + ? pointer.type + : ""; + const value = Array.isArray(pointer) + ? pointer[1] + : pointer && typeof pointer === "object" + ? pointer.value + : ""; + if ((type === "a" || type === "e") && typeof value === "string" && value.trim()) { + return { tag: `#${type}`, value: value.trim() }; + } + return null; +} + +export async function listVideoZapEventsWithDefaultClient( + pointer, + { since, client = nostrClient } = {}, +) { + const pf = pointerFilterKey(pointer); + if (!pf) { + return []; + } + const manager = + typeof client?.getSubscriptionManager === "function" + ? client.getSubscriptionManager() + : null; + const relays = Array.isArray(client?.relays) ? client.relays : []; + if (!manager || typeof manager.list !== "function" || !relays.length) { + return []; + } + + const filter = { + kinds: [ZAP_RECEIPT_KIND, ZAP_TALLY_KIND], + [pf.tag]: [pf.value], + limit: LIMIT, + }; + if (Number.isFinite(since) && since > 0) { + filter.since = Math.floor(since); + } + + try { + const events = await manager.list({ relays, filters: [filter] }); + return Array.isArray(events) ? events : []; + } catch (error) { + devLogger.warn("[zapEvents] Failed to list zap events:", error); + return []; + } +} + +export default listVideoZapEventsWithDefaultClient; diff --git a/tests/zap-time-series-chart.test.mjs b/tests/zap-time-series-chart.test.mjs new file mode 100644 index 00000000..11b65a0e --- /dev/null +++ b/tests/zap-time-series-chart.test.mjs @@ -0,0 +1,110 @@ +// #47 §5.9: the popularity chart gains a zaps-over-time (orange) series with a +// visible date axis. buildZapSatsTimeSeries buckets cumulative SATS from 9735 +// receipts + verified tallies, deduped by payment_hash (same rule as the badge +// store). buildPopularityChartSvg draws two token-colored lines + date ticks. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-zap-sats-series +// given: "zap events (9735) across days, incl. a duplicate payment_hash" +// when: "buildZapSatsTimeSeries buckets them" +// then: "cumulative sats per day; a repeated payment counts once" +// - id: SCN-popularity-svg +// given: "views + zaps cumulative series" +// when: "buildPopularityChartSvg renders" +// then: "two series (accent + zap colors) and visible date ticks" +// observable_outcomes: +// - "series buckets/cumulative/total" +// - "SVG polyline count, series color classes, date-tick text present" +// determinism_controls: +// - "in-test bech32 bolt11 vector; injected getSats; JSDOM" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { bech32 } from "../vendor/crypto-helpers.bundle.min.js"; +import { + buildZapSatsTimeSeries, + buildPopularityChartSvg, +} from "../js/viewCountChart.js"; + +const lenWords = (n) => [n >> 5, n & 31]; +function bolt11WithPaymentHash(fillByte) { + const pWords = bech32.toWords(new Uint8Array(32).fill(fillByte)); + const words = [0, 0, 0, 0, 0, 0, 0, 1, ...lenWords(pWords.length), ...pWords, ...new Array(104).fill(0)]; + return bech32.encode("lnbc", words, 2000); +} +const DAY = 86400; +const receipt = (id, createdAt, fillByte) => ({ + id, + kind: 9735, + created_at: createdAt, + tags: [["bolt11", bolt11WithPaymentHash(fillByte)]], +}); +const tools = { nip57: { getSatoshisAmountFromBolt11: () => 500 } }; + +test("buildZapSatsTimeSeries: cumulative sats per day", () => { + const events = [ + receipt("a", 10 * DAY + 100, 0x11), + receipt("b", 11 * DAY + 100, 0x22), + receipt("c", 11 * DAY + 200, 0x33), + ]; + const { series, total } = buildZapSatsTimeSeries(events, { tools }); + assert.equal(total, 1500, "3 × 500 sats"); + assert.equal(series.length, 2, "two day buckets"); + assert.equal(series[0].sats, 500); + assert.equal(series[1].sats, 1000); + assert.equal(series[1].cumulative, 1500, "cumulative across days"); +}); + +test("buildZapSatsTimeSeries: a repeated payment_hash is counted once", () => { + const events = [ + receipt("a", 10 * DAY, 0xab), + receipt("b", 10 * DAY, 0xab), // same payment_hash (same bolt11 fill) → dup + ]; + const { total } = buildZapSatsTimeSeries(events, { tools }); + assert.equal(total, 500, "deduped by payment_hash, not 1000"); +}); + +test("buildZapSatsTimeSeries: empty/garbage → empty series", () => { + assert.deepEqual(buildZapSatsTimeSeries([], { tools }), { series: [], total: 0 }); + assert.deepEqual(buildZapSatsTimeSeries(null, { tools }), { series: [], total: 0 }); +}); + +test("buildPopularityChartSvg: two colored lines + visible date ticks", () => { + const doc = new JSDOM("").window.document; + const views = [ + { bucketStart: 10 * DAY, cumulative: 3 }, + { bucketStart: 12 * DAY, cumulative: 8 }, + ]; + const zaps = [ + { bucketStart: 11 * DAY, cumulative: 500 }, + { bucketStart: 12 * DAY, cumulative: 2100 }, + ]; + const svg = buildPopularityChartSvg(doc, { views, zaps }); + + const polylines = svg.querySelectorAll("polyline"); + assert.equal(polylines.length, 2, "one line per series"); + assert.ok(svg.querySelector("g.text-accent polyline"), "views line uses the accent color"); + assert.ok(svg.querySelector("g.text-zap polyline"), "zaps line uses the zap color"); + + const ticks = svg.querySelectorAll("text"); + assert.ok(ticks.length >= 2, "visible x-axis date ticks rendered"); + assert.ok( + Array.from(ticks).every((t) => t.textContent && t.textContent.length > 0), + "each tick has a formatted date label", + ); + assert.match(svg.getAttribute("aria-label"), /views and .* sats zapped/); +}); + +test("buildPopularityChartSvg: no data → labelled empty, no lines", () => { + const doc = new JSDOM("").window.document; + const svg = buildPopularityChartSvg(doc, { views: [], zaps: [] }); + assert.equal(svg.querySelectorAll("polyline").length, 0); + assert.match(svg.getAttribute("aria-label"), /No views or zaps/i); +}); From cf82978060c8dc45532762895c2785f33d8949d2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:36:44 -0400 Subject: [PATCH 02/83] =?UTF-8?q?zaps:=20profile-zap=20counting=20+=20chan?= =?UTF-8?q?nel=20"sats=20zapped"=20total=20(#47=20=E2=80=94=20closes=20the?= =?UTF-8?q?=20feature)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store now supports p: (profile/channel) pointers — every zap tagged to a creator pubkey (direct profile zaps AND zaps to their videos, both carry a `p` tag), from 9735 receipts + verified tallies: - pointerKey accepts "p"; new profilePointer(pubkey) helper. - runBatch batches p: pointers into a #p filter; both folds count #p-tagged events (deduped by payment_hash like a:/e:). - Channel page: a "⚡ N sats zapped" total under the lightning address, wired to the store via js/channelZapTotal.js (own module for channelProfile.js's size budget), hidden until nonzero. Completes the D2 video + profile scope; the bitvid-native zap tally feature is now feature-complete (publish + count + chart + video + profile). Tests: profilePointer/pointerKey + #p counting in tests/zap-totals.test.mjs. Lint (size cap ok) + build green. Co-Authored-By: Claude Opus 4.8 --- docs/zap-tally-plan.md | 9 +++-- js/channelProfile.js | 4 +++ js/channelZapTotal.js | 68 ++++++++++++++++++++++++++++++++++++++ js/zapTotals.js | 28 ++++++++++++++-- tests/zap-totals.test.mjs | 38 +++++++++++++++++++++ views/channel-profile.html | 7 ++++ 6 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 js/channelZapTotal.js diff --git a/docs/zap-tally-plan.md b/docs/zap-tally-plan.md index ae6a2b29..46ea8a21 100644 --- a/docs/zap-tally-plan.md +++ b/docs/zap-tally-plan.md @@ -471,9 +471,12 @@ tallies simply stop being counted. No data migration. - [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify. - [x] `lint` + `build` + suites green; pushed to `unstable`. - [ ] **Remaining v1 work:** - - [ ] **Profile-zap counting (remaining):** the store queries `#a`/`#e` - only — add `#p` batches so a profile-only tally (no video pointer) is - fetched, and a channel-page total UI surface (D2 chose video+profile). + - [x] **Profile-zap counting DONE:** pointerKey/store support `p:` + pointers (profilePointer(pubkey)); runBatch queries `#p` and both + folds count `#p`-tagged 9735s + tallies. Channel page shows a + "⚡ N sats zapped" total (js/channelZapTotal.js, hidden until + nonzero). Tests in tests/zap-totals.test.mjs. This CLOSES the zap + tally feature (D2 video + profile). - [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess` so retried shares publish tallies too. (commit afed041f) - [x] **Popularity chart §5.9 DONE:** orange zaps-over-time line beside diff --git a/js/channelProfile.js b/js/channelProfile.js index af3ff7dd..5c476341 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2,6 +2,7 @@ import { nostrClient } from "./nostrClientFacade.js"; import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js"; +import { wireChannelZapTotal } from "./channelZapTotal.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5227,6 +5228,9 @@ function applyChannelProfileMetadata({ } setChannelZapVisibility(Boolean(lightningAddress)); + + // #47: total sats zapped to this creator (p: pointer in the store). + wireChannelZapTotal(currentChannelHex); } async function fetchChannelProfileFromRelays(pubkey) { diff --git a/js/channelZapTotal.js b/js/channelZapTotal.js new file mode 100644 index 00000000..b502e4a4 --- /dev/null +++ b/js/channelZapTotal.js @@ -0,0 +1,68 @@ +// js/channelZapTotal.js +// +// Channel-page "sats zapped to this creator" total (#47, docs/zap-tally-plan.md +// D2 profile path). Uses a p: pointer in the shared zap-totals store, so +// it counts every zap tagged to the creator — direct profile zaps AND zaps to +// their videos (both carry a `p` tag) — from 9735 receipts + verified bitvid +// tallies. Hidden until there's a nonzero total. Kept out of channelProfile.js +// (file-size budget). + +import { + profilePointer, + requestVideoZapTotal, + getVideoZapTotalSnapshot, + onZapTotalsChanged, +} from "./zapTotals.js"; +import { formatViewCount } from "./viewCounter.js"; +import { FEATURE_ZAP_TALLY } from "./constants.js"; + +let unsub = null; + +function teardown() { + if (typeof unsub === "function") { + try { + unsub(); + } catch (error) { + // best effort + } + unsub = null; + } +} + +export function teardownChannelZapTotal() { + teardown(); +} + +export function wireChannelZapTotal(pubkey, { document: doc = globalThis.document } = {}) { + teardown(); + const el = doc && typeof doc.getElementById === "function" + ? doc.getElementById("channelZapTotal") + : null; + if (!el) { + return; + } + const pointer = FEATURE_ZAP_TALLY ? profilePointer(pubkey) : null; + if (!pointer) { + el.classList.add("hidden"); + return; + } + const render = () => { + let sats = 0; + try { + sats = getVideoZapTotalSnapshot(pointer) || 0; + } catch (error) { + sats = 0; + } + if (sats > 0) { + el.textContent = `⚡ ${formatViewCount(sats)} sats zapped`; + el.classList.remove("hidden"); + } else { + el.classList.add("hidden"); + } + }; + requestVideoZapTotal(pointer); // primes the store (also schedules the fetch) + render(); + unsub = onZapTotalsChanged(render); +} + +export default wireChannelZapTotal; diff --git a/js/zapTotals.js b/js/zapTotals.js index a35dd2bc..28bb3ed7 100644 --- a/js/zapTotals.js +++ b/js/zapTotals.js @@ -39,14 +39,16 @@ function tagValues(event, name) { // Accepts BOTH pointer shapes used in the app: the canonical tag-style array // ["a"|"e", value, relay?] (what deriveVideoPointerInfo/resolveVideoPointer // produce) and the object form { type, value }. +const POINTER_TYPES = new Set(["a", "e", "p"]); + export function pointerKey(pointer) { let type = ""; let value = ""; if (Array.isArray(pointer)) { - type = pointer[0] === "a" || pointer[0] === "e" ? pointer[0] : ""; + type = POINTER_TYPES.has(pointer[0]) ? pointer[0] : ""; value = typeof pointer[1] === "string" ? pointer[1].trim() : ""; } else if (pointer && typeof pointer === "object") { - type = pointer.type === "a" || pointer.type === "e" ? pointer.type : ""; + type = POINTER_TYPES.has(pointer.type) ? pointer.type : ""; value = typeof pointer.value === "string" ? pointer.value.trim() : ""; } if (!type || !value) { @@ -55,6 +57,14 @@ export function pointerKey(pointer) { return `${type}:${value}`; } +// A `p:` (profile/channel) pointer for a creator pubkey — the total sats zapped +// to that pubkey via bitvid (direct profile zaps AND zaps to their videos, both +// tagged `p`). Used by the channel page; distinct key space from video a:/e:. +export function profilePointer(pubkey) { + const hex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + return hex ? ["p", hex] : null; +} + // Sats carried by one receipt: bolt11 first (the actual paid invoice), then // the zap request's amount tag (msats) embedded in the description. export function extractReceiptAmountSats(event, tools) { @@ -207,9 +217,11 @@ export function createZapTotalsStore({ const aValues = []; const eValues = []; + const pValues = []; for (const pointer of batch.values()) { if (pointer.type === "a") aValues.push(pointer.value); else if (pointer.type === "e") eValues.push(pointer.value); + else if (pointer.type === "p") pValues.push(pointer.value); } // One query for both sources: real 9735 receipts + bitvid tallies. @@ -236,6 +248,15 @@ export function createZapTotalsStore({ limit: RECEIPTS_PER_FILTER_LIMIT, }); } + // Profile/channel totals: every zap to this pubkey (direct or via a video) + // is tagged `#p`. Only queried when a p: pointer is requested (channel page). + for (let i = 0; i < pValues.length; i += MAX_POINTERS_PER_FILTER) { + filters.push({ + kinds, + "#p": pValues.slice(i, i + MAX_POINTERS_PER_FILTER), + limit: RECEIPTS_PER_FILTER_LIMIT, + }); + } let events = []; try { @@ -267,6 +288,7 @@ export function createZapTotalsStore({ const keys = [ ...tagValues(event, "a").map((value) => `a:${value}`), ...tagValues(event, "e").map((value) => `e:${value}`), + ...tagValues(event, "p").map((value) => `p:${value}`), ].filter((key) => batch.has(key) || totals.has(key)); if (!keys.length) { continue; @@ -310,7 +332,7 @@ export function createZapTotalsStore({ continue; } const keys = verdict.pointerTags - .filter((t) => t[0] === "a" || t[0] === "e") + .filter((t) => t[0] === "a" || t[0] === "e" || t[0] === "p") .map((t) => `${t[0]}:${t[1]}`) .filter((key) => batch.has(key) || totals.has(key)); for (const key of keys) { diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs index 15e34ca9..bc279061 100644 --- a/tests/zap-totals.test.mjs +++ b/tests/zap-totals.test.mjs @@ -32,10 +32,13 @@ import { createZapTotalsStore, extractReceiptAmountSats, pointerKey, + profilePointer, ZAP_RECEIPT_KIND, } from "../js/zapTotals.js"; import { createMostZappedSorter } from "../js/feedEngine/sorters.js"; +const CREATOR_HEX = "c".repeat(64); + const A1 = "30078:aaaa:video-1"; const A2 = "30078:bbbb:video-2"; @@ -304,6 +307,41 @@ test("cross-source dedup: a 9735 and a tally with the same payment_hash count on ); }); +test("profilePointer + pointerKey accept p: (channel total)", () => { + assert.deepEqual(profilePointer(CREATOR_HEX), ["p", CREATOR_HEX]); + assert.equal(profilePointer(" " + CREATOR_HEX.toUpperCase() + " ")[1], CREATOR_HEX); + assert.equal(profilePointer(""), null); + assert.equal(pointerKey(["p", CREATOR_HEX]), `p:${CREATOR_HEX}`); + assert.equal(pointerKey({ type: "p", value: CREATOR_HEX }), `p:${CREATOR_HEX}`); +}); + +test("profile counting: a p: pointer counts #p-tagged zaps (direct + via video)", async () => { + // A video zap's 9735 carries both #a (video) and #p (creator). Querying the + // creator's p: pointer should count it toward the channel total. + const store = createZapTotalsStore({ + persistKey: null, + isTallyEnabled: () => false, // 9735-only for this test + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ + list: async () => [ + receipt("r1", [["p", CREATOR_HEX], ["a", A1], amountTag(1500000)]), // 1500 sats + receipt("r2", [["p", CREATOR_HEX], amountTag(600000)]), // 600 sats, direct + ], + }), + }), + schedule: (fn) => { fn(); return 1; }, + }); + store.request({ type: "p", value: CREATOR_HEX }); + await store.flush(); + assert.equal( + store.getSnapshot({ type: "p", value: CREATOR_HEX }), + 2100, + "channel total sums every #p-tagged zap", + ); +}); + test("tally counting is gated: disabled → tallies ignored", async () => { const store = makeTallyStore({ enabled: false, diff --git a/views/channel-profile.html b/views/channel-profile.html index 32939924..76d68571 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -140,6 +140,13 @@ >Website

+ + From 96228a451b03e40a1b3342ce5a35e753f50b3aa8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:18:38 -0400 Subject: [PATCH 03/83] =?UTF-8?q?zaps:=20fix=20zap=20totals=20counting=20z?= =?UTF-8?q?ero=20=E2=80=94=20coordinate=20used=20kind=200=20instead=20of?= =?UTF-8?q?=2030078?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zap write path (zapSplit.js derivePointerTag) built the `a` coordinate tag straight from `videoEvent.kind`. bitvid's internal video objects don't always carry the Nostr kind — it can be 0 or undefined — and the inline builder only rejected non-finite kinds, so a kind-0 object produced a `0:pubkey:d` coordinate (Number.isFinite(0) === true). This flowed into both the kind-9734 zap request and the kind-30081 bitvid tally. The read side (zapTotals / mostZappedFeed) fetches totals by querying `30078:pubkey:d` via buildVideoAddressPointer, which defaults invalid/zero kinds to 30078. So the writer emitted `0:...` while the reader queried `30078:...` — they never matched and every zap counted as zero, even after a cache clear + refresh. Confirmed against live relays: 4 published bitvid tallies all carried `0:...` coordinates while the real video events are kind 30078. Fix: derivePointerTag now delegates to the same canonical buildVideoAddressPointer the reader uses, keeping writer and reader coordinates in lockstep. Adds a regression test driving splitAndZap with kind 0 / undefined / non-numeric video objects, asserting the zap request's `a` tag defaults to `30078:pubkey:d`. Co-Authored-By: Claude Opus 4.8 --- js/payments/zapSplit.js | 26 +++++++++------------- tests/zap-split.test.mjs | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index ee74a449..be755b42 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -21,6 +21,7 @@ import { resolvePlatformFeePercent, } from "./platformFee.js"; import { HEX64_REGEX } from "../utils/hex.js"; +import { buildVideoAddressPointer } from "../utils/videoPointer.js"; const ZAP_KIND = 9734; @@ -271,21 +272,16 @@ function decodeAdminPubkey() { } function derivePointerTag(videoEvent) { - if (!videoEvent || typeof videoEvent !== "object") { - return null; - } - if (!Array.isArray(videoEvent.tags)) { - return null; - } - const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]); - if (!dTag) { - return null; - } - if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") { - return null; - } - const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`; - return ["a", value]; + // Delegate to the app's canonical pointer derivation — the SAME one the read + // side (zapTotals / mostZappedFeed) uses to fetch totals. bitvid's internal + // video objects don't always carry the Nostr `kind` (it can be 0/undefined), + // and the old inline builder trusted `videoEvent.kind` verbatim: a kind-0 + // object produced a `0:pubkey:d` coordinate (Number.isFinite(0) === true), + // which the reader — querying `30078:pubkey:d` — could never match, so zaps + // silently counted as zero. buildVideoAddressPointer defaults invalid/zero + // kinds to 30078, keeping writer and reader coordinates in lockstep. + const value = buildVideoAddressPointer(videoEvent); + return value ? ["a", value] : null; } function resolveRelayUrls(wallet) { diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 31c5931f..fe00a42a 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -334,6 +334,53 @@ async function testSplitMath() { delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; } +// Regression: bitvid's internal video objects don't always carry the Nostr +// `kind` — it can be 0 or undefined. The zap request's `a` (coordinate) tag +// MUST still address the video as kind 30078, because the read side +// (zapTotals / mostZappedFeed) fetches totals by querying `30078:pubkey:d`. +// A kind-0 coordinate (`0:pubkey:d`) publishes fine but can never be matched, +// so zaps silently count as zero. This asserts the coordinate defaults to +// 30078 regardless of the source object's kind. +async function testZapPointerDefaultsToVideoKindWhenMissing() { + const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; + + const { deps } = createDeps(); + const callbackUrl = "https://lnurl.example/api/callback"; + const bech32Address = encodeLnurlBech32(callbackUrl); + const pubkey = "d".repeat(64); + + for (const brokenKind of [0, undefined, "not-a-number"]) { + const videoEvent = { + id: "event-id", + pubkey, + lightningAddress: bech32Address, + tags: [["d", "1782939097186-elrw1x3fdvg"]], + kind: brokenKind, + }; + + const result = await splitAndZap( + { videoEvent, amountSats: 500, comment: "PointerKind" }, + deps + ); + + const parsedZap = JSON.parse(result.receipts[0].zapRequest); + const aTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "a"); + assert(aTag, `zap request should carry an 'a' coordinate (kind=${brokenKind})`); + assert.equal( + aTag[1], + `30078:${pubkey}:1782939097186-elrw1x3fdvg`, + `coordinate must address the video as kind 30078, not '${brokenKind}'` + ); + } + + if (previousOverride === undefined) { + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + } else { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride; + } +} + async function testBech32LightningAddressZapTag() { const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; @@ -1046,6 +1093,7 @@ async function testSplitRoundingPreservesEverySat() { await testCreatorEqualsPlatformCollapsesToOnePayment(); await testSplitRoundingPreservesEverySat(); await testSplitMath(); +await testZapPointerDefaultsToVideoKindWhenMissing(); await testBech32LightningAddressZapTag(); await testUrlLightningAddressZapTag(); await testRequestInvoiceIncludesLnurlParam(); From d6ac9429c1f4f98d4791d25c3be8161c5f29c504 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:45:55 -0400 Subject: [PATCH 04/83] tests(11b): un-quarantine user-blocks + nwc-client + nostr-publish-rejection; quarantine dm-block-filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the #11b un-quarantine work: the three target files now pass and run in CI. A full-suite run with a per-file timeout then surfaced a FOURTH, pre-existing hang that had never been in the map (dm-block-filter) — quarantined with a triage note rather than left to hang CI. user-blocks.test.mjs — post-completion HANG. The decrypt-timeout section installs a never-resolving nip44Decrypt whose background decrypt-retry timer outlives the run (manager.reset() races it). Added the repo's established bare-assert `setTimeout(() => process.exit(0), 50)` exit after the final assertion. nostr-publish-rejection.test.mjs — post-completion HANG (was NOT in the map, so it had been silently hanging local `npm run test:unit`, which has no per-file timeout unless UNIT_TEST_TIMEOUT_MS is set). All assertions pass, then the imported app stack (app.js + subscriptions + userBlocks) keeps the loop alive. Same `process.exit(0)`-after-cleanup fix. (The dev-mode + NIP-71 spec fixes were already in the file.) nwc-client.test.mjs — (1) mock shadow: readToolkitFromScope prefers the bootstrap's frozen canonical toolkit over window.NostrTools, so per-section mocks never took effect and real @noble rejected the fake keys. Remove the scope canonical so the resolver falls through to each mock. (2) spec correction: one section expected a rejection when the toolkit "lacks nip44", but mergeWithCanonical ALWAYS backfills the canonical nip44 (even explicit `nip44:null`) — nip44_v2 is never absent. The wallet now advertises a genuinely-unsupported scheme (nip44_v3) to exercise the same UNSUPPORTED_ENCRYPTION path, and asserts error.code too. See TEST_INTEGRITY.md. dm-block-filter.test.mjs — QUARANTINED (pre-existing, node:test). All subtests pass, then it hangs: manager.loadBlocks() opens a live block-list subscription through nostrClient's real SimplePool pointed at the test's fake relay URLs, leaking TCP sockets + reconnect timers that node:test can't drain. Needs a node:test-safe fix (inject a mock pool so no real socket opens) — a force process.exit in after() is UNSAFE (verified: process.exitCode isn't set yet, so it masks failures). Co-Authored-By: Claude Opus 4.8 --- TEST_INTEGRITY.md | 51 ++++++++++++++++++++++++++ scripts/run-unit-tests.mjs | 3 +- tests/nostr-publish-rejection.test.mjs | 9 +++++ tests/nwc-client.test.mjs | 35 +++++++++++++++++- tests/user-blocks.test.mjs | 9 +++++ todo/TODO_2026-06-20_pre-launch.md | 46 +++++++++++++++++------ 6 files changed, 138 insertions(+), 15 deletions(-) diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index 16d311e7..93386d67 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -458,3 +458,54 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +## 2026-07-05 — todo-11b final: un-quarantine nwc-client (+ user-blocks / nostr-publish-rejection hangs) + +```yaml +test_integrity_note: + change_type: ["spec_correction", "refactor_tests", "flake_fix"] + scenarios: + - id: SCN-nwc-unsupported-encryption-scheme + given: "An NWC wallet whose info event (kind 13194) advertises ONLY an encryption scheme bitvid does not implement (nip44_v3)" + when: "ensureEncryptionForContext(context) negotiates a scheme" + then: "It rejects with message /Wallet advertises unsupported encryption schemes: nip44_v3\\./ AND error.code === 'UNSUPPORTED_ENCRYPTION'" + - id: SCN-nwc-mock-toolkit-selection + given: "Each nwc-client section installs a per-section window.NostrTools mock to drive encryption-scheme selection" + when: "The nostr-tools bootstrap has installed its frozen canonical toolkit on the global scope" + then: "Removing scope.__BITVID_CANONICAL_NOSTR_TOOLS__ lets readToolkitFromScope fall through to the section mock, so the wiring under test runs against the mock (not real @noble against fake keys)" + observable_outcomes: + - "ensureEncryptionForContext rejection message + error.code (UNSUPPORTED_ENCRYPTION) — asserted exactly" + - "nip44_v2 section still resolves via the mock's nip44 (assert.match on the mock ciphertext shape)" + determinism_controls: + - "per-section in-memory window.NostrTools mocks; canonical-toolkit removal is deterministic (one-time, nothing re-installs it); no network" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "hard-coded return value" + note: | + nwc-client: TWO issues. (1) MOCK SHADOW (not a spec change): the bootstrap installs a + frozen canonical toolkit that readToolkitFromScope() prefers over window.NostrTools, so + the per-section mocks never took effect and real @noble ran against the fake test keys + ("bad point: is not on curve"). Deleting the scope canonical lets the resolver use each + section's mock — the tests exercise the NWC client's encryption-SELECTION wiring, not + @noble crypto (which is real elsewhere; the parseNwcUri hex-vs-bytes production bug is + guarded by tests/nwc-parse-uri.test.mjs). (2) SPEC CORRECTION: one section asserted a + rejection when the toolkit "lacks nip44" by omitting nip44 from the mock. That precondition + is architecturally unreachable — the bootstrap's mergeWithCanonical ALWAYS backfills the + canonical nip44 (even an explicit `nip44: null` is overwritten by `!merged.nip44`), so + nip44_v2 is never an unsupported scheme. Replaced with a wallet advertising a scheme bitvid + genuinely cannot provide (nip44_v3), exercising the SAME UNSUPPORTED_ENCRYPTION rejection + via a reachable scenario, and strengthened the check to also assert error.code — strictly + stronger than the old message-only match. + + user-blocks + nostr-publish-rejection: FLAKE_FIX (no assertion changed). Both are + bare-assert files that PASS all assertions then hang on lingering handles after completion + (user-blocks: a never-resolving decrypt's background retry timer that manager.reset() doesn't + cancel; nostr-publish-rejection: js/app.js + subscriptions + userBlocks module-load + timers/connection managers). Applied the repo's established post-completion + `setTimeout(() => process.exit(0), 50)` exit after cleanup. No behavior or expectation was + altered — the process simply exits once the assertions have all run. + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index c1f5b701..9ac36eb6 100755 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -14,8 +14,7 @@ process.env.NODE_ENV = "test"; // failing on main, unnoticed). Each entry MUST cite why and link the triage item. // Remove an entry as soon as its file is fixed. See todo/TODO_2026-06-20_pre-launch.md todo-11b. const QUARANTINE = new Map([ - ["tests/user-blocks.test.mjs", "HANG — async leak; needs a deterministic rewrite. triage todo-11b"], - ["tests/nwc-client.test.mjs", "FAIL — mocks nostr-tools but is shadowed by the frozen canonical toolkit the bootstrap installs (real @noble rejects the fake keys). The underlying production bug (parseNwcUri passed a hex secret to getPublicKey, which needs bytes — NWC connection broken) is FIXED and guarded by tests/nwc-parse-uri.test.mjs; this file needs a mock-injection rework. triage todo-11b (NWC / item #3)"], + ["tests/dm-block-filter.test.mjs", "HANG (post-completion) — all subtests PASS (TAP 'ok'), then the process hangs on a lingering handle from the imported app stack (nostrService/userBlocks/nostrClient/relayManager) so node:test never exits. Surfaced 2026-07-05 by running the full suite with UNIT_TEST_TIMEOUT_MS (pre-existing; it was never in this map). Needs a node:test-SAFE fix: close the leaked resource in an after() hook so node exits naturally — a force `process.exit` in after() is UNSAFE here (process.exitCode isn't set yet, verified, so it masks real failures). triage todo-11b"], ]); const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url))); diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs index 7cbfea59..4ccd0c87 100644 --- a/tests/nostr-publish-rejection.test.mjs +++ b/tests/nostr-publish-rejection.test.mjs @@ -473,3 +473,12 @@ try { window.NostrTools = originalWindowNostrTools; } } + +// All assertions above run to completion before the cleanup finally block; a +// failure would have rejected the top-level await and exited non-zero. This +// file imports the full app stack (js/app.js, subscriptions, userBlocks) whose +// module-load side effects (timers/connection managers) keep the event loop +// alive after the tests finish, so the process would otherwise hang. Force a +// clean exit — the established pattern for this repo's bare-assert tests with +// lingering handles. The small delay lets the "tests passed" log flush first. +setTimeout(() => process.exit(0), 50); diff --git a/tests/nwc-client.test.mjs b/tests/nwc-client.test.mjs index 15eb186d..96e70312 100644 --- a/tests/nwc-client.test.mjs +++ b/tests/nwc-client.test.mjs @@ -8,6 +8,22 @@ const nwcModule = await import("../js/payments/nwcClient.js"); const { __TESTING__, resetWalletClient } = nwcModule; const { buildPayInvoiceParams, ensureActiveState, parseNwcUri, getActiveState } = __TESTING__; +// The nostr-tools bootstrap installs a frozen canonical toolkit on the global +// scope, and nwcClient's readToolkitFromScope() prefers it over +// window.NostrTools. That shadows the per-section mocks below, so the real +// @noble crypto runs and rejects the fake test keys ("bad point: is not on +// curve"). These sections exercise the NWC client's encryption-SELECTION +// wiring — which scheme it negotiates and how it threads +// getConversationKey/encrypt — NOT @noble's crypto correctness (that's real +// elsewhere, and the hex-vs-bytes production bug parseNwcUri once had is +// guarded by tests/nwc-parse-uri.test.mjs). Remove the canonical so the +// resolver falls through to each section's mock. Nothing re-installs it during +// the run, so one removal holds for every section. +delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; +if (globalThis.window && typeof globalThis.window === "object") { + delete globalThis.window.__BITVID_CANONICAL_NOSTR_TOOLS__; +} + await (async () => { const params = buildPayInvoiceParams({ invoice: "bolt11-invoice", @@ -240,9 +256,19 @@ await (async () => { `&secret=${secretKey}`; const context = ensureActiveState({ nwcUri: uri }); + // Spec correction: the original scenario advertised "nip44_v2" and relied on + // the mock toolkit LACKING nip44 to force a rejection. That precondition is + // architecturally unreachable — the nostr-tools bootstrap's mergeWithCanonical + // always backfills the canonical nip44 (even an explicit `nip44: null` is + // overwritten), so nip44_v2 is ALWAYS a supported scheme and the wallet's + // advertisement would be honored, not rejected. The intended behavior under + // test is: when a wallet advertises ONLY encryption schemes bitvid cannot + // provide, ensureEncryption rejects with UNSUPPORTED_ENCRYPTION. bitvid only + // implements nip04 + nip44_v2, so advertising an unknown scheme is the + // reachable, faithful way to exercise that exact branch. context.infoEvent = { kind: 13194, - tags: [["encryption", "nip44_v2"]], + tags: [["encryption", "nip44_v3"]], }; await assert.rejects( @@ -250,7 +276,12 @@ await (async () => { (error) => { assert.match( error?.message || "", - /Wallet advertises unsupported encryption schemes: nip44_v2\./ + /Wallet advertises unsupported encryption schemes: nip44_v3\./ + ); + assert.equal( + error?.code, + "UNSUPPORTED_ENCRYPTION", + "unsupported-scheme rejection must carry the UNSUPPORTED_ENCRYPTION code" ); return true; } diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 8a703c86..30bb100c 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -1786,3 +1786,12 @@ await (async () => { manager.reset(); } })(); + +// All assertions above run at top level; a failure would have rejected and +// exited non-zero before reaching here. The decrypt-timeout section +// deliberately installs a never-resolving nip44Decrypt, which leaves a +// background decrypt-retry timer alive (manager.reset() doesn't cancel it), +// so the process would otherwise hang. Force a clean exit — the established +// pattern for this repo's bare-assert tests with lingering handles. +console.log("user-blocks tests passed"); +setTimeout(() => process.exit(0), 50); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 079a95dd..9801799e 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1276,8 +1276,10 @@ test below was failing on `main` unnoticed). `watch-history-feed`, `subscriptions-feed`, `discussion-count-service`, `feed-engine`, `zap-split`, `nostr-view-events`, `watchHistory/watch-history-telemetry`, `unit/ui/thumbnailBinder`. -- [ ] **Triage + un-quarantine the 10 broken files** (first-error diagnosis 2026-06-23; - each needs the stale-vs-real-bug investigation the delete-flow file got): +- [x] **ALL broken files triaged + un-quarantined (COMPLETE 2026-07-05).** The QUARANTINE + map in `run-unit-tests.mjs` is now empty; every unit test file runs in CI. Per-file + diagnosis + spec-correction history below (each got the stale-vs-real-bug + investigation the delete-flow file got): - `watch-history` (#2) — DONE + UN-QUARANTINED 2026-06-23. Three stale tests, all spec-corrected to shipped behavior (see TEST_INTEGRITY.md): (1) `testWatchHistoryPartialRelayRetry` asserted snapshot THROWS on partial @@ -1322,15 +1324,37 @@ test below was failing on `main` unnoticed). parseCommunityBlacklistReferences decoded them twice; and createListEvent omitted the `d` tag that selectNewestEventsForReferences matches on. Fixed all three to be faithful to production. See TEST_INTEGRITY.md. - - `nostr-publish-rejection` — STILL QUARANTINED. Partially diagnosed 2026-06-25: - multi-precondition setup. (1) `testPublishVideoNoteDefaultsToLiveModeInDev` needs - dev mode — set `globalThis.__BITVID_DEV_MODE_OVERRIDE__=true` BEFORE the first - (dynamic) config-importing import (~line 50). (2) The `publishNip71Video` sub-test - then fails — `publishVideo`'s NIP-71 invocation path needs FEATURE_PUBLISH_NIP71 / - condition triage; possibly more after. Quarantine note carries the steps. - - `nostr-publish-rejection`, `nwc-client`, `user-blocks` remain quarantined - (see QUARANTINE map in run-unit-tests.mjs for the precise reason on each). - - `user-blocks` — STILL QUARANTINED: HANGS (async leak); needs a deterministic rewrite. + - `nostr-publish-rejection` — ✅ DONE + UN-QUARANTINED 2026-07-05. The two spec + precondition fixes had already landed (dev-mode override at top; NIP-71 + auto-publish spec-corrected to expect ZERO calls with FEATURE_PUBLISH_NIP71 off). + Remaining issue was a post-completion HANG: the file imports the full app stack + (js/app.js + subscriptions + userBlocks) whose module-load timers/connection + managers keep the event loop alive after all assertions pass. Applied the repo's + established bare-assert `setTimeout(() => process.exit(0), 50)` exit after cleanup. + NOTE: it was never actually in the QUARANTINE map (only 2 entries: user-blocks + + nwc-client), so it had been silently hanging any local `npm run test:unit` (no + per-file timeout unless UNIT_TEST_TIMEOUT_MS is set). + - `nwc-client` — ✅ DONE + UN-QUARANTINED 2026-07-05. (1) Mock-shadow fixed: the + bootstrap's frozen canonical toolkit is preferred by readToolkitFromScope, so the + per-section `window.NostrTools` mocks never took effect (real @noble rejected the + fake keys). Removing the scope canonical lets the resolver fall through to each + mock. (2) Spec correction: one section expected a rejection when the toolkit + "lacks nip44," but mergeWithCanonical ALWAYS backfills the canonical nip44 (even + explicit `nip44:null`), so nip44_v2 is never absent — the wallet now advertises a + genuinely-unsupported scheme (`nip44_v3`) to exercise the same UNSUPPORTED_ENCRYPTION + path, and the assertion also checks `error.code`. + - `user-blocks` — ✅ DONE + UN-QUARANTINED 2026-07-05. Post-completion HANG: the + decrypt-timeout section installs a never-resolving nip44Decrypt that leaves a + background decrypt-retry timer alive (manager.reset() doesn't cancel it). Applied + the same `setTimeout(() => process.exit(0), 50)` exit after the final assertion. + - **All THREE #11b target files un-quarantined.** Running the full suite with a + per-file timeout then surfaced a FOURTH, pre-existing hang that was never in the + map: `tests/dm-block-filter.test.mjs` (node:test) — all subtests PASS, then it + hangs on a lingering app-stack handle so node:test never exits. Quarantined with a + triage note (needs a node:test-safe resource-close fix, NOT a force-exit — a force + `process.exit` in an after() hook was verified to mask failures because + process.exitCode isn't set yet). So the map has ONE entry (dm-block-filter), down + from the original two, with the newly-found file documented. ### 12. Promotion: `unstable → beta` - [ ] After this batch soaks and the high-priority items land, promote `unstable → beta` From a326d043309425059157724007b7defa5b7e9a5a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:03:03 -0400 Subject: [PATCH 05/83] =?UTF-8?q?tests(11b):=20un-quarantine=20dm-block-fi?= =?UTF-8?q?lter=20=E2=80=94=20hermetic=20block-list=20subscription;=20QUAR?= =?UTF-8?q?ANTINE=20now=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fully closes #11b's un-quarantine work: the QUARANTINE map is now EMPTY. dm-block-filter.test.mjs (node:test) PASSED all subtests then hung, so the node:test runner never exited. Diagnosed via process.getActiveResourcesInfo(): the "integration with loaded user block list" test's manager.loadBlocks() opens a LIVE block-list subscription through nostrClient's real SimplePool aimed at the test's fake relay URLs, leaking 4 TCP sockets + reconnect timers. It is NOT the decrypt-retry timer (verified absent). Nulling nostrClient.pool did not help — loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real pool when pool is falsy. Fix (node:test-SAFE, no force-exit): make the test hermetic — (1) a TRUTHY stub pool so ensurePool() is skipped, (2) a no-op getSubscriptionManager so startBlockListSubscription opens nothing. The block-merge logic under test still runs fully through the already-stubbed fetchListIncrementally. Restored in finally. Verified BOTH directions: a clean run exits 0 (# pass 12), AND a deliberately broken assertion still exits 1 (# fail 1) — failures are NOT masked. A force process.exit in an after() hook was rejected precisely because it masked failures (process.exitCode isn't set when the hook fires). See TEST_INTEGRITY.md. NOTE: the full suite still has one SEPARATE, pre-existing red unrelated to #11b — tests/ui/uploadModal-reset.test.mjs (TODO #11): its "zombie callbacks" subtest feeds a plain object where webtorrent expects a Blob ("must be an instance of Blob") and leaks async activity after the test ends. Untouched here; tracked under #11 (harden flaky tests), not the #11b quarantine work. Co-Authored-By: Claude Opus 4.8 --- TEST_INTEGRITY.md | 39 ++++++++++++++++++++++++++++++ scripts/run-unit-tests.mjs | 4 +-- tests/dm-block-filter.test.mjs | 20 +++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 24 ++++++++++++------ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index 93386d67..a8c03581 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -509,3 +509,42 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +## 2026-07-05 — todo-11b: un-quarantine dm-block-filter (hermetic subscription; no force-exit) + +```yaml +test_integrity_note: + change_type: ["flake_fix"] + scenarios: + - id: SCN-dm-block-filter-hermetic + given: "The 'integration with loaded user block list' test loads standard + legacy block lists and filters incoming DMs" + when: "manager.loadBlocks() runs against stubbed fetchListIncrementally, with the live block-list subscription stubbed out" + then: "Both blocked contacts (standard + legacy) are filtered (dmMessages === [ALLOWED]) AND the process opens NO real relay sockets, so node:test exits naturally with the correct exit code" + observable_outcomes: + - "manager.isBlocked(legacy) && manager.isBlocked(standard) === true; dmMessages length 1 (ALLOWED only)" + - "process.getActiveResourcesInfo() shows no TCPSocketWrap after loadBlocks; node exits 0 on pass, 1 on a forced failure (verified — failures NOT masked)" + determinism_controls: + - "stubbed nostrClient.fetchListIncrementally; truthy stub nostrClient.pool; no-op nostrClient.getSubscriptionManager; window.nostr.nip04.decrypt stub. All restored in finally. No network." + anti_cheat_rationale: + prevents: + - "retry/sleep-based flake masking" + - "over-mocking internal logic" + note: | + FLAKE_FIX, no assertion changed. The file PASSED all subtests then hung: loadBlocks() + opens a LIVE block-list subscription through nostrClient's real SimplePool aimed at the + test's fake relay URLs, leaking TCP sockets + reconnect timers that outlive the run so + node:test never exits (surfaced only once the runner was given a per-file timeout). + Diagnosed with process.getActiveResourcesInfo() — 4 TCPSocketWrap + reconnect Timeouts; + NOT the decrypt-retry timer (verified absent). Nulling the pool did not help because + loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real pool when + pool is falsy. Fix keeps the test hermetic: a TRUTHY stub pool (so ensurePool is skipped) + + a no-op getSubscriptionManager (so the subscription opens nothing). The block-merge + logic under test still runs fully through the stubbed fetchListIncrementally. A force + `process.exit` in an after() hook was explicitly REJECTED after verifying it masks real + failures (process.exitCode is not set when the hook's timer fires — a deliberately broken + assertion still exited 0 under that approach). Under this fix, the forced-failure control + correctly exits 1. + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9ac36eb6..3457b798 100755 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -13,9 +13,7 @@ process.env.NODE_ENV = "test"; // imported `node:test`, silently excluding ~20 bare-assert files; several were // failing on main, unnoticed). Each entry MUST cite why and link the triage item. // Remove an entry as soon as its file is fixed. See todo/TODO_2026-06-20_pre-launch.md todo-11b. -const QUARANTINE = new Map([ - ["tests/dm-block-filter.test.mjs", "HANG (post-completion) — all subtests PASS (TAP 'ok'), then the process hangs on a lingering handle from the imported app stack (nostrService/userBlocks/nostrClient/relayManager) so node:test never exits. Surfaced 2026-07-05 by running the full suite with UNIT_TEST_TIMEOUT_MS (pre-existing; it was never in this map). Needs a node:test-SAFE fix: close the leaked resource in an after() hook so node exits naturally — a force `process.exit` in after() is UNSAFE here (process.exitCode isn't set yet, verified, so it masks real failures). triage todo-11b"], -]); +const QUARANTINE = new Map([]); const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const testsDir = path.join(rootDir, "tests"); diff --git a/tests/dm-block-filter.test.mjs b/tests/dm-block-filter.test.mjs index 0a6fbf6a..14afa235 100644 --- a/tests/dm-block-filter.test.mjs +++ b/tests/dm-block-filter.test.mjs @@ -194,6 +194,8 @@ describe("DM block/mute filtering", () => { : nostrClient.relays; const originalFetchListIncrementally = nostrClient.fetchListIncrementally; const originalRelayEntries = relayManager.getEntries(); + const originalPool = nostrClient.pool; + const originalGetSubscriptionManager = nostrClient.getSubscriptionManager; const relayUrls = ["wss://dm-block-regression.example"]; relayManager.setEntries( @@ -202,6 +204,22 @@ describe("DM block/mute filtering", () => { ); nostrClient.relays = relayUrls; + // Keep this test hermetic. Otherwise loadBlocks() opens a LIVE block-list + // subscription through nostrClient's real SimplePool aimed at the fake relay + // above, leaking TCP sockets + reconnect timers that outlive the test and + // hang the node:test runner AFTER every assertion has already passed. + // (1) A truthy stub pool stops loadBlocks from calling ensurePool(), which + // otherwise re-creates and connects a real pool when pool is falsy + // (userBlocks.js) — so nulling the pool alone is not enough. + // (2) A no-op subscription manager makes startBlockListSubscription() a + // no-op instead of a real relay REQ. + // The block MERGE under test still runs entirely through the stubbed + // fetchListIncrementally below. Both are restored in finally. + nostrClient.pool = { __mockForDmBlockFilterTest: true }; + nostrClient.getSubscriptionManager = () => ({ + subscribe: () => ({ close() {} }), + }); + if (typeof globalThis.window === "undefined") { globalThis.window = {}; } @@ -317,6 +335,8 @@ describe("DM block/mute filtering", () => { }); nostrClient.relays = originalRelays; nostrClient.fetchListIncrementally = originalFetchListIncrementally; + nostrClient.pool = originalPool; + nostrClient.getSubscriptionManager = originalGetSubscriptionManager; if (typeof globalThis.window === "undefined") { globalThis.window = {}; } diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 9801799e..1a3b576a 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1347,14 +1347,22 @@ test below was failing on `main` unnoticed). decrypt-timeout section installs a never-resolving nip44Decrypt that leaves a background decrypt-retry timer alive (manager.reset() doesn't cancel it). Applied the same `setTimeout(() => process.exit(0), 50)` exit after the final assertion. - - **All THREE #11b target files un-quarantined.** Running the full suite with a - per-file timeout then surfaced a FOURTH, pre-existing hang that was never in the - map: `tests/dm-block-filter.test.mjs` (node:test) — all subtests PASS, then it - hangs on a lingering app-stack handle so node:test never exits. Quarantined with a - triage note (needs a node:test-safe resource-close fix, NOT a force-exit — a force - `process.exit` in an after() hook was verified to mask failures because - process.exitCode isn't set yet). So the map has ONE entry (dm-block-filter), down - from the original two, with the newly-found file documented. + - `dm-block-filter` — ✅ DONE + UN-QUARANTINED 2026-07-05. A FOURTH, pre-existing hang + surfaced by running the full suite with a per-file timeout (it was never in the map, + so it had been silently hanging local `npm run test:unit`). All subtests PASS, then + node:test never exits. Root cause (diagnosed via process.getActiveResourcesInfo): + the "integration with loaded user block list" test's `manager.loadBlocks()` opens a + LIVE block-list subscription through nostrClient's real SimplePool aimed at the + test's fake relay URLs, leaking 4 TCP sockets + reconnect timers. NOT the + decrypt-retry timer (verified absent). Nulling the pool did NOT help — loadBlocks + calls `ensurePool()` (userBlocks.js:967) to re-create+connect a real pool when pool + is falsy. Fix (node:test-SAFE, no force-exit): make the test hermetic — (1) a TRUTHY + stub pool so ensurePool() is skipped, (2) a no-op getSubscriptionManager so the + subscription opens nothing; the block MERGE still runs via the stubbed + fetchListIncrementally. Verified: clean run exits 0 (# pass 12), AND a deliberately + broken assertion still exits 1 (# fail 1) — failures are NOT masked. See + TEST_INTEGRITY.md. + - **QUARANTINE map is now EMPTY** — every unit test file runs in CI. ### 12. Promotion: `unstable → beta` - [ ] After this batch soaks and the high-priority items land, promote `unstable → beta` From 567a12c7584d5a084cd26351d96ac4fe9c83bc7e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:29:36 -0400 Subject: [PATCH 06/83] tests(11): make uploadModal-reset zombie-callback test deterministic (correct mock boundary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "guard against zombie callbacks" subtest passed its asserts but leaked async activity after the test ended (node:test flags that as failure). Root cause: it mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but handleVideoSelection drives the upload through this.mediaUploader.uploadVideo — a DIFFERENT object with its own helpers — so the mediaUploader still ran real createTorrentMetadata()/hashing on a synthetic {name:"video.mp4"} file ("invalid input type" async) and captureVideoMetadata() ran URL.createObjectURL(file) ("must be an instance of Blob"). Not intermittent — a consistent wrong-boundary mock. Fix: mock at the real DI boundary — modal.mediaUploader.uploadVideo = () => delayedUpload (controllable pending promise) — and stub captureVideoMetadata. Hermetic, deterministic (3/3 exit 0, # pass 3). Mutation-verified: breaking the videoUploadId zombie guard makes the test fail. Production code untouched. See TEST_INTEGRITY.md. NOTE: the runner is fail-fast, so the full suite surfaces pre-existing failures one at a time. tests/unit/hashChangeHandler.test.mjs is next (consistent AssertionError on "loads default view if hash is empty" — likely the same stale-default bug the sibling tests/app/hash-change-handler.test.mjs already had when TRENDING became the logged-out default). Enumerating full scope next. Co-Authored-By: Claude Opus 4.8 --- TEST_INTEGRITY.md | 40 +++++++++++++++++++++++++++++ tests/ui/uploadModal-reset.test.mjs | 26 ++++++++++--------- todo/TODO_2026-06-20_pre-launch.md | 18 ++++++++++--- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index a8c03581..eda16051 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -510,6 +510,46 @@ test_integrity_note: if_true_explain_spec_basis: "" ``` +## 2026-07-05 — todo-11: uploadModal-reset zombie-callback test made deterministic (correct mock boundary) + +```yaml +test_integrity_note: + change_type: ["flake_fix", "refactor_tests"] + scenarios: + - id: SCN-upload-zombie-callback-guard + given: "An upload is in progress (videoUploadState.status === 'uploading') via a controllable pending mediaUploader.uploadVideo" + when: "resetUploads() runs mid-upload (bumping videoUploadId), then the in-flight upload later resolves (a 'zombie' completion)" + then: "The stale completion is ignored — status stays 'idle' and results.videoUrl stays '' — because handleVideoSelection guards on videoUploadId !== currentUploadId" + observable_outcomes: + - "videoUploadState.status: 'uploading' after start, 'idle' after reset, still 'idle' after the zombie resolve" + - "results.videoUrl.value === '' after the zombie resolve" + - "test exits 0 with no async-after-test activity (was leaking webtorrent/Blob errors)" + determinism_controls: + - "mediaUploader.uploadVideo replaced with a manually-resolved promise; captureVideoMetadata stubbed; jsdom DOM; no webtorrent, no network, no real Blob ops" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "retry/sleep-based flake masking" + note: | + FLAKE_FIX + REFACTOR_TESTS, no assertion weakened. The subtest PASSED its asserts but + leaked async activity after the test ended, which node:test flags as a failure. Root + cause: it mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but + handleVideoSelection drives the upload through this.mediaUploader.uploadVideo — a + DIFFERENT object with its OWN helpers — so the mediaUploader still ran real + createTorrentMetadata()/hashing on a synthetic {name:'video.mp4'} file ("invalid input + type" async), and captureVideoMetadata() ran URL.createObjectURL(file) ("must be an + instance of Blob"). Fixed by mocking at the correct DI boundary + (modal.mediaUploader.uploadVideo returns the controllable pending promise) and stubbing + captureVideoMetadata. The unit under test is the modal's videoUploadId zombie-guard, so + mocking the uploader boundary is correct isolation, not over-mocking (real upload/torrent + paths are covered by the mediaUploader / s3-upload suites). + mutation_verification: + - "Broke the guard (`if (this.videoUploadId !== currentUploadId) return` → `if (false)`) → the test FAILS ('State should remain idle after zombie completion', # fail 1). Reverted." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + ## 2026-07-05 — todo-11b: un-quarantine dm-block-filter (hermetic subscription; no force-exit) ```yaml diff --git a/tests/ui/uploadModal-reset.test.mjs b/tests/ui/uploadModal-reset.test.mjs index 1e4ff83d..40629d60 100644 --- a/tests/ui/uploadModal-reset.test.mjs +++ b/tests/ui/uploadModal-reset.test.mjs @@ -151,17 +151,9 @@ describe("UploadModal Reset Logic", () => { let resolveUpload; const delayedUpload = new Promise(r => { resolveUpload = r; }); - const slowR2Service = { - prepareUpload: async () => ({ - settings: {}, - bucketEntry: { bucket: 'test', publicBaseUrl: 'https://test.com' } - }), - uploadFile: () => delayedUpload // Return promise that waits - }; - const modal = new UploadModal({ storageService: mockStorageService, - r2Service: slowR2Service, + r2Service: mockR2Service, getCurrentPubkey: () => "pk1", removeTrackingScripts: () => {}, setGlobalModalState: () => {}, @@ -175,9 +167,19 @@ describe("UploadModal Reset Logic", () => { modal.activeProvider = "cloudflare_r2"; modal.activeCredentials = { accountId: 'acc', accessKeyId: 'key', secretAccessKey: 'sec' }; - // Mock generateTorrentMetadata BEFORE calling handleVideoSelection - modal.generateTorrentMetadata = async () => ({ hasValidInfoHash: false }); - modal.resolveUploadIdentifier = async () => "mock-identifier"; + // handleVideoSelection() drives the upload through this.mediaUploader.uploadVideo + // — NOT the modal's own generate/resolve helpers — so mock at THAT boundary. + // Returning the controllable pending promise lets us reset mid-upload and then + // release a "zombie" completion. This isolates the unit under test (the modal's + // videoUploadId versioning guard) from real webtorrent/S3, which is what the + // test intends. Previously the test mocked modal.generateTorrentMetadata / + // resolveUploadIdentifier (the wrong object), so the mediaUploader still ran + // real createTorrentMetadata()/hashing on a synthetic file — throwing "invalid + // input type" as async activity after the test ended. + modal.mediaUploader.uploadVideo = () => delayedUpload; + // captureVideoMetadata() does URL.createObjectURL(file), which needs a real + // Blob; stub it so the synthetic file below doesn't throw an async Blob error. + modal.captureVideoMetadata = async () => {}; // Start upload const file = { name: "video.mp4" }; diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 1a3b576a..67080b04 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1241,10 +1241,20 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro local patch is guesswork — the update is the honest fix. ### 11. Harden flaky tests (CI gate reliability) -- [ ] `tests/ui/uploadModal-reset.test.mjs` ("UploadModal Reset Logic") intermittently - hangs/cancels (jsdom/webtorrent async-hang flake; documented in KNOWN_ISSUES, - reproduced on pre-refactor `1b11cb1b`). Make it deterministic so it can be a - trusted release gate. Audit e2e parallel-load flakiness too. +- [x] `tests/ui/uploadModal-reset.test.mjs` ("UploadModal Reset Logic") — FIXED 2026-07-05. + The "guard against zombie callbacks" subtest mocked `modal.generateTorrentMetadata` / + `resolveUploadIdentifier`, but handleVideoSelection drives the upload through + `this.mediaUploader.uploadVideo` (a DIFFERENT object with its own helpers) — so the + mediaUploader still ran real `createTorrentMetadata()`/hashing on a synthetic + `{name:"video.mp4"}` file, throwing "invalid input type" as async activity AFTER the + test ended (node:test flags that as an uncaughtException) plus + `captureVideoMetadata()` calling `URL.createObjectURL(file)` → "must be an instance of + Blob". Not intermittent anymore — it was a consistent, wrong-boundary mock. Fix: mock + at the real DI boundary — `modal.mediaUploader.uploadVideo = () => delayedUpload` (the + controllable pending promise) + stub `modal.captureVideoMetadata`. Now hermetic, no + webtorrent/Blob, deterministic (3/3 exit 0, # pass 3). Mutation-verified: breaking the + videoUploadId zombie guard makes the test fail (# fail 1). See TEST_INTEGRITY.md. +- [ ] Audit e2e parallel-load flakiness too. ### 11b. SILENTLY-EXCLUDED unit tests — 20 files never run in CI (found 2026-06-23) > **2026-07-03 addendum — stale-default failure was hiding in local runs:** From bd7de888e0fd759923be49e4344fb4e7300025cf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:36:15 -0400 Subject: [PATCH 07/83] tests(11): spec-correct hashChangeHandler default view (trending, not most-recent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/unit/hashChangeHandler.test.mjs asserted the logged-out default view was views/most-recent-videos.html. Commit 04621efc (2026-06-24, "Trending defaults") made TRENDING the deliberate logged-out landing (FEATURE_TRENDING_FEED defaults on; hashChangeHandler.js:95-99 → logged out = TRENDING, else RECENT). This is the SAME stale spec the sibling tests/app/hash-change-handler.test.mjs was already corrected for — this second file was missed. Equally-strict spec_correction: assert views/trending.html. Verified: # pass 4 # fail 0. Co-Authored-By: Claude Opus 4.8 --- tests/unit/hashChangeHandler.test.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/hashChangeHandler.test.mjs b/tests/unit/hashChangeHandler.test.mjs index 277d2ad8..ca48a5ba 100644 --- a/tests/unit/hashChangeHandler.test.mjs +++ b/tests/unit/hashChangeHandler.test.mjs @@ -36,8 +36,13 @@ test('createHashChangeHandler', async (t) => { loadedView = null; mockLocation.hash = ''; await handler(); - // Default is most-recent-videos if not logged in - assert.equal(loadedView, 'views/most-recent-videos.html'); + // spec_correction (todo-11): commit 04621efc (2026-06-24, "Trending defaults") + // made TRENDING the deliberate logged-out landing (FEATURE_TRENDING_FEED + // defaults on; hashChangeHandler.js:95-99). This test still expected the OLD + // default (most-recent-videos) — the same stale spec the sibling + // tests/app/hash-change-handler.test.mjs was already corrected for. Equally + // strict: the correct observable outcome is views/trending.html. + assert.equal(loadedView, 'views/trending.html'); }); await t.test('redirects legacy view', async () => { From a36d57c8b4e034bfafdfcf5f42f0d45ce6fbbbbe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:40:04 -0400 Subject: [PATCH 08/83] tests(11): spec-correct searchFilters author parsing (validated pubkeys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/unit/searchFilters.test.mjs fed author:abc123 and expected authorPubkeys === ["abc123"] with zero errors. The author filter now runs values through normalizeAuthorValues (searchFilters.js): it accepts a 64-char hex pubkey or a decodable npub1 and DROPS anything else (recording an error), because videos carry HEX pubkeys and npub authors otherwise matched nothing. "abc123" is neither, so it is correctly dropped → authorPubkeys empty + an error, contradicting this happy-path test. Equally-strict spec_correction: use a valid 64-hex author so the test exercises the intended normalization. Verified: # pass 4 # fail 0. This was the LAST failure in a full per-file scan of all 392 test files (the fail-fast runner had been surfacing them one at a time). Co-Authored-By: Claude Opus 4.8 --- tests/unit/searchFilters.test.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit/searchFilters.test.mjs b/tests/unit/searchFilters.test.mjs index 44227691..916d2da4 100644 --- a/tests/unit/searchFilters.test.mjs +++ b/tests/unit/searchFilters.test.mjs @@ -8,11 +8,19 @@ import { import { buildVideoSearchFilterMatcher } from "../../js/search/searchFilterMatchers.js"; test("parseFilterQuery collects filters and text tokens", () => { + // spec_correction (todo-11): the author filter now runs values through + // normalizeAuthorValues (searchFilters.js) — it accepts a 64-char hex pubkey + // or a decodable npub1 and DROPS anything else (with an error), because videos + // carry HEX pubkeys and npub authors otherwise matched nothing. The old fixture + // "abc123" is neither hex64 nor an npub, so it is (correctly) dropped, leaving + // authorPubkeys empty and an error — contradicting this happy-path test. Use a + // valid hex pubkey so the test exercises the intended normalization. + const authorHex = "a".repeat(64); const parsed = parseFilterQuery( - "author:abc123 tag:#news duration:<=5m has:magnet nsfw:false hello world", + `author:${authorHex} tag:#news duration:<=5m has:magnet nsfw:false hello world`, ); - assert.deepEqual(parsed.filters.authorPubkeys, ["abc123"]); + assert.deepEqual(parsed.filters.authorPubkeys, [authorHex]); assert.deepEqual(parsed.filters.tags, ["news"]); assert.equal(parsed.filters.duration.maxSeconds, 300); assert.equal(parsed.filters.hasMagnet, true); From e5064eeb87a0f79312d99af7a470aa6b5ef404b9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:48:49 -0400 Subject: [PATCH 09/83] =?UTF-8?q?playlists(#37):=20foundation=20=E2=80=94?= =?UTF-8?q?=20kind-30082=20schema,=20builder,=20pure=20service=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of creator playlists. Adds a bitvid-native addressable playlist event and the pure, hermetic core that builds/parses/mutates it. No UI or network yet — those land in follow-up commits. - nostrEventSchemas.js: PLAYLIST_KIND=30082 (+ NOTE_TYPES.PLAYLIST, BASE_SCHEMAS entry) and buildPlaylistEvent(). Data lives in tags: d (id), title/description/ image, and ordered `a` refs (30078:pubkey:d) to videos; order = tag order; repeated refs deduped. Deliberately NOT NIP-51's 30005 "video curation set" — bitvid already reads 30005 as legacy interest-sets, so reuse would collide. - js/playlists/playlistService.js: parsePlaylistEvent, buildPlaylist, and immutable add/remove/reorder item helpers + videoCoordinate() (reuses the canonical buildVideoAddressPointer, same 30078 contract as the zap fix) + generatePlaylistId(). Pure — fully unit-testable with no relays/signer. - tests/playlist-service.test.mjs: 12 scenarios (build tags + order + dedup, d-tag required, parse structure/rejects/defaults, build→parse round-trip, add/remove/reorder immutability + clamping, coordinate derivation incl. the kind-0 internal-object case). - docs/nostr-event-schemas.md: catalogue row for the new kind. Schema validation, lint, and build all pass; tests 12/12. Co-Authored-By: Claude Opus 4.8 --- docs/nostr-event-schemas.md | 1 + js/nostrEventSchemas.js | 118 +++++++++++++++ js/playlists/playlistService.js | 245 ++++++++++++++++++++++++++++++++ tests/playlist-service.test.mjs | 223 +++++++++++++++++++++++++++++ 4 files changed, 587 insertions(+) create mode 100644 js/playlists/playlistService.js create mode 100644 tests/playlist-service.test.mjs diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 9c4198a5..068b6c46 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -197,6 +197,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', ]` plus optional `['e', ]`, `['a', ]`, `['amount', ]`, `['lnurl', ]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note | | Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', ]`, `['description', ]`, `['p', ]` plus optional `['e', ]` and `['a', ]` | Empty content; receipts are published by the recipient's LNURL server | | Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', ]`, `['p', ]`, optional `['e', ]`/`['a', ]`, `['amount', ]`, `['bolt11', ]`, `['preimage', ]`, `['description', ]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. | +| Playlist (`NOTE_TYPES.PLAYLIST`) | `PLAYLIST_KIND` (`30082`, addressable, `d = playlist id`) | `['t','playlist']`, `['d', ]`, optional `['title', ...]`/`['description', ...]`/`['image', ...]`, then **ordered** `['a', '30078:pubkey:d']` refs per video (`['e', ]` also accepted). Item order = tag order. | Creator-curated video playlist (#37). bitvid-native — deliberately NOT NIP-51's `30005` "video curation set", which bitvid already reads as legacy interest-sets (would collide). Built/parsed by `js/playlists/playlistService.js`. | | HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', ]`, `['method', ]`, `['payload', ]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) | | Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', , ]`, `['p', , ]`, and optional `['t', ]` | Plaintext report reason | diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index ff6f553b..0064d14a 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -48,6 +48,7 @@ export const NOTE_TYPES = Object.freeze({ ZAP_REQUEST: "zapRequest", ZAP_RECEIPT: "zapReceipt", ZAP_TALLY: "zapTally", + PLAYLIST: "playlist", WATCH_HISTORY: "watchHistory", SUBSCRIPTION_LIST: "subscriptionList", SUBSCRIPTION_BACKUP: "subscriptionBackup", @@ -98,6 +99,14 @@ const DEFAULT_APPEND_TAGS = []; // (30078 video, 30079 view). See docs/zap-tally-plan.md. export const ZAP_TALLY_KIND = 30081; +// bitvid-native creator playlist (#37). Addressable (30000–39999) so `d = +// playlist id` yields one canonical, replaceable event per playlist. In bitvid's +// family (30078 video, 30079 view, 30081 zap tally). Deliberately NOT NIP-51's +// 30005 "video curation set": bitvid already reads 30005 as legacy interest-sets +// (hashtagPreferencesService), so reusing it would collide. Ordered `a` tags +// (30078:pubkey:d) reference the videos; order = tag order. +export const PLAYLIST_KIND = 30082; + let cachedUtf8Encoder = null; const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]); @@ -339,6 +348,19 @@ const BASE_SCHEMAS = { ], }, }, + [NOTE_TYPES.PLAYLIST]: { + type: NOTE_TYPES.PLAYLIST, + label: "Playlist", + kind: PLAYLIST_KIND, + topicTag: { name: "t", value: "playlist" }, + identifierTag: { name: "d" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Reserved for future notes; playlist data lives in tags (title/description/image + ordered `a` video refs).", + }, + }, [NOTE_TYPES.VIDEO_MIRROR]: { type: NOTE_TYPES.VIDEO_MIRROR, label: "NIP-94 mirror", @@ -1423,6 +1445,102 @@ export function buildVideoMirrorEvent(params) { return event; } +/** + * Builds a bitvid Playlist event (Kind 30082, addressable). All playlist data + * lives in tags: `d` (id), `title`, `description`, `image`, and ordered `a` + * refs (`30078:pubkey:d`) to the videos — order is preserved as tag order. + * Republishing with the same `d` replaces the playlist (NIP-33). + * + * @param {Object} params + * @param {string} params.pubkey + * @param {number} params.created_at + * @param {string} params.dTagValue stable playlist id (required) + * @param {string} [params.title] + * @param {string} [params.description] + * @param {string} [params.image] + * @param {string[]} [params.videoCoordinates] ordered "30078:pubkey:d" strings (`a`) + * @param {string[]} [params.eventRefs] optional ordered raw event ids (`e`) + * @param {string} [params.content] + * @returns {Object} Unsigned event. + */ +export function buildPlaylistEvent(params) { + const { + pubkey, + created_at, + dTagValue, + title = "", + description = "", + image = "", + videoCoordinates = [], + eventRefs = [], + content = "", + } = params || {}; + + const identifier = typeof dTagValue === "string" ? dTagValue.trim() : ""; + if (!identifier) { + throw new Error("A playlist requires a d-tag identifier."); + } + + const schema = getNostrEventSchema(NOTE_TYPES.PLAYLIST); + const tags = []; + + if (schema?.topicTag?.name && schema?.topicTag?.value) { + tags.push([schema.topicTag.name, schema.topicTag.value]); + } + tags.push([schema?.identifierTag?.name || "d", identifier]); + + const trimmedTitle = typeof title === "string" ? title.trim() : ""; + if (trimmedTitle) { + tags.push(["title", trimmedTitle]); + } + const trimmedDescription = + typeof description === "string" ? description.trim() : ""; + if (trimmedDescription) { + tags.push(["description", trimmedDescription]); + } + const trimmedImage = typeof image === "string" ? image.trim() : ""; + if (trimmedImage) { + tags.push(["image", trimmedImage]); + } + + // Ordered video refs; dedupe so the same video can't appear twice. + const seen = new Set(); + if (Array.isArray(videoCoordinates)) { + videoCoordinates.forEach((coord) => { + const value = typeof coord === "string" ? coord.trim() : ""; + if (value && !seen.has(`a:${value}`)) { + seen.add(`a:${value}`); + tags.push(["a", value]); + } + }); + } + if (Array.isArray(eventRefs)) { + eventRefs.forEach((ref) => { + const value = typeof ref === "string" ? ref.trim() : ""; + if (value && !seen.has(`e:${value}`)) { + seen.add(`e:${value}`); + tags.push(["e", value]); + } + }); + } + + appendSchemaTags(tags, schema); + + const event = { + kind: schema?.kind ?? PLAYLIST_KIND, + pubkey, + created_at, + tags, + content: ensureValidUtf8Content(content), + }; + + if (isDevMode) { + validateEventAgainstSchema(NOTE_TYPES.PLAYLIST, event); + } + + return event; +} + /** * Builds a Repost (Kind 6) or Generic Repost (Kind 16) event. * diff --git a/js/playlists/playlistService.js b/js/playlists/playlistService.js new file mode 100644 index 00000000..03ae6087 --- /dev/null +++ b/js/playlists/playlistService.js @@ -0,0 +1,245 @@ +// js/playlists/playlistService.js +// +// Creator playlists (#37) — pure core. A playlist is a bitvid-native addressable +// event (kind PLAYLIST_KIND=30082): all data lives in tags (d/title/description/ +// image + ordered `a` refs to videos). This module holds the pure, hermetic +// logic — parse an event into a structured playlist, build a signable event from +// one, and immutably add/remove/reorder items. Network I/O (fetch a creator's +// playlists, publish) lives in the facade that wires this to the live client, so +// this core stays fully unit-testable with no relays or signer. + +import { + buildPlaylistEvent, + PLAYLIST_KIND, +} from "../nostrEventSchemas.js"; +import { buildVideoAddressPointer } from "../utils/videoPointer.js"; + +export { PLAYLIST_KIND }; + +function firstTagValue(tags, name) { + const tag = tags.find( + (t) => Array.isArray(t) && t[0] === name && typeof t[1] === "string", + ); + return tag ? tag[1] : ""; +} + +/** + * A structured playlist item references one video, either by addressable + * coordinate (`a` → "30078:pubkey:d") or raw event id (`e`). + * @typedef {{ type: "a" | "e", value: string }} PlaylistItem + */ + +export function playlistItemKey(item) { + return item && item.type && typeof item.value === "string" && item.value + ? `${item.type}:${item.value}` + : ""; +} + +/** + * Parse a kind-30082 event into a structured playlist. Returns null for a + * non-playlist event or one missing a `d` identifier. Item order follows tag + * order; duplicate refs are collapsed. + * @param {Object} event + * @returns {null | { + * id: string, pubkey: string, title: string, description: string, + * image: string, items: PlaylistItem[], updatedAt: number, + * address: string, eventId: string, + * }} + */ +export function parsePlaylistEvent(event) { + if (!event || typeof event !== "object" || !Array.isArray(event.tags)) { + return null; + } + if (Number.isFinite(event.kind) && event.kind !== PLAYLIST_KIND) { + return null; + } + + const tags = event.tags; + const id = firstTagValue(tags, "d").trim(); + if (!id) { + return null; + } + + const items = []; + const seen = new Set(); + for (const tag of tags) { + if (!Array.isArray(tag) || (tag[0] !== "a" && tag[0] !== "e")) { + continue; + } + if (typeof tag[1] !== "string") { + continue; + } + const value = tag[1].trim(); + const key = `${tag[0]}:${value}`; + if (value && !seen.has(key)) { + seen.add(key); + items.push({ type: tag[0], value }); + } + } + + const pubkey = + typeof event.pubkey === "string" ? event.pubkey.trim().toLowerCase() : ""; + + return { + id, + pubkey, + title: firstTagValue(tags, "title").trim() || "Untitled playlist", + description: firstTagValue(tags, "description").trim(), + image: firstTagValue(tags, "image").trim(), + items, + updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0, + address: `${PLAYLIST_KIND}:${pubkey}:${id}`, + eventId: typeof event.id === "string" ? event.id : "", + }; +} + +/** + * Immutably add a video (by addressable coordinate) to an item list. Deduped — + * a video already present is a no-op. Appends unless a valid `position` is given. + * @param {PlaylistItem[]} items + * @param {string} coordinate "30078:pubkey:d" + * @param {{ position?: number }} [opts] + * @returns {PlaylistItem[]} a new array + */ +export function addVideoToPlaylist(items, coordinate, { position } = {}) { + const list = Array.isArray(items) ? items.slice() : []; + const value = typeof coordinate === "string" ? coordinate.trim() : ""; + if (!value) { + return list; + } + const item = { type: "a", value }; + const key = playlistItemKey(item); + if (list.some((existing) => playlistItemKey(existing) === key)) { + return list; + } + if (Number.isInteger(position) && position >= 0 && position < list.length) { + list.splice(position, 0, item); + } else { + list.push(item); + } + return list; +} + +/** + * Immutably remove a video from an item list, by coordinate string (treated as + * an `a` ref) or by a {type,value} item. + * @param {PlaylistItem[]} items + * @param {string | PlaylistItem} coordinateOrItem + * @returns {PlaylistItem[]} a new array + */ +export function removeVideoFromPlaylist(items, coordinateOrItem) { + const list = Array.isArray(items) ? items.slice() : []; + const key = + typeof coordinateOrItem === "string" + ? `a:${coordinateOrItem.trim()}` + : playlistItemKey(coordinateOrItem); + if (!key) { + return list; + } + return list.filter((item) => playlistItemKey(item) !== key); +} + +/** + * Immutably move an item from one index to another (drag-to-reorder). Out-of- + * range indices are ignored / clamped; returns a new array. + * @param {PlaylistItem[]} items + * @param {number} fromIndex + * @param {number} toIndex + * @returns {PlaylistItem[]} + */ +export function reorderPlaylistItems(items, fromIndex, toIndex) { + const list = Array.isArray(items) ? items.slice() : []; + if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) { + return list; + } + if (fromIndex < 0 || fromIndex >= list.length) { + return list; + } + const clampedTo = Math.max(0, Math.min(toIndex, list.length - 1)); + if (clampedTo === fromIndex) { + return list; + } + const [moved] = list.splice(fromIndex, 1); + list.splice(clampedTo, 0, moved); + return list; +} + +/** + * The addressable coordinate ("30078:pubkey:d") for a video object/event, using + * the app's canonical derivation (defaults invalid/missing kinds to 30078). + * @param {Object} video + * @returns {string} coordinate, or "" if it can't be derived + */ +export function videoCoordinate(video) { + return buildVideoAddressPointer(video); +} + +/** + * Build an unsigned, signable playlist event from a structured playlist. + * @param {{ + * pubkey: string, id: string, title?: string, description?: string, + * image?: string, items?: PlaylistItem[], content?: string, created_at?: number, + * }} playlist + * @returns {Object} unsigned kind-30082 event + */ +export function buildPlaylist({ + pubkey, + id, + title = "", + description = "", + image = "", + items = [], + content = "", + created_at, +} = {}) { + const videoCoordinates = []; + const eventRefs = []; + if (Array.isArray(items)) { + for (const item of items) { + if (!item || typeof item.value !== "string") { + continue; + } + if (item.type === "a") { + videoCoordinates.push(item.value); + } else if (item.type === "e") { + eventRefs.push(item.value); + } + } + } + return buildPlaylistEvent({ + pubkey, + created_at: Number.isFinite(created_at) + ? created_at + : Math.floor(Date.now() / 1000), + dTagValue: id, + title, + description, + image, + videoCoordinates, + eventRefs, + content, + }); +} + +/** + * Generate a stable-ish, collision-resistant playlist id for a new playlist's + * `d` tag (time-prefixed + random suffix, lowercase, URL/tag-safe). + * @returns {string} + */ +export function generatePlaylistId() { + const time = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 10); + return `pl-${time}-${rand}`; +} + +export default { + PLAYLIST_KIND, + parsePlaylistEvent, + playlistItemKey, + addVideoToPlaylist, + removeVideoFromPlaylist, + reorderPlaylistItems, + videoCoordinate, + buildPlaylist, + generatePlaylistId, +}; diff --git a/tests/playlist-service.test.mjs b/tests/playlist-service.test.mjs new file mode 100644 index 00000000..4aac2cda --- /dev/null +++ b/tests/playlist-service.test.mjs @@ -0,0 +1,223 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + PLAYLIST_KIND, + parsePlaylistEvent, + addVideoToPlaylist, + removeVideoFromPlaylist, + reorderPlaylistItems, + videoCoordinate, + buildPlaylist, + generatePlaylistId, + playlistItemKey, +} from "../js/playlists/playlistService.js"; +import { buildPlaylistEvent } from "../js/nostrEventSchemas.js"; + +const PUBKEY = "a".repeat(64); +const AUTHOR = "b".repeat(64); +const coord = (dtag, author = AUTHOR) => `30078:${author}:${dtag}`; + +test("buildPlaylistEvent emits kind, identifier, metadata, and ordered a-refs", () => { + const event = buildPlaylistEvent({ + pubkey: PUBKEY, + created_at: 1000, + dTagValue: "my-list", + title: "My List", + description: "cool clips", + image: "https://img.example/x.jpg", + videoCoordinates: [coord("v1"), coord("v2"), coord("v3")], + }); + + assert.equal(event.kind, PLAYLIST_KIND); + assert.equal(event.tags.find((t) => t[0] === "d")?.[1], "my-list"); + assert.equal(event.tags.find((t) => t[0] === "t")?.[1], "playlist"); + assert.equal(event.tags.find((t) => t[0] === "title")?.[1], "My List"); + assert.equal(event.tags.find((t) => t[0] === "description")?.[1], "cool clips"); + assert.equal( + event.tags.find((t) => t[0] === "image")?.[1], + "https://img.example/x.jpg", + ); + + // Ordered a-refs, in the given order. + const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]); + assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]); +}); + +test("buildPlaylistEvent dedupes repeated video coordinates, preserving first order", () => { + const event = buildPlaylistEvent({ + pubkey: PUBKEY, + created_at: 1000, + dTagValue: "d1", + videoCoordinates: [coord("v1"), coord("v2"), coord("v1"), coord("v3")], + }); + const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]); + assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]); +}); + +test("buildPlaylistEvent requires a d-tag identifier", () => { + assert.throws( + () => buildPlaylistEvent({ pubkey: PUBKEY, created_at: 1, dTagValue: " " }), + /requires a d-tag/i, + ); +}); + +test("parsePlaylistEvent structures a playlist and preserves item order", () => { + const event = buildPlaylistEvent({ + pubkey: PUBKEY, + created_at: 4242, + dTagValue: "list-7", + title: "Seven", + videoCoordinates: [coord("a"), coord("b")], + eventRefs: ["e".repeat(64)], + }); + event.id = "event-id-123"; + + const parsed = parsePlaylistEvent(event); + assert.equal(parsed.id, "list-7"); + assert.equal(parsed.pubkey, PUBKEY); + assert.equal(parsed.title, "Seven"); + assert.equal(parsed.updatedAt, 4242); + assert.equal(parsed.address, `${PLAYLIST_KIND}:${PUBKEY}:list-7`); + assert.equal(parsed.eventId, "event-id-123"); + assert.deepEqual(parsed.items, [ + { type: "a", value: coord("a") }, + { type: "a", value: coord("b") }, + { type: "e", value: "e".repeat(64) }, + ]); +}); + +test("parsePlaylistEvent rejects the wrong kind and a missing d-tag", () => { + assert.equal( + parsePlaylistEvent({ kind: 30078, tags: [["d", "x"]] }), + null, + "wrong kind → null", + ); + assert.equal( + parsePlaylistEvent({ kind: PLAYLIST_KIND, tags: [["title", "no id"]] }), + null, + "missing d → null", + ); + assert.equal(parsePlaylistEvent(null), null); +}); + +test("parsePlaylistEvent defaults an empty title", () => { + const parsed = parsePlaylistEvent({ + kind: PLAYLIST_KIND, + pubkey: PUBKEY, + created_at: 1, + tags: [["d", "no-title"]], + }); + assert.equal(parsed.title, "Untitled playlist"); +}); + +test("build → parse round-trips a full playlist (order intact)", () => { + const items = [ + { type: "a", value: coord("v1") }, + { type: "a", value: coord("v2") }, + { type: "a", value: coord("v3") }, + ]; + const event = buildPlaylist({ + pubkey: PUBKEY, + id: "rt", + title: "Round Trip", + description: "desc", + items, + created_at: 55, + }); + const parsed = parsePlaylistEvent({ ...event, id: "signed-id" }); + assert.equal(parsed.title, "Round Trip"); + assert.equal(parsed.description, "desc"); + assert.deepEqual(parsed.items, items); +}); + +test("addVideoToPlaylist appends, dedupes, inserts by position, and is immutable", () => { + const base = [{ type: "a", value: coord("v1") }]; + + const appended = addVideoToPlaylist(base, coord("v2")); + assert.deepEqual(appended.map((i) => i.value), [coord("v1"), coord("v2")]); + assert.deepEqual(base.map((i) => i.value), [coord("v1")], "input not mutated"); + + const deduped = addVideoToPlaylist(appended, coord("v1")); + assert.equal(deduped.length, 2, "existing video is a no-op"); + + const inserted = addVideoToPlaylist(appended, coord("v3"), { position: 1 }); + assert.deepEqual(inserted.map((i) => i.value), [ + coord("v1"), + coord("v3"), + coord("v2"), + ]); + + assert.deepEqual(addVideoToPlaylist(base, " "), base.slice(), "blank ignored"); +}); + +test("removeVideoFromPlaylist removes by coordinate and by item, immutably", () => { + const base = [ + { type: "a", value: coord("v1") }, + { type: "a", value: coord("v2") }, + { type: "e", value: "e".repeat(64) }, + ]; + + const byCoord = removeVideoFromPlaylist(base, coord("v1")); + assert.deepEqual(byCoord.map(playlistItemKey), [ + `a:${coord("v2")}`, + `e:${"e".repeat(64)}`, + ]); + assert.equal(base.length, 3, "input not mutated"); + + const byItem = removeVideoFromPlaylist(base, { type: "e", value: "e".repeat(64) }); + assert.ok(!byItem.some((i) => i.type === "e")); +}); + +test("reorderPlaylistItems moves items, clamps, no-ops safely, immutably", () => { + const base = [ + { type: "a", value: coord("v1") }, + { type: "a", value: coord("v2") }, + { type: "a", value: coord("v3") }, + ]; + + // Move first to last. + const moved = reorderPlaylistItems(base, 0, 2); + assert.deepEqual(moved.map((i) => i.value), [ + coord("v2"), + coord("v3"), + coord("v1"), + ]); + assert.deepEqual(base.map((i) => i.value), [ + coord("v1"), + coord("v2"), + coord("v3"), + ], "input not mutated"); + + // toIndex beyond the end clamps to last. + const clamped = reorderPlaylistItems(base, 0, 99); + assert.equal(clamped[clamped.length - 1].value, coord("v1")); + + // Out-of-range fromIndex → unchanged copy. + assert.deepEqual(reorderPlaylistItems(base, 5, 0), base); + // Same index → unchanged. + assert.deepEqual(reorderPlaylistItems(base, 1, 1), base); +}); + +test("videoCoordinate derives the 30078 coordinate from a video object", () => { + const video = { + kind: 30078, + pubkey: AUTHOR, + tags: [["d", "1700000000000-abc"]], + }; + assert.equal(videoCoordinate(video), `30078:${AUTHOR}:1700000000000-abc`); + + // A video object carrying kind 0 (bitvid's internal objects often do) still + // yields the canonical 30078 coordinate — matches the zap-pointer contract. + assert.equal( + videoCoordinate({ kind: 0, pubkey: AUTHOR, tags: [["d", "x"]] }), + `30078:${AUTHOR}:x`, + ); +}); + +test("generatePlaylistId produces distinct, tag-safe ids", () => { + const a = generatePlaylistId(); + const b = generatePlaylistId(); + assert.notEqual(a, b); + assert.match(a, /^pl-[0-9a-z]+-[0-9a-z]+$/); +}); From 0caf468c36eb0ff73b06c4ae84cbb0eddd1cc895 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:51:43 -0400 Subject: [PATCH 10/83] playlists(#37): fetch/publish facade over the live client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network wiring on top of the pure playlistService core: - fetchCreatorPlaylists(pubkey) — lists kind-30082 by author via the L1 SubscriptionManager, collapses to newest-per-`d` (NIP-33 replaceable), drops empty playlists from the listing (a cleared playlist republishes empty), sorts newest-first. - fetchPlaylist(pubkey, id) — newest matching event, parsed. - publishPlaylist(playlist) — build + sign + publish via nostrClient.signAndPublishEvent (active signer), replacing the same `d`. All entry points take an injectable client for hermetic testing. tests/playlist-facade.test.mjs (5): newest-per-id + empty/other-author drop + sort, includeEmpty, blank/no-relay guards, fetch-one newest, publish emits a kind-30082 event with the right d/title/ordered a-refs under the "playlist" context. Mock client; no relays/signer. Lint clean; 5/5. Co-Authored-By: Claude Opus 4.8 --- js/playlists/playlistFacade.js | 156 +++++++++++++++++++++++++++++++++ tests/playlist-facade.test.mjs | 138 +++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 js/playlists/playlistFacade.js create mode 100644 tests/playlist-facade.test.mjs diff --git a/js/playlists/playlistFacade.js b/js/playlists/playlistFacade.js new file mode 100644 index 00000000..fcf506b5 --- /dev/null +++ b/js/playlists/playlistFacade.js @@ -0,0 +1,156 @@ +// js/playlists/playlistFacade.js +// +// Network wiring for creator playlists (#37): fetch a creator's playlists / one +// playlist, and publish (build + sign + publish) a playlist. Kept apart from the +// pure playlistService core so that core stays hermetic. Reads route through the +// L1 SubscriptionManager (relay caps + in-flight dedupe), matching the rest of +// bitvid's read paths; writes go through nostrClient.signAndPublishEvent (active +// signer). All entry points take an injectable `client` for testing. + +import { nostrClient } from "../nostrClientFacade.js"; +import { + PLAYLIST_KIND, + parsePlaylistEvent, + buildPlaylist, +} from "./playlistService.js"; +import { devLogger } from "../utils/logger.js"; + +const LIST_LIMIT = 200; + +function getManagerAndRelays(client) { + const manager = + typeof client?.getSubscriptionManager === "function" + ? client.getSubscriptionManager() + : null; + const relays = Array.isArray(client?.relays) ? client.relays : []; + return { manager, relays }; +} + +// Collapse to the newest event per `d` tag (NIP-33 replaceable): a flaky relay +// list can return an old + new copy of the same playlist, so keep only the +// freshest per id. +function newestPerDTag(events) { + const byD = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (!event || !Array.isArray(event.tags)) { + continue; + } + const dTag = event.tags.find( + (t) => Array.isArray(t) && t[0] === "d" && typeof t[1] === "string", + ); + const id = dTag ? dTag[1] : ""; + if (!id) { + continue; + } + const existing = byD.get(id); + if ( + !existing || + (Number(event.created_at) || 0) > (Number(existing.created_at) || 0) + ) { + byD.set(id, event); + } + } + return [...byD.values()]; +} + +/** + * Fetch a creator's playlists, parsed, newest-per-id, sorted newest-first. + * Empty playlists are dropped from the listing by default (a cleared playlist + * republishes as an empty replaceable event) — pass includeEmpty to keep them. + * @param {string} pubkey + * @param {{ client?: any, includeEmpty?: boolean }} [opts] + * @returns {Promise} parsed playlists + */ +export async function fetchCreatorPlaylists( + pubkey, + { client = nostrClient, includeEmpty = false } = {}, +) { + const author = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!author) { + return []; + } + const { manager, relays } = getManagerAndRelays(client); + if (!manager || typeof manager.list !== "function" || !relays.length) { + return []; + } + + let events = []; + try { + events = await manager.list({ + relays, + filters: [{ kinds: [PLAYLIST_KIND], authors: [author], limit: LIST_LIMIT }], + }); + } catch (error) { + devLogger.warn("[playlists] Failed to list creator playlists:", error); + return []; + } + + return newestPerDTag(events) + .map(parsePlaylistEvent) + .filter(Boolean) + .filter((playlist) => includeEmpty || playlist.items.length > 0) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); +} + +/** + * Fetch a single playlist by (pubkey, id). Returns the newest matching event + * parsed, or null. + * @param {string} pubkey + * @param {string} id + * @param {{ client?: any }} [opts] + * @returns {Promise} + */ +export async function fetchPlaylist(pubkey, id, { client = nostrClient } = {}) { + const author = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + const dTag = typeof id === "string" ? id.trim() : ""; + if (!author || !dTag) { + return null; + } + const { manager, relays } = getManagerAndRelays(client); + if (!manager || typeof manager.list !== "function" || !relays.length) { + return null; + } + + let events = []; + try { + events = await manager.list({ + relays, + filters: [ + { + kinds: [PLAYLIST_KIND], + authors: [author], + "#d": [dTag], + limit: 20, + }, + ], + }); + } catch (error) { + devLogger.warn("[playlists] Failed to fetch playlist:", error); + return null; + } + + const newest = newestPerDTag(events)[0]; + return newest ? parsePlaylistEvent(newest) : null; +} + +/** + * Build + sign + publish a playlist (replaces the same `d` on relays, NIP-33). + * @param {Object} playlist { pubkey, id, title, description, image, items } + * @param {{ client?: any }} [opts] + * @returns {Promise} the signed event + */ +export async function publishPlaylist(playlist, { client = nostrClient } = {}) { + const event = buildPlaylist(playlist); + const result = await client.signAndPublishEvent(event, { + context: "playlist", + }); + return result?.signedEvent || null; +} + +export default { + fetchCreatorPlaylists, + fetchPlaylist, + publishPlaylist, +}; diff --git a/tests/playlist-facade.test.mjs b/tests/playlist-facade.test.mjs new file mode 100644 index 00000000..3e255826 --- /dev/null +++ b/tests/playlist-facade.test.mjs @@ -0,0 +1,138 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + fetchCreatorPlaylists, + fetchPlaylist, + publishPlaylist, +} from "../js/playlists/playlistFacade.js"; +import { + buildPlaylistEvent, + PLAYLIST_KIND, +} from "../js/nostrEventSchemas.js"; + +const AUTHOR = "a".repeat(64); +const OTHER = "c".repeat(64); +const coord = (d) => `30078:${"b".repeat(64)}:${d}`; + +function playlistEvent({ pubkey = AUTHOR, id, title, coords = [], created_at }) { + const event = buildPlaylistEvent({ + pubkey, + created_at, + dTagValue: id, + title, + videoCoordinates: coords, + }); + event.id = `id-${id}-${created_at}`; + return event; +} + +// Mock client: a subscription manager whose list() filters an in-memory event +// set by kinds/authors/#d, plus a signAndPublishEvent that captures the event. +function mockClient({ events = [], onPublish } = {}) { + return { + relays: ["wss://mock.relay"], + getSubscriptionManager: () => ({ + list: async ({ filters }) => { + const f = filters[0] || {}; + return events.filter((ev) => { + if (f.kinds && !f.kinds.includes(ev.kind)) return false; + if (f.authors && !f.authors.includes(ev.pubkey)) return false; + if (f["#d"]) { + const d = ev.tags.find((t) => t[0] === "d")?.[1]; + if (!f["#d"].includes(d)) return false; + } + return true; + }); + }, + }), + signAndPublishEvent: async (event, options) => { + if (onPublish) onPublish(event, options); + return { signedEvent: { ...event, id: "signed-id", sig: "sig" } }; + }, + }; +} + +test("fetchCreatorPlaylists returns newest-per-id, drops empties, sorts newest-first", async () => { + const events = [ + // Same id "a" published twice — the newer (created_at 200) must win. + playlistEvent({ id: "a", title: "A old", coords: [coord("v1")], created_at: 100 }), + playlistEvent({ id: "a", title: "A new", coords: [coord("v1"), coord("v2")], created_at: 200 }), + playlistEvent({ id: "b", title: "B", coords: [coord("v9")], created_at: 300 }), + // Empty playlist — dropped from the listing by default. + playlistEvent({ id: "empty", title: "Empty", coords: [], created_at: 400 }), + // Another author's playlist — must not appear. + playlistEvent({ pubkey: OTHER, id: "x", title: "Other", coords: [coord("v0")], created_at: 500 }), + ]; + + const playlists = await fetchCreatorPlaylists(AUTHOR, { client: mockClient({ events }) }); + + assert.deepEqual(playlists.map((p) => p.id), ["b", "a"], "sorted newest-first, empties + other author dropped"); + const a = playlists.find((p) => p.id === "a"); + assert.equal(a.title, "A new", "newest copy wins"); + assert.equal(a.items.length, 2); +}); + +test("fetchCreatorPlaylists can include empty playlists when asked", async () => { + const events = [playlistEvent({ id: "empty", title: "Empty", coords: [], created_at: 1 })]; + const playlists = await fetchCreatorPlaylists(AUTHOR, { + client: mockClient({ events }), + includeEmpty: true, + }); + assert.deepEqual(playlists.map((p) => p.id), ["empty"]); +}); + +test("fetchCreatorPlaylists returns [] for blank pubkey or no relays", async () => { + assert.deepEqual(await fetchCreatorPlaylists("", { client: mockClient({}) }), []); + const noRelays = { ...mockClient({}), relays: [] }; + assert.deepEqual(await fetchCreatorPlaylists(AUTHOR, { client: noRelays }), []); +}); + +test("fetchPlaylist returns the newest matching playlist parsed", async () => { + const events = [ + playlistEvent({ id: "list", title: "old", coords: [coord("v1")], created_at: 10 }), + playlistEvent({ id: "list", title: "new", coords: [coord("v1"), coord("v2")], created_at: 20 }), + playlistEvent({ id: "other", title: "nope", coords: [coord("v3")], created_at: 99 }), + ]; + const parsed = await fetchPlaylist(AUTHOR, "list", { client: mockClient({ events }) }); + assert.equal(parsed.title, "new"); + assert.equal(parsed.id, "list"); + assert.equal(parsed.items.length, 2); + + assert.equal(await fetchPlaylist(AUTHOR, "missing", { client: mockClient({ events }) }), null); +}); + +test("publishPlaylist builds a kind-30082 event and publishes with the playlist context", async () => { + let published = null; + let ctx = null; + const client = mockClient({ + onPublish: (event, options) => { + published = event; + ctx = options?.context; + }, + }); + + const signed = await publishPlaylist( + { + pubkey: AUTHOR, + id: "new-list", + title: "Fresh", + items: [ + { type: "a", value: coord("v1") }, + { type: "a", value: coord("v2") }, + ], + created_at: 777, + }, + { client }, + ); + + assert.equal(published.kind, PLAYLIST_KIND); + assert.equal(ctx, "playlist"); + assert.equal(published.tags.find((t) => t[0] === "d")?.[1], "new-list"); + assert.equal(published.tags.find((t) => t[0] === "title")?.[1], "Fresh"); + assert.deepEqual( + published.tags.filter((t) => t[0] === "a").map((t) => t[1]), + [coord("v1"), coord("v2")], + ); + assert.equal(signed.id, "signed-id", "returns the signed event"); +}); From 85903965a493410819c477bf32f0b4f16b6fac80 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:12:37 -0400 Subject: [PATCH 11/83] playlists(#37): FEATURE_PLAYLISTS flag + channel "Playlists" section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js: FEATURE_PLAYLISTS (default OFF) so nothing is user-visible until the whole playlist UI ships; flip to preview on unstable. - js/channelPlaylists.js: wireChannelPlaylists(pubkey) fetches the creator's playlists (facade) and renders a responsive row of cards (thumbnail + video count + title) linking to `#view=playlist&pubkey=…&id=…`. Hidden when the flag is off, no playlists, or the container is missing; a nav-token guards against a stale fetch resolving after a channel switch. DOM built with textContent (no innerHTML). Kept out of the 5729-line-capped channelProfile.js. - channelProfile.js: one-line wireChannelPlaylists(currentChannelHex) hook next to wireChannelZapTotal (no-op while the flag is off). - views/channel-profile.html: #channelPlaylistsSection above the video grid. - tailwind.source.css: .playlist-grid / .playlist-card* (token-only; responsive via @apply like .bv-grid-video). Lint + build clean. The playlist view the cards link to lands next. Co-Authored-By: Claude Opus 4.8 --- css/tailwind.source.css | 62 ++++++++++++++++++ js/channelPlaylists.js | 128 +++++++++++++++++++++++++++++++++++++ js/channelProfile.js | 4 ++ js/constants.js | 7 ++ views/channel-profile.html | 9 +++ 5 files changed, 210 insertions(+) create mode 100644 js/channelPlaylists.js diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 1cf66059..9549e477 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2951,6 +2951,68 @@ background-color: currentColor; } + /* #37: channel playlists section — a responsive row of playlist cards. */ + .playlist-grid { + @apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4; + gap: var(--space-md); + } + + .playlist-card { + display: flex; + flex-direction: column; + gap: var(--space-2xs); + text-decoration: none; + transition: transform 0.12s ease-out; + } + + .playlist-card:hover { + transform: translateY(-2px); + } + + .playlist-card__thumb { + position: relative; + display: flex; + align-items: flex-end; + justify-content: flex-end; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: var(--radius-sm); + background-color: var(--color-page-alt); + border: var(--border-width-hairline) solid var(--color-page-alt); + } + + .playlist-card__image { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + object-fit: cover; + } + + .playlist-card__count { + position: relative; + margin: var(--space-2xs); + border-radius: var(--radius-sm); + padding-block: var(--space-3xs); + padding-inline: var(--space-2xs); + font-size: var(--font-size-2xs); + font-weight: 500; + color: var(--color-text-strong); + background-color: var(--color-page); + } + + .playlist-card__title { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + /* #43: always-visible active-identity pill. Rendered after the header in flow and pulled up with a negative block-start margin so it sits half-on / half-off the header divider — every pane shows who you're acting as. */ diff --git a/js/channelPlaylists.js b/js/channelPlaylists.js new file mode 100644 index 00000000..971670fc --- /dev/null +++ b/js/channelPlaylists.js @@ -0,0 +1,128 @@ +// js/channelPlaylists.js +// +// Channel-page "Playlists" section (#37). Fetches the creator's playlists and +// renders a row of cards linking to the playlist view. Kept out of +// channelProfile.js (that file is at its size cap) and wired in via a one-line +// hook, mirroring channelZapTotal.js. Gated by FEATURE_PLAYLISTS — with the flag +// off (default) it renders nothing and does no fetch, so it's invisible until +// the whole playlist UI ships. + +import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js"; +import { FEATURE_PLAYLISTS } from "./constants.js"; +import { devLogger } from "./utils/logger.js"; + +let currentToken = 0; + +function clearChildren(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +function playlistHref(playlist) { + const params = new URLSearchParams({ + view: "playlist", + pubkey: playlist.pubkey, + id: playlist.id, + }); + return `#${params.toString()}`; +} + +function renderPlaylistCard(doc, playlist) { + const card = doc.createElement("a"); + card.href = playlistHref(playlist); + card.className = "playlist-card"; + card.setAttribute( + "aria-label", + `Playlist: ${playlist.title} (${playlist.items.length} videos)`, + ); + + const thumb = doc.createElement("div"); + thumb.className = "playlist-card__thumb"; + if (playlist.image) { + const img = doc.createElement("img"); + img.src = playlist.image; + img.alt = ""; + img.loading = "lazy"; + img.referrerPolicy = "no-referrer"; + img.className = "playlist-card__image"; + thumb.appendChild(img); + } + const count = doc.createElement("span"); + count.className = "playlist-card__count"; + const n = playlist.items.length; + count.textContent = `${n} ${n === 1 ? "video" : "videos"}`; + thumb.appendChild(count); + card.appendChild(thumb); + + const title = doc.createElement("p"); + title.className = "playlist-card__title"; + title.textContent = playlist.title; + card.appendChild(title); + + return card; +} + +export function teardownChannelPlaylists() { + currentToken += 1; +} + +/** + * Populate the channel "Playlists" section for a creator. Hidden when the flag + * is off, the container is missing, or the creator has no playlists. + * @param {string} pubkey the channel's hex pubkey + * @param {{ document?: Document, client?: any }} [opts] + */ +export async function wireChannelPlaylists( + pubkey, + { document: doc = globalThis.document, client } = {}, +) { + teardownChannelPlaylists(); + const token = currentToken; + + const section = + doc && typeof doc.getElementById === "function" + ? doc.getElementById("channelPlaylistsSection") + : null; + const grid = doc?.getElementById?.("channelPlaylistsGrid") || null; + if (!section || !grid) { + return; + } + + const hide = () => section.classList.add("hidden"); + const show = () => section.classList.remove("hidden"); + + const hex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!FEATURE_PLAYLISTS || !hex) { + hide(); + return; + } + + let playlists = []; + try { + playlists = await fetchCreatorPlaylists(hex, client ? { client } : {}); + } catch (error) { + devLogger.warn("[channelPlaylists] Failed to load playlists:", error); + playlists = []; + } + + // A newer navigation (channel switch / teardown) superseded this fetch. + if (token !== currentToken) { + return; + } + + if (!playlists.length) { + hide(); + return; + } + + clearChildren(grid); + const frag = doc.createDocumentFragment(); + for (const playlist of playlists) { + frag.appendChild(renderPlaylistCard(doc, playlist)); + } + grid.appendChild(frag); + show(); +} + +export default wireChannelPlaylists; diff --git a/js/channelProfile.js b/js/channelProfile.js index 5c476341..2da3d44a 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -3,6 +3,7 @@ import { nostrClient } from "./nostrClientFacade.js"; import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js"; import { wireChannelZapTotal } from "./channelZapTotal.js"; +import { wireChannelPlaylists } from "./channelPlaylists.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5231,6 +5232,9 @@ function applyChannelProfileMetadata({ // #47: total sats zapped to this creator (p: pointer in the store). wireChannelZapTotal(currentChannelHex); + + // #37: creator playlists section (no-op unless FEATURE_PLAYLISTS is on). + wireChannelPlaylists(currentChannelHex); } async function fetchChannelProfileFromRelays(pubkey) { diff --git a/js/constants.js b/js/constants.js index 13e7d0f4..e65dbb65 100644 --- a/js/constants.js +++ b/js/constants.js @@ -201,6 +201,7 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab. FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab. FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false. + FEATURE_PLAYLISTS: false, // Creator playlists (#37): channel section + playlist view + add-to-playlist. Default OFF until the UI is complete. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, @@ -226,6 +227,7 @@ const runtimeFlags = (() => { FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED, FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED, FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY, + FEATURE_PLAYLISTS: DEFAULT_FLAGS.FEATURE_PLAYLISTS, TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], @@ -281,6 +283,11 @@ export let FEATURE_ZAP_TALLY = coerceBoolean( DEFAULT_FLAGS.FEATURE_ZAP_TALLY ); +export let FEATURE_PLAYLISTS = coerceBoolean( + runtimeFlags.FEATURE_PLAYLISTS, + DEFAULT_FLAGS.FEATURE_PLAYLISTS +); + export let FEATURE_TRUST_SEEDS = coerceBoolean( runtimeFlags.FEATURE_TRUST_SEEDS, DEFAULT_FLAGS.FEATURE_TRUST_SEEDS diff --git a/views/channel-profile.html b/views/channel-profile.html index 76d68571..d73fcf3c 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -156,6 +156,15 @@
+ + +

Videos by This User

Playlist

From 2a543c3ba9a7b62a6827c4d5f3c1404d6ffaabbf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:48:03 -0400 Subject: [PATCH 16/83] playlists(#37): playlist view fetches its videos (fix deep-link showing recent feed) On a DIRECT load of a playlist URL the video cache is empty, so the view resolved zero videos and the shared #videoList kept showing the default recent feed. The view now fetches the referenced creators' videos from relays (nostrService.fetchVideosByAuthors) before resolving, so a cold/deep-linked playlist renders its ACTUAL videos in order. Resolution runs against the fetched pool + the active cache; the grid is always rendered (even empty) so it can't fall through to the recent feed. Co-Authored-By: Claude Opus 4.8 --- js/playlistView.js | 62 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/js/playlistView.js b/js/playlistView.js index 8cfe5188..4b39cbd3 100644 --- a/js/playlistView.js +++ b/js/playlistView.js @@ -25,22 +25,15 @@ function readParams() { }; } -// Resolve the playlist's items to the app's active video objects, in playlist -// order. Unresolved refs (video not in the cache) are skipped — the count line +// Resolve the playlist's items to video objects, in playlist order, from the +// given pool. Unresolved refs (video missing) are skipped — the count line // reflects how many resolved. -function resolveOrderedVideos(playlist) { - let active = []; - try { - active = nostrClient.getActiveVideos() || []; - } catch (error) { - active = []; - } - +function resolveOrderedVideos(playlist, videoPool) { const byCoordinate = new Map(); const byEventId = new Map(); - for (const video of active) { + for (const video of videoPool) { const coordinate = buildVideoAddressPointer(video); - if (coordinate) { + if (coordinate && !byCoordinate.has(coordinate)) { byCoordinate.set(coordinate, video); } if (video && typeof video.id === "string" && video.id) { @@ -61,6 +54,39 @@ function resolveOrderedVideos(playlist) { return ordered; } +// The videos to resolve against: the referenced creators' videos (fetched from +// relays so a DIRECT load / cold cache still works) plus whatever's already +// active. Without the fetch, a deep-linked playlist resolves nothing and the +// shared grid shows the default feed instead. +async function gatherVideoPool(playlist, app) { + const authors = [ + ...new Set( + playlist.items + .filter((item) => item.type === "a") + .map((item) => item.value.split(":")[1]) + .filter(Boolean), + ), + ]; + + let fetched = []; + if (authors.length && typeof app?.nostrService?.fetchVideosByAuthors === "function") { + try { + fetched = (await app.nostrService.fetchVideosByAuthors(authors)) || []; + } catch (error) { + devLogger.warn("[playlistView] Failed to fetch playlist videos:", error); + } + } + + let active = []; + try { + active = nostrClient.getActiveVideos() || []; + } catch (error) { + active = []; + } + + return [...fetched, ...active]; +} + function setText(id, text) { const el = document.getElementById(id); if (el) { @@ -103,7 +129,8 @@ export async function initPlaylistView({ getApp } = {}) { setText("playlistTitle", playlist.title); - const videos = resolveOrderedVideos(playlist); + const pool = await gatherVideoPool(playlist, app); + const videos = resolveOrderedVideos(playlist, pool); const total = playlist.items.length; setText( "playlistMeta", @@ -112,6 +139,9 @@ export async function initPlaylistView({ getApp } = {}) { : `${videos.length} of ${total} videos available`, ); + // Always render into the shared grid — even an empty playlist must OVERRIDE + // the default feed that populates #videoList, so a deep-linked playlist never + // shows unrelated recent videos. if (app && typeof app.mountVideoListView === "function") { app.mountVideoListView({ includeTags: false }); } @@ -119,7 +149,11 @@ export async function initPlaylistView({ getApp } = {}) { try { await app.renderVideoList({ videos, - metadata: { reason: "playlist", preserveOrder: true }, + metadata: { + reason: "playlist", + preserveOrder: true, + emptyStateMessage: "None of this playlist's videos could be loaded.", + }, }); } catch (error) { devLogger.warn("[playlistView] Failed to render playlist videos:", error); From 73a99358a2a042158c28c6ba11007c963e440b4a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:16:06 -0400 Subject: [PATCH 17/83] =?UTF-8?q?playlists(#37):=20profile-modal=20"Playli?= =?UTF-8?q?sts"=20tab=20=E2=80=94=20rename=20/=20delete=20/=20reorder=20/?= =?UTF-8?q?=20remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A central management surface, a sibling tab to "My Videos" (gated by FEATURE_PLAYLISTS — hidden when off). - components/profile-modal.html: "Playlists" nav item + #profilePanePlaylists. - js/ui/profileModal/ProfilePlaylistsController.js: lists the user's playlists (incl. empty) with a collapsible row each — Rename, Delete, and an expand to a video sublist with ↑/↓ reorder and Remove. Fetches the referenced creators' videos so the sublist shows real titles/thumbnails. Edits are optimistic and re-sync on failure; publishes via the facade; dispatches bitvid:playlists-changed so the channel section + this pane stay in sync. - profileModalController.js: instantiate + bind nav/pane + cache DOM + register listeners + selectPane("playlists") refresh; hide the tab when the flag is off. - promptDialog.js: showTextPrompt (plain text prompt) for rename. - playlistFacade.js: deletePlaylist — NIP-09 kind-5 (a + e refs) plus an empty republish so kind-5-ignoring readers also drop it. - tailwind.source.css: .playlist-row* styles (token-only). Lint + build clean; profile-modal-controller tests 26/26. Co-Authored-By: Claude Opus 4.8 --- components/profile-modal.html | 83 ++++ css/tailwind.source.css | 92 ++++ js/playlists/playlistFacade.js | 50 ++ .../ProfilePlaylistsController.js | 430 ++++++++++++++++++ js/ui/profileModalController.js | 15 + js/ui/promptDialog.js | 107 +++++ 6 files changed, 777 insertions(+) create mode 100644 js/ui/profileModal/ProfilePlaylistsController.js diff --git a/components/profile-modal.html b/components/profile-modal.html index a4ee0f30..dcf9b2c7 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -211,6 +211,28 @@ My Videos + +
+ + +
    + +