From 298e456ff955a8311107ba66282c8648278e3a54 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:45:23 -0400 Subject: [PATCH 01/13] [dm] Clear the previous account's DMs on account switch (not just the modal view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching accounts (e.g. NIP-07 → nsec) still showed the OLD account's direct messages. The earlier switch fix reset only the profile modal's DM view, but the DM data lives in the app-wide nostrService store (this.dmMessages), which the messages pane reads via listDirectMessageConversationSummaries / getDirectMessagesForConversation. Logout clears that store (stopDirectMessageSubscription + clearDirectMessages); the switch path never did, so the old messages persisted. Fix: on an identity change, authSessionCoordinator.handleAuthLogin now stops the old DM subscription and clears the in-memory DM store BEFORE the new account's subscription starts — so the pane shows the new account's DMs (or empty→new), never the old. Uses a new clearDirectMessages({ keepSnapshot: true }) option: it wipes the in-memory store but preserves each account's PERSISTED per-actor DM snapshot, so switching back to an account stays fast (per-account DM cache). Logout still fully clears (keepSnapshot defaults false). Tests: tests/services/nostr-service.test.mjs — clearDirectMessages empties the in-memory store + resets the active actor and emits cleared/updated, in both keepSnapshot modes. Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 16 +++++++++++++ js/services/nostrService.js | 8 +++++-- tests/services/nostr-service.test.mjs | 34 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 6bb442fa..cc2b96e6 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -200,6 +200,22 @@ export function createAuthSessionCoordinator(deps) { if (detail?.identityChanged) { this.resetViewLoggingState(); + + // Account switch: clear the PREVIOUS account's cached DMs + stop its + // subscription before the new account loads. The DM store is app-wide on + // nostrService, so resetting only the modal's view left the old account's + // messages on screen after a switch (e.g. NIP-07 → nsec) — logout clears it + // the same way. keepSnapshot keeps each account's persisted cache so + // switching back stays fast. + try { + this.nostrService?.stopDirectMessageSubscription?.(); + this.nostrService?.clearDirectMessages?.({ emit: true, keepSnapshot: true }); + } catch (error) { + devLogger.warn( + "[Application] Failed to reset DMs on account switch:", + error, + ); + } } this.resetPermissionPromptState(); diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 203d915f..8b3bf168 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -786,7 +786,11 @@ export class NostrService { return Array.isArray(this.dmMessages) ? [...this.dmMessages] : []; } - clearDirectMessages({ actorPubkey = null, emit = true } = {}) { + // keepSnapshot: clear the IN-MEMORY DM store but preserve each account's + // persisted per-actor snapshot. Used on account SWITCH so the new account + // doesn't show the previous account's messages, while switching back stays fast + // (its cached DMs survive). Logout still fully clears (keepSnapshot defaults off). + clearDirectMessages({ actorPubkey = null, emit = true, keepSnapshot = false } = {}) { this.dmMessages = []; this.dmMessageIndex = new Map(); this.dmHydratedFromSnapshot = false; @@ -805,7 +809,7 @@ export class NostrService { ? actorPubkey : this.dmActorPubkey || this.resolveActiveDmActor(), ); - if (activeActor) { + if (activeActor && !keepSnapshot) { clearDirectMessageSnapshot(activeActor).catch((error) => { userLogger.warn("[nostrService] Failed to clear DM snapshot", error); }); diff --git a/tests/services/nostr-service.test.mjs b/tests/services/nostr-service.test.mjs index 9a823142..8d36d9eb 100644 --- a/tests/services/nostr-service.test.mjs +++ b/tests/services/nostr-service.test.mjs @@ -80,4 +80,38 @@ describe("NostrService", () => { assert.equal(mockClient.pool.list.mock.callCount(), 1); }); }); + + // Account switch must not leave the previous account's DMs on screen: the DM + // store lives here (nostrService), so a switch clears the in-memory store. With + // keepSnapshot it preserves each account's persisted cache for fast switch-back. + describe("clearDirectMessages", () => { + it("empties the in-memory DM store and resets the active actor", () => { + nostrService.dmMessages = [{ id: "m1" }, { id: "m2" }]; + nostrService.dmMessageIndex = new Map([["m1", 0], ["m2", 1]]); + nostrService.dmActorPubkey = "a".repeat(64); + + const events = []; + const originalEmit = nostrService.emit; + nostrService.emit = (name) => events.push(name); + try { + nostrService.clearDirectMessages({ emit: true, keepSnapshot: true }); + } finally { + nostrService.emit = originalEmit; + } + + assert.deepEqual(nostrService.dmMessages, []); + assert.equal(nostrService.dmMessageIndex.size, 0); + assert.equal(nostrService.dmActorPubkey, null); + assert.ok(events.includes("directMessages:cleared")); + assert.ok(events.includes("directMessages:updated")); + }); + + it("clears the in-memory store in both keepSnapshot modes (logout parity)", () => { + nostrService.dmMessages = [{ id: "m1" }]; + nostrService.dmMessageIndex = new Map([["m1", 0]]); + nostrService.clearDirectMessages({ emit: false, keepSnapshot: false }); + assert.deepEqual(nostrService.dmMessages, []); + assert.equal(nostrService.dmMessageIndex.size, 0); + }); + }); }); From a20388c5d6007265335e84999ac2bcbe7e3f5f68 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:51:10 -0400 Subject: [PATCH 02/13] =?UTF-8?q?[video-modal]=20Fix=20dead=20channel/crea?= =?UTF-8?q?tor=20link=20=E2=80=94=20event-name=20mismatch=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the creator avatar/name in the video player modal did nothing. VideoModal dispatched "navigate:profile", but ModalManager only listens for "creator:navigate" (→ app.openCreatorChannel), so the event went into the void — same class as the old action:embed bug noted just above the handler. Fix: - VideoModal.handleCreatorNavigation dispatches "creator:navigate" (the listened name) with { pubkey }. - ModalManager's handler forwards event.detail.pubkey to app.openCreatorChannel. - routerCoordinator.openCreatorChannel(pubkey) accepts an optional pubkey (prefers it, falls back to the active video's creator), so it navigates to the modal's own creator rather than whatever currentVideo happens to be. Tests: tests/video-modal-controllers.test.mjs (+2 — dispatches creator:navigate with the pubkey and never the dead navigate:profile; no-op without a pubkey). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/app/routerCoordinator.js | 12 ++++-- js/ui/ModalManager.js | 4 +- js/ui/components/VideoModal.js | 6 ++- tests/video-modal-controllers.test.mjs | 58 ++++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 13 ++++-- 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/js/app/routerCoordinator.js b/js/app/routerCoordinator.js index 92c5d713..0f019db8 100644 --- a/js/app/routerCoordinator.js +++ b/js/app/routerCoordinator.js @@ -45,15 +45,21 @@ export function createRouterCoordinator(deps) { window.location.hash = `#view=channel-profile&npub=${npub}`; }, - openCreatorChannel() { - if (!this.currentVideo || !this.currentVideo.pubkey) { + openCreatorChannel(pubkey) { + // Prefer an explicit pubkey (e.g. the video modal's own creator) and fall + // back to the active video's creator. + const targetPubkey = + (typeof pubkey === "string" && pubkey.trim()) || + this.currentVideo?.pubkey || + ""; + if (!targetPubkey) { this.showError("No creator info available."); return; } try { // Encode the hex pubkey to npub - const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); + const npub = window.NostrTools.nip19.npubEncode(targetPubkey); // Close the video modal this.hideModal(); diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index cdf0c13b..7ac2cc50 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -574,8 +574,8 @@ export default class ModalManager { this.videoModalHandlers.contextAction, ); - this.videoModalHandlers.creatorNavigate = () => { - app.openCreatorChannel(); + this.videoModalHandlers.creatorNavigate = (event) => { + app.openCreatorChannel(event?.detail?.pubkey); }; this.videoModal.addEventListener( "creator:navigate", diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index a612b40e..4f2d140a 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -3017,7 +3017,11 @@ export class VideoModal { handleCreatorNavigation() { if (this.activeVideo?.pubkey) { - this.dispatch("navigate:profile", { pubkey: this.activeVideo.pubkey }); + // ModalManager listens for "creator:navigate" (→ app.openCreatorChannel). + // The previous "navigate:profile" name had NO listener, so clicking the + // creator avatar/name did nothing — the event went into the void (same class + // as the old action:embed bug above). + this.dispatch("creator:navigate", { pubkey: this.activeVideo.pubkey }); } } diff --git a/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs index 4199e211..b74bc17d 100644 --- a/tests/video-modal-controllers.test.mjs +++ b/tests/video-modal-controllers.test.mjs @@ -190,3 +190,61 @@ test("VideoModal trims tag strip to fit modal width", async (t) => { ); }); + +// Channel-profile link in the player modal (TODO #53): clicking the creator +// avatar/name must navigate to the creator's channel. It dispatched +// "navigate:profile", which had NO listener (ModalManager listens for +// "creator:navigate"), so the click did nothing — same class as the old +// action:embed bug. Now it dispatches "creator:navigate" with the pubkey. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-video-modal-creator-nav +// given: "a video modal with an active video" +// when: "handleCreatorNavigation runs (creator avatar/name click)" +// then: "it dispatches creator:navigate with the pubkey, not the dead navigate:profile" +// observable_outcomes: +// - "creator:navigate fires with { pubkey }" +// - "the old dead navigate:profile event never fires" +// determinism_controls: +// - "JSDOM modal via setupModal; synchronous dispatch" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "asserting the broken event name"] +// relaxation: +// did_relax_any_assertion: false + +test("creator link dispatches creator:navigate with the pubkey (not the dead navigate:profile)", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + modal.activeVideo = { pubkey: "a".repeat(64) }; + + let creatorNavigate = null; + let deadEventFired = false; + modal.addEventListener("creator:navigate", (event) => { + creatorNavigate = event.detail; + }); + modal.addEventListener("navigate:profile", () => { + deadEventFired = true; + }); + + modal.handleCreatorNavigation(); + + assert.ok(creatorNavigate, "creator:navigate should fire (ModalManager listens for it)"); + assert.equal(creatorNavigate.pubkey, "a".repeat(64)); + assert.equal(deadEventFired, false, "the old listener-less navigate:profile must not be used"); +}); + +test("creator navigation is a no-op without an active video pubkey", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + modal.activeVideo = { pubkey: "" }; + let fired = false; + modal.addEventListener("creator:navigate", () => { + fired = true; + }); + modal.handleCreatorNavigation(); + assert.equal(fired, false, "no pubkey → no navigation event"); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index d4ac2ba3..b5f7620a 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1636,10 +1636,15 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. `onVideosShouldRefresh` path #46 uses, keyed by `videoRootId`. ### 53. Channel-profile link in the video player modal doesn't work (BUG) -- [ ] Clicking the channel/creator link inside the video player modal does nothing - (should navigate to that author's channel profile view). Audit the modal's - channel-link handler (`js/ui/videoModal*` / the author byline) — likely a missing - or wrong nav callback (compare with the working channel links in the grid cards). +- [x] **FIXED 2026-07-02.** Event-name mismatch: `VideoModal.handleCreatorNavigation` + dispatched `"navigate:profile"`, but ModalManager only listens for + `"creator:navigate"` → the click went into the void (same class as the old + `action:embed` bug). Renamed the dispatch to `"creator:navigate"` and threaded the + modal's own creator pubkey through: the handler now passes `event.detail.pubkey` + to `app.openCreatorChannel(pubkey)`, and `routerCoordinator.openCreatorChannel` + accepts an optional pubkey (falls back to the active video's creator). Tests: + `tests/video-modal-controllers.test.mjs` (+2 — dispatches creator:navigate with + the pubkey, never the dead event; no-op without a pubkey). Lint + build green. ### 54. Like / dislike buttons in the video player modal don't work (BUG) - [ ] The reaction (like/dislike) buttons in the player modal are unresponsive / don't From cf6a830183bd1fe81dbe4b47491b8742a30b9777 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:08:32 -0400 Subject: [PATCH 03/13] [upload] Derive a webseed torrent for external-URL posts (P2P for external links) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Posting an external URL used to skip the torrent/webseed benefit entirely. Now the upload modal's external tab auto-derives a WebTorrent webseed from the URL at publish time: fetch the remote file (CORS-permitting), stream it under a hard size cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=). Best-effort — CORS failure / over the cap / any error silently degrades to URL-only. Only runs when the user didn't paste their own magnet. - js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap, aborts past it, short-circuits on content-length), buildWebseedMagnet, and deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit tested without network/WebTorrent. - mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when storage is unlocked, also hosts the tiny .torrent so the webseed can actually bootstrap P2P (xs=) — best-effort, falls back to ws=-only. - UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the .torrent when storage is unlocked, shows progress on the submit button, and never throws. Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing, streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected- hash, failure propagation). Lint + build green. Next: the same for editing a URL in the Edit modal. Co-Authored-By: Claude Opus 4.8 --- js/ui/components/UploadModal.js | 59 +++++++++- js/ui/components/mediaUploader.js | 91 +++++++++++++++ js/utils/externalUrlTorrent.js | 167 ++++++++++++++++++++++++++++ tests/external-url-torrent.test.mjs | 163 +++++++++++++++++++++++++++ 4 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 js/utils/externalUrlTorrent.js create mode 100644 tests/external-url-torrent.test.mjs diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 5fcaa0fe..05787993 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1259,13 +1259,70 @@ export class UploadModal { throw new Error("Please provide at least a Video URL or Magnet Link."); } - if (!hasUrl && !hasImeta && hasMagnet) { + // Auto-derive a webseed torrent from the external URL so the link keeps the + // P2P benefit (fetch + hash the remote file → magnet with ws=url). Best-effort + // and size-capped: on CORS / too-large / any error it silently degrades to + // URL-only. Only runs when the user didn't already paste their own magnet. + if (hasUrl && !hasMagnet) { + const derived = await this.deriveExternalWebseed(metadata.url); + if (derived?.magnet) { + metadata.magnet = derived.magnet; + metadata.infoHash = derived.infoHash || metadata.infoHash || ""; + metadata.ws = [metadata.url, this.inputs.ws?.value || ""]; + if (derived.torrentUrl) metadata.xs = derived.torrentUrl; + } + } + + if (!hasUrl && !hasImeta && metadata.magnet.length > 0) { if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return; } await this.publish(metadata); } + // Best-effort webseed derivation for an external URL (TODO: external-URL P2P). + // Hosts the tiny .torrent (xs=) only when storage is actually unlocked; otherwise + // returns a ws=-only magnet. Never throws — returns null so the caller publishes + // URL-only. + async deriveExternalWebseed(url) { + if (typeof this.mediaUploader?.deriveTorrentForExternalUrl !== "function") { + return null; + } + const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + const canHostTorrent = Boolean( + this.activeProvider && + this.activeCredentials && + pubkey && + this.storageService?.isUnlocked?.(pubkey), + ); + const previousLabel = this.submitButton?.textContent; + if (this.submitButton) { + this.submitButton.disabled = true; + this.submitButton.textContent = "Computing torrent hash…"; + } + try { + return await this.mediaUploader.deriveTorrentForExternalUrl(url, { + provider: canHostTorrent ? this.activeProvider : undefined, + credentials: canHostTorrent ? this.activeCredentials : undefined, + onProgress: ({ label }) => { + if (this.submitStatus && label) this.submitStatus.textContent = label; + }, + }); + } catch (error) { + userLogger.warn( + "[UploadModal] External URL webseed derivation skipped:", + error?.message || error, + ); + return null; + } finally { + if (this.submitButton) { + this.submitButton.disabled = false; + if (previousLabel) this.submitButton.textContent = previousLabel; + } + if (this.submitStatus) this.submitStatus.textContent = ""; + } + } + async publish(metadata) { // Normalize & Publish const { payload, errors } = normalizeVideoNotePayload(metadata); diff --git a/js/ui/components/mediaUploader.js b/js/ui/components/mediaUploader.js index 00dee287..8f701cbf 100644 --- a/js/ui/components/mediaUploader.js +++ b/js/ui/components/mediaUploader.js @@ -12,6 +12,11 @@ import { calculateTorrentInfoHash, createTorrentMetadata, } from "../../utils/torrentHash.js"; +import { + deriveExternalUrlTorrent, + buildWebseedMagnet, + DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES, +} from "../../utils/externalUrlTorrent.js"; import { buildR2Key, buildPublicUrl } from "../../r2.js"; import { buildS3ObjectUrl } from "../../services/s3Service.js"; import { PROVIDERS } from "../../services/storageService.js"; @@ -307,6 +312,92 @@ export class MediaUploader { return result; } + + /** + * Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external + * link keeps the P2P benefit. Best-effort: fetches the remote file (CORS- + * permitting), streams it under a size cap, computes the infoHash, and builds a + * magnet with the URL as the webseed (ws=). If storage credentials are available + * it also hosts the tiny .torrent so the webseed can bootstrap P2P (xs=); + * otherwise it returns a ws=-only magnet. Any failure throws (coded) so the + * caller degrades to URL-only. + * + * @returns {Promise<{ infoHash, magnet, torrentUrl, torrentFile, name, hasValidInfoHash }>} + */ + async deriveTorrentForExternalUrl( + url, + { provider, credentials, onProgress, maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES } = {}, + ) { + const emit = (fraction, label) => { + if (typeof onProgress === "function") onProgress({ fraction, label }); + }; + + emit(0, "Fetching the file to compute its hash…"); + const derived = await deriveExternalUrlTorrent(url, { + maxBytes, + onProgress: ({ received, total }) => { + emit(total ? Math.min(1, received / total) : null, "Computing torrent hash…"); + }, + }); + + const result = { + infoHash: derived.infoHash, + magnet: derived.magnet, + torrentUrl: "", + torrentFile: derived.torrentFile, + name: derived.name, + hasValidInfoHash: Boolean(derived.infoHash), + }; + + // Host the tiny .torrent (metadata) so the webseed can actually bootstrap P2P. + // Requires storage; best-effort — a failure keeps the ws=-only magnet. + const effectiveProvider = + credentials?.provider || credentials?.meta?.provider || provider; + const npub = this.safeEncodeNpub(this.getCurrentPubkey()); + if (credentials && effectiveProvider && derived.torrentFile && npub) { + try { + const service = this.serviceFor(effectiveProvider); + const { settings, bucketEntry } = await service.prepareUpload(npub, { + credentials, + }); + const torrentKey = `${npub}/external/${derived.infoHash}.torrent`; + const torrentPublicUrl = this.publicUrlFor( + effectiveProvider, + bucketEntry.publicBaseUrl, + torrentKey, + ); + emit(null, "Hosting torrent metadata…"); + await service.uploadFile({ + file: derived.torrentFile, + bucket: bucketEntry.bucket, + key: torrentKey, + accountId: settings.accountId, + endpoint: settings.endpoint, + provider: settings.provider || effectiveProvider || "cloudflare_r2", + region: settings.region, + accessKeyId: settings.accessKeyId, + secretAccessKey: settings.secretAccessKey, + forcePathStyle: settings.forcePathStyle, + createBucketIfMissing: true, + }); + result.torrentUrl = torrentPublicUrl; + result.magnet = buildWebseedMagnet({ + infoHash: derived.infoHash, + url, + name: derived.name, + torrentUrl: torrentPublicUrl, + }); + } catch (torrentHostError) { + userLogger.warn( + "[mediaUploader] Could not host .torrent for external URL; publishing ws=-only:", + torrentHostError, + ); + } + } + + emit(1, "Ready to publish!"); + return result; + } } export { isR2Provider, normalizeInfoHash, isValidInfoHash }; diff --git a/js/utils/externalUrlTorrent.js b/js/utils/externalUrlTorrent.js new file mode 100644 index 00000000..a002b2d7 --- /dev/null +++ b/js/utils/externalUrlTorrent.js @@ -0,0 +1,167 @@ +// Derive a WebTorrent webseed magnet from an externally-hosted video URL (TODO +// #45-adjacent / external-URL P2P). Posting a plain URL skips the torrent/webseed +// benefit; here we (best-effort) fetch the remote file, compute its infoHash, and +// build a magnet with the URL as the webseed (ws=). Fully optional: any failure +// (CORS, over the size cap, network) leaves the caller to fall back to URL-only. +// +// Constraints baked in: +// - CORS: fetch()-ing the remote requires the host to allow it (same requirement +// as webseed playback), so callers must treat failure as "URL-only". +// - Whole-file download: an infoHash requires reading every byte, so we stream +// with a hard size cap and abort past it (never buffer an unbounded download). + +import { createTorrentMetadata as defaultCreateTorrentMetadata } from "./torrentHash.js"; + +export const DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES = 500 * 1024 * 1024; // 500 MB + +export function deriveNameFromUrl(url) { + try { + const parsed = new URL(url); + // A path ending in "/" has no filename. + if (parsed.pathname.endsWith("/")) { + return ""; + } + const last = parsed.pathname.split("/").filter(Boolean).pop() || ""; + const decoded = decodeURIComponent(last); + return decoded && /\S/.test(decoded) ? decoded : ""; + } catch (error) { + return ""; + } +} + +// magnet:?xt=urn:btih:&dn=&ws=[&xs=] +export function buildWebseedMagnet({ infoHash, url, name, torrentUrl } = {}) { + const hash = typeof infoHash === "string" ? infoHash.trim() : ""; + const webseed = typeof url === "string" ? url.trim() : ""; + if (!hash || !webseed) { + return ""; + } + const dn = encodeURIComponent(name || deriveNameFromUrl(webseed) || "video"); + let magnet = `magnet:?xt=urn:btih:${hash}&dn=${dn}&ws=${encodeURIComponent(webseed)}`; + if (typeof torrentUrl === "string" && torrentUrl.trim()) { + magnet += `&xs=${encodeURIComponent(torrentUrl.trim())}`; + } + return magnet; +} + +// Fetch a URL into a Blob, streaming with a hard byte cap. Throws a coded error +// on any failure so the caller can distinguish + degrade: +// fetch-failed | too-large | (network error from fetchImpl) +export async function fetchBlobWithCap( + url, + { + maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES, + onProgress, + fetchImpl = typeof fetch === "function" ? fetch : null, + } = {}, +) { + if (typeof fetchImpl !== "function") { + const error = new Error("fetch is unavailable in this environment."); + error.code = "fetch-unavailable"; + throw error; + } + + const response = await fetchImpl(url); + if (!response || !response.ok) { + const error = new Error( + `Could not fetch the URL (${response?.status ?? "no response"}).`, + ); + error.code = "fetch-failed"; + error.status = response?.status; + throw error; + } + + const declaredTotal = Number(response.headers?.get?.("content-length")) || 0; + if (declaredTotal && declaredTotal > maxBytes) { + const error = new Error("The file is larger than the size cap."); + error.code = "too-large"; + error.total = declaredTotal; + throw error; + } + + const contentType = response.headers?.get?.("content-type") || ""; + + // Prefer streaming so we can abort a too-large download mid-flight. + if (response.body && typeof response.body.getReader === "function") { + const reader = response.body.getReader(); + const chunks = []; + let received = 0; + for (;;) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + break; + } + received += value.byteLength; + if (received > maxBytes) { + try { + await reader.cancel(); + } catch (cancelError) { + // ignore cancel failures + } + const error = new Error("The file is larger than the size cap."); + error.code = "too-large"; + error.total = declaredTotal || received; + throw error; + } + chunks.push(value); + if (typeof onProgress === "function") { + onProgress({ received, total: declaredTotal }); + } + } + return new Blob(chunks, contentType ? { type: contentType } : undefined); + } + + // No streaming body: fall back to blob() and enforce the cap after the fact. + const blob = await response.blob(); + if (blob.size > maxBytes) { + const error = new Error("The file is larger than the size cap."); + error.code = "too-large"; + error.total = blob.size; + throw error; + } + return blob; +} + +// Best-effort: fetch + hash a remote URL and return { infoHash, magnet, torrentFile, +// name }. Throws (coded) on CORS/size/network failure — the caller degrades to +// URL-only. `createMetadata` + `fileFactory` are injectable for tests. +export async function deriveExternalUrlTorrent( + url, + { + maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES, + onProgress, + fetchImpl, + createMetadata = defaultCreateTorrentMetadata, + fileFactory, + } = {}, +) { + const trimmed = typeof url === "string" ? url.trim() : ""; + if (!trimmed) { + const error = new Error("A URL is required."); + error.code = "no-url"; + throw error; + } + + const blob = await fetchBlobWithCap(trimmed, { maxBytes, onProgress, fetchImpl }); + const name = deriveNameFromUrl(trimmed) || "video"; + const file = + typeof fileFactory === "function" + ? fileFactory(blob, name) + : new File([blob], name, blob.type ? { type: blob.type } : undefined); + + const metadata = await createMetadata(file, [trimmed]); + const infoHash = typeof metadata?.infoHash === "string" ? metadata.infoHash : ""; + if (!infoHash) { + const error = new Error("Failed to compute the torrent info hash."); + error.code = "hash-failed"; + throw error; + } + + return { + infoHash, + name, + torrentFile: metadata?.torrentFile || null, + magnet: buildWebseedMagnet({ infoHash, url: trimmed, name }), + }; +} diff --git a/tests/external-url-torrent.test.mjs b/tests/external-url-torrent.test.mjs new file mode 100644 index 00000000..41085817 --- /dev/null +++ b/tests/external-url-torrent.test.mjs @@ -0,0 +1,163 @@ +// Derive a webseed magnet from an external URL (external-URL P2P). We fetch the +// remote file (best-effort, CORS-permitting), stream it under a hard size cap, +// compute the infoHash, and build a ws= magnet. Any failure must degrade to +// URL-only (the caller catches). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-external-url-torrent +// given: "a remote URL + an injected fetch/hash" +// when: "deriveExternalUrlTorrent / fetchBlobWithCap / buildWebseedMagnet run" +// then: "magnet carries the infoHash + ws=url; oversize/fetch failures throw coded errors" +// observable_outcomes: +// - "buildWebseedMagnet: xt/dn/ws present; xs only when torrentUrl given; empty on missing infoHash/url" +// - "deriveNameFromUrl extracts + decodes the filename" +// - "fetchBlobWithCap streams under the cap and aborts past it (code too-large)" +// - "content-length over cap short-circuits before downloading" +// - "non-ok response -> fetch-failed" +// - "deriveExternalUrlTorrent returns { infoHash, magnet(ws=url) } via injected hash" +// determinism_controls: +// - "injected fetchImpl (fake Response) + createMetadata stub + fileFactory; no network/WebTorrent" +// 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 { + buildWebseedMagnet, + deriveNameFromUrl, + fetchBlobWithCap, + deriveExternalUrlTorrent, +} from "../js/utils/externalUrlTorrent.js"; + +function streamResponse(bytes, { contentLength, contentType = "video/mp4", ok = true, status = 200, chunkSize = 4 } = {}) { + const headers = new Map(); + if (contentLength !== null) { + headers.set("content-length", String(contentLength ?? bytes.length)); + } + if (contentType) headers.set("content-type", contentType); + let offset = 0; + return { + ok, + status, + headers: { get: (k) => headers.get(k.toLowerCase()) ?? null }, + body: { + getReader() { + return { + async read() { + if (offset >= bytes.length) return { done: true, value: undefined }; + const end = Math.min(offset + chunkSize, bytes.length); + const value = bytes.slice(offset, end); + offset = end; + return { done: false, value }; + }, + async cancel() {}, + }; + }, + }, + }; +} + +test("buildWebseedMagnet: xt/dn/ws, xs only when torrentUrl is given", () => { + const m = buildWebseedMagnet({ infoHash: "abc123", url: "https://h/v.mp4", name: "v.mp4" }); + assert.match(m, /^magnet:\?xt=urn:btih:abc123&dn=v\.mp4&ws=https%3A%2F%2Fh%2Fv\.mp4$/); + const withXs = buildWebseedMagnet({ infoHash: "abc", url: "https://h/v.mp4", torrentUrl: "https://h/v.torrent" }); + assert.match(withXs, /&xs=https%3A%2F%2Fh%2Fv\.torrent$/); +}); + +test("buildWebseedMagnet: empty when infoHash or url is missing", () => { + assert.equal(buildWebseedMagnet({ url: "https://h/v.mp4" }), ""); + assert.equal(buildWebseedMagnet({ infoHash: "abc" }), ""); + assert.equal(buildWebseedMagnet({}), ""); +}); + +test("deriveNameFromUrl extracts + decodes the filename", () => { + assert.equal(deriveNameFromUrl("https://h/path/My%20Clip.mp4"), "My Clip.mp4"); + assert.equal(deriveNameFromUrl("https://h/path/"), ""); + assert.equal(deriveNameFromUrl("not a url"), ""); +}); + +test("fetchBlobWithCap streams the whole body under the cap", async () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const progress = []; + const blob = await fetchBlobWithCap("https://h/v.mp4", { + maxBytes: 1000, + onProgress: (p) => progress.push(p.received), + fetchImpl: async () => streamResponse(bytes, { chunkSize: 4 }), + }); + assert.equal(blob.size, 10); + assert.deepEqual(progress, [4, 8, 10]); +}); + +test("fetchBlobWithCap aborts a stream that exceeds the cap (too-large)", async () => { + const bytes = new Uint8Array(20); + await assert.rejects( + () => + fetchBlobWithCap("https://h/v.mp4", { + maxBytes: 8, + // no content-length header → only the streaming guard can catch it + fetchImpl: async () => streamResponse(bytes, { contentLength: null, chunkSize: 4 }), + }), + (err) => err.code === "too-large", + ); +}); + +test("fetchBlobWithCap short-circuits on content-length over the cap (no download)", async () => { + let readerMade = false; + await assert.rejects( + () => + fetchBlobWithCap("https://h/big.mp4", { + maxBytes: 100, + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: { get: (k) => (k.toLowerCase() === "content-length" ? "999999" : null) }, + body: { getReader() { readerMade = true; return { read: async () => ({ done: true }) }; } }, + }), + }), + (err) => err.code === "too-large" && err.total === 999999, + ); + assert.equal(readerMade, false, "must not start reading the body when the size is known-too-big"); +}); + +test("fetchBlobWithCap: non-ok response -> fetch-failed", async () => { + await assert.rejects( + () => fetchBlobWithCap("https://h/404", { fetchImpl: async () => ({ ok: false, status: 403, headers: { get: () => null } }) }), + (err) => err.code === "fetch-failed" && err.status === 403, + ); +}); + +test("deriveExternalUrlTorrent returns infoHash + ws= magnet via the injected hasher", async () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const seen = { fileName: null, urlList: null }; + const result = await deriveExternalUrlTorrent("https://host/clips/My%20Clip.mp4", { + maxBytes: 1000, + fetchImpl: async () => streamResponse(bytes), + fileFactory: (blob, name) => ({ blob, name }), + createMetadata: async (file, urlList) => { + seen.fileName = file.name; + seen.urlList = urlList; + return { infoHash: "deadbeef", torrentFile: new Uint8Array([9]) }; + }, + }); + assert.equal(result.infoHash, "deadbeef"); + assert.equal(seen.fileName, "My Clip.mp4", "file name derived from the URL"); + assert.deepEqual(seen.urlList, ["https://host/clips/My%20Clip.mp4"], "url passed as the webseed"); + assert.match(result.magnet, /xt=urn:btih:deadbeef/); + assert.match(result.magnet, /ws=https%3A%2F%2Fhost%2Fclips%2FMy%2520Clip\.mp4/); + assert.ok(result.torrentFile, "torrentFile returned for optional .torrent hosting"); +}); + +test("deriveExternalUrlTorrent propagates a fetch failure (caller degrades to URL-only)", async () => { + await assert.rejects( + () => + deriveExternalUrlTorrent("https://host/blocked.mp4", { + fetchImpl: async () => ({ ok: false, status: 0, headers: { get: () => null } }), + createMetadata: async () => ({ infoHash: "x" }), + }), + (err) => err.code === "fetch-failed", + ); +}); From 11c7884366c0c0e30748d29a99be6b5956ea7b06 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:12:14 -0400 Subject: [PATCH 04/13] [edit] Derive a webseed torrent when editing a video's URL to an external link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the upload-modal external-URL webseed for the Edit modal: when the user changes a video's URL to an external link with no magnet, auto-derive the webseed torrent (fetch + hash the remote file → magnet ws=url) so an edited external URL keeps the P2P benefit. Best-effort + size-capped; on CORS / too-large / any error it stays URL-only. Runs before the existing magnet normalization so the derived magnet's ws/xs are extracted the same way a pasted magnet's are. submit() is now async (it was fire-and-forget from the form handler; the pendingSubmit guard already prevents re-entry, so it just stays "pending" during the derivation). Known gap: hosting the tiny .torrent (xs=) on the edit path needs storage- credential plumbing the Edit modal doesn't currently have — so the edit path is ws=-only for now (upload path hosts xs= when storage is unlocked). Tracked as a follow-up. Edit-modal submit-state + upload-field tests green. Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/ui/components/EditModal.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index 098a62f1..a76f0611 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -888,7 +888,7 @@ export class EditModal { } } - submit() { + async submit() { if (this.pendingSubmit) { return; } @@ -1076,6 +1076,34 @@ export class EditModal { return; } + // Auto-derive a webseed torrent when the URL was changed to an external link + // with no magnet, so an edited external URL keeps the P2P benefit (magnet + // ws=url). Best-effort + size-capped; on CORS / too-large / any error it stays + // URL-only. Runs before the magnet normalization below so the derived magnet's + // ws/xs are extracted the same way a pasted magnet's are. (Hosting the tiny + // .torrent (xs=) on the edit path needs credential plumbing — a follow-up.) + if ( + urlWasEdited && + finalUrl && + !finalMagnet && + typeof this.mediaUploader?.deriveTorrentForExternalUrl === "function" + ) { + try { + const derived = await this.mediaUploader.deriveTorrentForExternalUrl( + finalUrl, + {}, + ); + if (derived?.magnet) { + finalMagnet = derived.magnet; + } + } catch (error) { + devLogger.warn( + "[EditModal] External URL webseed derivation skipped:", + error?.message || error, + ); + } + } + if (finalMagnet) { // When the web seed list was manually edited, replace the magnet's full // ws= set with exactly what's in the field — this is what makes removal From 757fca10591b841503e7cb906f1e48ec1e025104 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:17:32 -0400 Subject: [PATCH 05/13] =?UTF-8?q?[edit]=20Host=20the=20.torrent=20(xs=3D)?= =?UTF-8?q?=20for=20edited=20external=20URLs=20too=20=E2=80=94=20close=20t?= =?UTF-8?q?he=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit-modal external-URL webseed was ws=-only because the Edit modal had no storage credentials wired in. Reuse mediaUploader.resolveActiveConnection() (the same resolver the Edit modal's file-replace uploads use) to get the active provider + credentials when storage is unlocked, and pass them to deriveTorrentForExternalUrl so it hosts the tiny .torrent and emits ws=+xs= — full P2P bootstrap, matching the upload modal. Storage locked / no connection → ws=-only (unchanged, best-effort). Edit-modal submit-state + upload-field + external-url-torrent suites green. Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/ui/components/EditModal.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index a76f0611..6c2523ee 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -1080,8 +1080,9 @@ export class EditModal { // with no magnet, so an edited external URL keeps the P2P benefit (magnet // ws=url). Best-effort + size-capped; on CORS / too-large / any error it stays // URL-only. Runs before the magnet normalization below so the derived magnet's - // ws/xs are extracted the same way a pasted magnet's are. (Hosting the tiny - // .torrent (xs=) on the edit path needs credential plumbing — a follow-up.) + // ws/xs are extracted the same way a pasted magnet's are. When storage is + // unlocked we also host the tiny .torrent (xs=) so the webseed can bootstrap + // P2P — same as the upload modal. if ( urlWasEdited && finalUrl && @@ -1089,9 +1090,24 @@ export class EditModal { typeof this.mediaUploader?.deriveTorrentForExternalUrl === "function" ) { try { + let connection = null; + try { + connection = + typeof this.mediaUploader.resolveActiveConnection === "function" + ? await this.mediaUploader.resolveActiveConnection() + : null; + } catch (connectionError) { + connection = null; + } + const canHostTorrent = Boolean( + connection?.unlocked && connection?.credentials && connection?.provider, + ); const derived = await this.mediaUploader.deriveTorrentForExternalUrl( finalUrl, - {}, + { + provider: canHostTorrent ? connection.provider : undefined, + credentials: canHostTorrent ? connection.credentials : undefined, + }, ); if (derived?.magnet) { finalMagnet = derived.magnet; From 6e2894e3d8f28be73863371dca5a720748fead5e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:40:46 -0400 Subject: [PATCH 06/13] =?UTF-8?q?[video-modal]=20Fix=20dead=20like/dislike?= =?UTF-8?q?=20buttons=20=E2=80=94=20dispatch=20video:reaction=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real #54 bug (the signer gate was correct but unreachable): the reactions controller forwards the raw click event to VideoModal.handleReactionClick, which called this.reactionsController.handleReaction(event) — a method that does NOT exist on the video-modal reactions controller (it only binds buttons + does optimistic UI), passing the DOM event as the "reaction". So clicking like/dislike threw / silently did nothing and never reached the publish path. Fix: handleReactionClick now derives +/- from the clicked button (#modalLikeBtn / #modalDislikeBtn, or a bare "+"/"-" for programmatic callers) and dispatches "video:reaction" — the event ModalManager already routes to app.handleVideoReaction -> reactionController.handleReaction (signs + publishes the kind-7, and runs the ensureEncryptionCapableSigner gate from the earlier #54 pass). Note: #53 (channel avatar/name link) is already fixed in a20388c5 (handleCreatorNavigation now dispatches "creator:navigate"); if it still looks dead it's a stale build — rebuild dist / redeploy the branch under test. Tests: tests/video-modal-controllers.test.mjs (+2). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/ui/components/VideoModal.js | 33 ++++++++++++-- tests/video-modal-controllers.test.mjs | 60 ++++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 11 +++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 4f2d140a..4ea29c04 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -3030,10 +3030,37 @@ export class VideoModal { this.log("[VideoModal] More button clicked"); } - handleReactionClick(reaction) { - if (this.reactionsController) { - this.reactionsController.handleReaction(reaction); + handleReactionClick(event) { + // The reactionsController binds the like/dislike buttons and forwards the raw + // click event here. Derive WHICH button was clicked, then dispatch + // "video:reaction" — the event ModalManager listens for + // (→ app.handleVideoReaction → reactionController.handleReaction, which signs + + // publishes the kind-7). The old code called this.reactionsController.handleReaction + // (a method that doesn't exist on that controller) with the DOM event as the + // "reaction", so like/dislike silently did nothing. Also accept a bare "+"/"-" + // for programmatic callers. + let reaction = ""; + if (event === "+" || event === "-") { + reaction = event; + } else { + const target = + event?.currentTarget instanceof Element + ? event.currentTarget + : event?.target?.closest?.("#modalLikeBtn, #modalDislikeBtn") || null; + if (target && target === this.reactionButtons?.["+"]) { + reaction = "+"; + } else if (target && target === this.reactionButtons?.["-"]) { + reaction = "-"; + } else if (target?.id === "modalLikeBtn") { + reaction = "+"; + } else if (target?.id === "modalDislikeBtn") { + reaction = "-"; + } } + if (reaction !== "+" && reaction !== "-") { + return; + } + this.dispatch("video:reaction", { reaction, video: this.activeVideo }); } bindVideoEvents() { diff --git a/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs index b74bc17d..803d9e3f 100644 --- a/tests/video-modal-controllers.test.mjs +++ b/tests/video-modal-controllers.test.mjs @@ -248,3 +248,63 @@ test("creator navigation is a no-op without an active video pubkey", async (t) = modal.handleCreatorNavigation(); assert.equal(fired, false, "no pubkey → no navigation event"); }); + +// Like/dislike in the player modal (TODO #54): clicking a reaction button must +// dispatch "video:reaction" (→ app.handleVideoReaction → reactionController). The +// old handleReactionClick called this.reactionsController.handleReaction (a method +// that doesn't exist on the video-modal reactions controller) with the raw DOM +// event, so nothing was published. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-video-modal-reaction-dispatch +// given: "a video modal with an active video" +// when: "handleReactionClick runs for a like/dislike button or a bare +/-" +// then: "it dispatches video:reaction with the derived reaction; unrelated targets do nothing" +// observable_outcomes: +// - "bare '+'/'-' → video:reaction with that reaction" +// - "a click whose currentTarget is the like button → reaction '+'" +// - "a null/unrelated target → no dispatch" +// determinism_controls: +// - "JSDOM modal via setupModal; synchronous dispatch" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "asserting the broken call path"] +// relaxation: +// did_relax_any_assertion: false + +test("like/dislike dispatches video:reaction with the derived reaction", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + modal.activeVideo = { pubkey: "a".repeat(64) }; + const events = []; + modal.addEventListener("video:reaction", (event) => events.push(event.detail)); + + modal.handleReactionClick("+"); + modal.handleReactionClick("-"); + + assert.equal(events.length, 2, "both reactions dispatched"); + assert.equal(events[0].reaction, "+"); + assert.equal(events[1].reaction, "-"); + assert.equal(events[0].video, modal.activeVideo, "carries the active video"); + + // And via a real button click (currentTarget = the bound like button). + if (modal.reactionButtons?.["+"]) { + modal.handleReactionClick({ currentTarget: modal.reactionButtons["+"] }); + assert.equal(events[2]?.reaction, "+", "derived '+' from the like button element"); + } +}); + +test("reaction click with no matching button does not dispatch", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + let fired = false; + modal.addEventListener("video:reaction", () => { + fired = true; + }); + modal.handleReactionClick({ currentTarget: null }); + modal.handleReactionClick({}); + assert.equal(fired, false, "no reaction event for an unrelated/empty target"); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index b5f7620a..252ece55 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1659,6 +1659,17 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. Tests: `tests/reaction-signer-gate.test.mjs`. **Still VERIFY on unstable** whether any residual dead-button behavior remains (event binding / pointer availability), which would be a separate wiring bug from the signer issue. +- [x] **The actual dead-button bug — FIXED 2026-07-02.** `VideoModal.handleReactionClick` + never derived the reaction or reached the publish path: it called + `this.reactionsController.handleReaction(event)` — a method that does NOT exist on + the video-modal reactions controller (which only binds buttons + does optimistic + UI), passing the raw DOM click event as the "reaction". So the #54 signer gate was + correct but unreachable. Now `handleReactionClick` derives `+`/`-` from the clicked + button (`#modalLikeBtn`/`#modalDislikeBtn`) and dispatches `video:reaction` — the + event ModalManager already routes to `app.handleVideoReaction` → + `reactionController.handleReaction` (which signs/publishes + runs the signer gate). + Tests: `tests/video-modal-controllers.test.mjs` (+2 — derives the reaction and + dispatches; unrelated target is a no-op). Lint + build green. ### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX) - [ ] **Channel-profile grid still loads slowly.** Even after the coalescing + relay From 6b251dbda5ea84e7d9764dddfae1fbc6a49a0ecf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:02:43 -0400 Subject: [PATCH 07/13] =?UTF-8?q?[reactions]=20Fix=20"Missing=20signing=20?= =?UTF-8?q?primitives"=20=E2=80=94=20session=20actor=20must=20have=20a=20u?= =?UTF-8?q?sable=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "Failed to send reaction" (after the dispatch fix made the button reach the publish path): reactions are published under the anonymous SESSION ACTOR, signed with its private key. But ensureSessionActor adopted a stored session-actor entry as that key — and a persisted nsec login is passphrase-encrypted with privateKey "" ("never exposes plaintext"). So a leftover saved-nsec entry hijacked the anonymous telemetry key, and signEventWithPrivateKey(event, "") threw "Missing signing primitives". (Repro: logged in with NIP-07 while a saved nsec account exists on the device.) Every ensureSessionActor caller uses it to SIGN telemetry (reactions / view events / watch-history), so the returned actor must carry a usable plaintext key. Now it only reuses a current/stored actor that has one; otherwise it tracks the locked entry (for other flows) and generates a fresh ephemeral signing key — instead of returning a keyless actor that can sign nothing. Existing session-actor suites green; lint + build green. Follow-up: the fresh ephemeral key isn't persisted, so a like isn't remembered across reloads (different anonymous key) — separate from this crash fix. Co-Authored-By: Claude Opus 4.8 --- js/nostr/managers/SignerManager.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 6338d4fc..43a819f9 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -138,16 +138,30 @@ export class SignerManager { } async ensureSessionActor(force = false) { + // The session actor is an ANONYMOUS telemetry key used to SIGN reactions / + // view events / watch-history when they aren't tied to the login. It must + // therefore carry a usable plaintext private key. A persisted nsec login is + // passphrase-encrypted (privateKey ""), so adopting one as the session actor + // made reactions fail with "Missing signing primitives" (a leftover saved-nsec + // entry hijacked the anonymous key). Only reuse an actor that can actually + // sign; otherwise fall through and generate a fresh ephemeral key. + const hasUsableKey = (actor) => + Boolean( + actor && + typeof actor.privateKey === "string" && + HEX64_REGEX.test(actor.privateKey), + ); + if (!force) { - if (this.sessionActor) { + if (hasUsableKey(this.sessionActor)) { return this.sessionActor.pubkey; } - if (this.lockedSessionActor) { - return this.lockedSessionActor.pubkey; - } const storedEntry = readStoredSessionActorEntry(); if (storedEntry) { + // Track a locked/encrypted entry (other flows read it) but don't sign with it. this.lockedSessionActor = storedEntry; + } + if (hasUsableKey(storedEntry)) { this.sessionActor = storedEntry; return storedEntry.pubkey; } From 9e5cb9f57b16905fd88e18fda8e20c0743a9e820 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:44:09 -0400 Subject: [PATCH 08/13] [channel] Show view counts on channel-profile cards (were blank) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-profile video cards rendered blank view counts. The main feed subscribes each card's [data-view-count] element to the shared view counter (VideoListView.ensureViewCountSubscription / registerVideoViewCountElement), but the channel-profile grid renders its own VideoCards and never did — so nothing ever populated the element. Add js/channelViewCounts.js (subscribeChannelCardViewCount / clearChannelCardViewCounts) and call it from channelProfile.js after each card's getRoot(); teardown runs in clearChannelVideoCardRegistry (already invoked on grid re-render / channel close). Logic lives in a new module so channelProfile.js stays under its size cap. On "are they counted correctly?": yes — this subscribes to the SAME batched kind-30079 view-count transport the feed uses (one shared subscription + the deduped cache), so the channel now shows the exact same counts the feed does. The counting itself was never the issue; the channel grid just wasn't displaying it. Tests: tests/channel-view-counts.test.mjs (3 — non-blank label, safe no-ops, idempotent teardown). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/channelProfile.js | 8 ++++ js/channelViewCounts.js | 77 ++++++++++++++++++++++++++++++ tests/channel-view-counts.test.mjs | 65 +++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 js/channelViewCounts.js create mode 100644 tests/channel-view-counts.test.mjs diff --git a/js/channelProfile.js b/js/channelProfile.js index 1261facb..af3ff7dd 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -13,6 +13,10 @@ import { escapeHTML } from "./utils/domUtils.js"; import { formatShortNpub } from "./utils/formatters.js"; import createPopover from "./ui/overlay/popoverEngine.js"; import { VideoCard } from "./ui/components/VideoCard.js"; +import { + subscribeChannelCardViewCount, + clearChannelCardViewCounts, +} from "./channelViewCounts.js"; import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; @@ -179,6 +183,7 @@ function updateChannelBannerBackground(el, url) { export function clearChannelVideoCardRegistry() { channelVideoCardsById.clear(); + clearChannelCardViewCounts(); } let currentChannelHex = null; @@ -5029,6 +5034,9 @@ export async function renderChannelVideosFromList({ const cardEl = videoCard.getRoot(); if (cardEl) { fragment.appendChild(cardEl); + // Subscribe the card to the shared view counter (the feed does this; the + // channel grid didn't, so its view counts rendered blank). + subscribeChannelCardViewCount(cardEl, pointerInfo); } } diff --git a/js/channelViewCounts.js b/js/channelViewCounts.js new file mode 100644 index 00000000..5c3b3563 --- /dev/null +++ b/js/channelViewCounts.js @@ -0,0 +1,77 @@ +// Wire channel-profile video cards to the shared view counter. The main feed does +// this in VideoListView (ensureViewCountSubscription / registerVideoViewCountElement); +// the channel-profile grid renders its own VideoCards and never subscribed them, so +// every card's view count rendered blank. This mirrors the feed's behavior against +// the SAME batched view-count transport (one shared kind-30079 subscription), so the +// counts are identical to what the feed shows — the channel just wasn't displaying +// them (the counting itself is unchanged and shared). + +import { + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, + formatViewCount, +} from "./viewCounter.js"; +import { devLogger } from "./utils/logger.js"; + +const activeSubscriptions = []; + +function formatViewCountLabel(total) { + const value = Number.isFinite(total) ? Number(total) : 0; + return `${formatViewCount(value)} ${value === 1 ? "view" : "views"}`; +} + +// Subscribe one channel card's [data-view-count] element to its pointer. +export function subscribeChannelCardViewCount(cardEl, pointerInfo) { + const pointer = pointerInfo?.pointer; + if (!cardEl || !pointer) { + return; + } + const el = cardEl.querySelector("[data-view-count]"); + if (!el) { + return; + } + if (!el.textContent || !el.textContent.trim()) { + el.textContent = "– views"; + } + + try { + const token = subscribeToVideoViewCount( + pointer, + ({ total, status, partial }) => { + let text; + if (Number.isFinite(total)) { + text = formatViewCountLabel(total); + if (partial) { + text = `${text} (partial)`; + } + } else if (status === "hydrating") { + text = "Loading views…"; + } else { + text = "– views"; + } + if (el.isConnected) { + el.textContent = text; + el.dataset.viewCountState = partial ? "partial" : status; + } + }, + ); + activeSubscriptions.push({ pointer, token }); + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to subscribe card view count:", + error, + ); + } +} + +// Tear down every channel-card view-count subscription (on grid re-render / close). +export function clearChannelCardViewCounts() { + while (activeSubscriptions.length) { + const { pointer, token } = activeSubscriptions.pop(); + try { + unsubscribeFromVideoViewCount(pointer, token); + } catch (error) { + // best-effort teardown + } + } +} diff --git a/tests/channel-view-counts.test.mjs b/tests/channel-view-counts.test.mjs new file mode 100644 index 00000000..201ff312 --- /dev/null +++ b/tests/channel-view-counts.test.mjs @@ -0,0 +1,65 @@ +// Channel-profile cards showed BLANK view counts because the channel grid never +// subscribed its cards to the shared view counter (the main feed does). This wires +// each card's [data-view-count] element the same way. The count VALUES come from the +// same shared transport as the feed — the channel just wasn't displaying them. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-channel-card-view-count +// given: "a channel card element with a [data-view-count] span + a pointer" +// when: "subscribeChannelCardViewCount runs (and clearChannelCardViewCounts tears down)" +// then: "the element gets a non-blank view label; missing pointer/element/card are safe no-ops" +// observable_outcomes: +// - "a card with a pointer gets a non-blank view label (no longer blank)" +// - "no pointer / no [data-view-count] / no card -> no throw, no label forced" +// - "clearChannelCardViewCounts is idempotent and never throws" +// determinism_controls: +// - "JSDOM element; real module against the (uninitialised) shared view counter" +// anti_cheat_rationale: +// prevents: ["asserting the blank (broken) state", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { + subscribeChannelCardViewCount, + clearChannelCardViewCounts, +} from "../js/channelViewCounts.js"; + +function makeCard(html) { + const dom = new JSDOM(`${html}`); + return dom.window.document.body.firstElementChild; +} + +test("a card with a pointer gets a non-blank view-count label (fixes the blank)", () => { + const card = makeCard('
'); + subscribeChannelCardViewCount(card, { pointer: ["a", 30078, "root-1"], key: "k1" }); + const el = card.querySelector("[data-view-count]"); + assert.ok(el.textContent && el.textContent.trim().length > 0, "label is not blank"); + assert.match(el.textContent, /view|Loading/i, "shows a view/loading label"); + clearChannelCardViewCounts(); +}); + +test("safe no-ops: missing pointer, missing [data-view-count], or missing card", () => { + const cardNoEl = makeCard('
'); + const cardWithEl = makeCard('
'); + assert.doesNotThrow(() => { + subscribeChannelCardViewCount(cardWithEl, {}); // no pointer + subscribeChannelCardViewCount(cardNoEl, { pointer: ["a", 30078, "x"] }); // no element + subscribeChannelCardViewCount(null, { pointer: ["a", 30078, "x"] }); // no card + }); + // A card with no pointer must not have its (absent) element forced to a label. + assert.equal(cardWithEl.querySelector("[data-view-count]").textContent, ""); + clearChannelCardViewCounts(); +}); + +test("clearChannelCardViewCounts is idempotent and never throws", () => { + assert.doesNotThrow(() => { + clearChannelCardViewCounts(); + clearChannelCardViewCounts(); + }); +}); From c20938b4aacf34a77ca00088013e993fbd21fee8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:48:18 -0400 Subject: [PATCH 09/13] [perf] Media caching: immutable upload headers + SW image cache (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified first that event caching needs nothing: allEvents (incl. ingested NIP-71) already persists to IndexedDB and every grid renders cache-first + merge-new. The real gap was MEDIA: thumbnails/avatars/banners re-downloaded across visits. Part 1 — Cache-Control on uploads (js/storage/s3-multipart.js): computeCacheControl already stamped most media immutable but missed webp/avif/ico and .torrent files (which fell to the 1h default). Added them — safe because buildR2Key namespaces keys by infohash (content-addressed, a key's bytes never change). Playlists (m3u8/mpd) stay short-lived (live manifests mutate in place). Exported for tests. Part 2 — service-worker image cache (sw.min.js): Cross-origin images (the hosts bitvid does NOT control headers for) are now served stale-while-revalidate from a versioned Cache-API cache (bitvid-images-v1): - instant on revisit; background refresh self-heals changed images (and where part 1's immutable headers apply, that refresh is served by the HTTP cache — free) - opaque (no-cors) responses cached — exactly the external-thumbnail case - LRU-capped (300); quota failures drop the cache and fail open; every handler error falls back to plain fetch so a caching bug can never break images - the activate handler's wipe-all now PRESERVES the current image cache (old versions + other caches still wiped); same-origin assets untouched (they ride the host HTTP cache + dist cache-purge pipeline) - app._initServiceWorker registers the SW at boot when none exists, same path/scope/options as webtorrent.js's setupServiceWorker so playback's later register() resolves to the same registration (playback path untouched) - SW_VERSION bumped Tests: storage-cache-control (3, exported policy) + sw-image-cache (6 — evaluates the REAL sw.min.js in a VM sandbox and drives its handlers: cold/warm/offline/ opaque flows, same-origin + non-image non-interception, activate preserves the image cache). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/app.js | 30 ++- js/storage/s3-multipart.js | 9 +- sw.min.js | 110 ++++++++++- tests/storage-cache-control.test.mjs | 63 +++++++ tests/sw-image-cache.test.mjs | 264 +++++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 44 +++-- 6 files changed, 499 insertions(+), 21 deletions(-) create mode 100644 tests/storage-cache-control.test.mjs create mode 100644 tests/sw-image-cache.test.mjs diff --git a/js/app.js b/js/app.js index 16c5dd24..99f028d9 100644 --- a/js/app.js +++ b/js/app.js @@ -861,9 +861,33 @@ class Application { _initServiceWorker() { // Force update of any registered service workers to ensure latest code is used. if ("serviceWorker" in navigator) { - navigator.serviceWorker.getRegistrations().then((registrations) => { - registrations.forEach((registration) => registration.update()); - }); + navigator.serviceWorker + .getRegistrations() + .then((registrations) => { + if (registrations.length) { + registrations.forEach((registration) => registration.update()); + return; + } + // No registration yet (user never played a torrent): register the SW + // at boot so its cross-origin image cache (thumbnails/avatars) works + // for everyone. Same path/scope/options as js/webtorrent.js's + // setupServiceWorker, so its later register() resolves to this same + // registration — playback behavior is unchanged. Best-effort only. + return navigator.serviceWorker + .register("/sw.min.js", { scope: "/", updateViaCache: "none" }) + .catch((error) => { + devLogger.warn( + "[Application] Boot service-worker registration skipped:", + error, + ); + }); + }) + .catch((error) => { + devLogger.warn( + "[Application] Service-worker init failed:", + error, + ); + }); } } diff --git a/js/storage/s3-multipart.js b/js/storage/s3-multipart.js index cb51968f..fc23765f 100644 --- a/js/storage/s3-multipart.js +++ b/js/storage/s3-multipart.js @@ -28,7 +28,12 @@ function getCreateBucketConfig(region) { }; } -function computeCacheControl(key) { +// Exported for tests. Upload keys are content-addressed (buildR2Key namespaces +// by the file's infohash), so media at a given key never changes — a long +// immutable Cache-Control is safe and makes browsers keep thumbnails/avatars/ +// videos across visits instead of re-fetching them. Playlists (m3u8/mpd) stay +// short-lived because live/HLS manifests DO mutate in place. +export function computeCacheControl(key) { if (!key) { return DEFAULT_CACHE_CONTROL; } @@ -39,7 +44,7 @@ function computeCacheControl(key) { } if ( - /\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test( + /\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|webp|avif|ico|vtt|srt|mpg|mpeg|torrent)$/.test( lower ) ) { diff --git a/sw.min.js b/sw.min.js index d415e4c2..caafee44 100644 --- a/sw.min.js +++ b/sw.min.js @@ -1,12 +1,100 @@ (() => { "use strict"; - const SW_VERSION = "bitvid-sw-2026-02-10"; + const SW_VERSION = "bitvid-sw-2026-07-02"; const SW_METADATA = Object.freeze({ version: SW_VERSION, runtimePathPrefix: "/webtorrent/", }); + // Cross-origin image cache (thumbnails / avatars / banners). These are the + // images bitvid does NOT control headers for (external hosts, user CDNs), so + // without this they can re-download on every visit. Strategy: + // stale-while-revalidate — serve the cached copy instantly, refresh it in the + // background — so revisits are instant AND a changed image self-heals on the + // next view. Same-origin assets are left alone (they ride the host's HTTP + // cache + the dist cache-purge pipeline). Bump the version to discard. + const IMAGE_CACHE = "bitvid-images-v1"; + const IMAGE_CACHE_MAX_ENTRIES = 300; + + function isCacheableImageRequest(request) { + if (!request || request.method !== "GET") { + return false; + } + if (request.destination !== "image") { + return false; + } + const url = request.url || ""; + if (!/^https?:/.test(url)) { + return false; + } + // Same-origin images (app assets) keep the normal HTTP-cache path. + try { + return new URL(url).origin !== self.location.origin; + } catch (error) { + return false; + } + } + + async function trimImageCache(cache) { + try { + const keys = await cache.keys(); + if (keys.length <= IMAGE_CACHE_MAX_ENTRIES) { + return; + } + // Cache keys() preserves insertion order; drop the oldest overflow. + const excess = keys.length - IMAGE_CACHE_MAX_ENTRIES; + await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key))); + } catch (error) { + // Trimming is best-effort. + } + } + + async function putImageInCache(cache, request, response) { + try { + await cache.put(request, response); + await trimImageCache(cache); + } catch (error) { + // Quota pressure (opaque responses are padded heavily for quota + // accounting) or storage failure: drop the whole image cache and keep + // going — the page still got its network response. + try { + await caches.delete(IMAGE_CACHE); + } catch (cleanupError) { + // fail open + } + } + } + + async function handleImageRequest(event) { + const request = event.request; + const cache = await caches.open(IMAGE_CACHE); + const cached = await cache.match(request, { ignoreVary: true }); + + const refresh = fetch(request) + .then((response) => { + // Cache good responses; opaque (cross-origin no-cors) counts as good — + // its status is unreadable, and these are exactly the images we target. + if (response && (response.ok || response.type === "opaque")) { + event.waitUntil(putImageInCache(cache, request, response.clone())); + } + return response; + }) + .catch((error) => { + if (cached) { + return cached; + } + throw error; + }); + + if (cached) { + // Keep the background refresh alive after we respond. + event.waitUntil(refresh.catch(() => {})); + return cached; + } + return refresh; + } + let cancelled = false; function broadcastLifecycle(eventType, details = {}) { @@ -46,7 +134,9 @@ event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")])); }); - // Claim clients on activation and clear caches + // Claim clients on activation and clear caches. The CURRENT image cache is + // preserved (it is versioned; stale versions and everything else are wiped) so + // an SW update doesn't throw away every cached thumbnail/avatar. self.addEventListener("activate", (event) => { event.waitUntil( Promise.all([ @@ -58,7 +148,11 @@ caches .keys() .then((cacheNames) => - Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))) + Promise.all( + cacheNames + .filter((cacheName) => cacheName !== IMAGE_CACHE) + .map((cacheName) => caches.delete(cacheName)) + ) ), ]) ); @@ -68,6 +162,16 @@ self.addEventListener("fetch", (event) => { const requestURL = event.request.url; + // Cross-origin images: serve from the image cache (stale-while-revalidate). + // Fail open — any handler error falls back to a plain network fetch so a + // caching bug can never break image loading. + if (isCacheableImageRequest(event.request)) { + event.respondWith( + handleImageRequest(event).catch(() => fetch(event.request)) + ); + return; + } + // Only handle WebTorrent streaming requests if (!requestURL.includes("/webtorrent/")) { return; diff --git a/tests/storage-cache-control.test.mjs b/tests/storage-cache-control.test.mjs new file mode 100644 index 00000000..35592c77 --- /dev/null +++ b/tests/storage-cache-control.test.mjs @@ -0,0 +1,63 @@ +// Cache-Control policy for uploaded media (TODO #55, part 1). Upload keys are +// content-addressed (buildR2Key namespaces by infohash), so media should get a +// long immutable Cache-Control — browsers then keep thumbnails/videos/torrents +// across visits. Mutable playlists (m3u8/mpd) must stay short-lived. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-upload-cache-control +// given: "an object key by extension" +// when: "computeCacheControl derives the Cache-Control header" +// then: "media/torrents are immutable-year; playlists revalidate; unknown gets the default" +// observable_outcomes: +// - "video + image (incl webp/avif) + .torrent keys -> public, max-age=31536000, immutable" +// - "m3u8/mpd -> public, max-age=60, must-revalidate" +// - "unknown/empty keys -> the 1h default (never immutable)" +// determinism_controls: +// - "pure function; explicit inputs" +// anti_cheat_rationale: +// prevents: ["hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { computeCacheControl } from "../js/storage/s3-multipart.js"; + +const IMMUTABLE = "public, max-age=31536000, immutable"; + +test("videos, images (incl. webp/avif), and torrents are immutable for a year", () => { + for (const key of [ + "u/npub1x/abc123/video.mp4", + "u/npub1x/abc123/clip.webm", + "u/npub1x/abc123/thumb.jpg", + "u/npub1x/abc123/thumb.webp", + "u/npub1x/abc123/poster.avif", + "u/npub1x/abc123/video.torrent", + "u/npub1x/external/deadbeef.torrent", + "SHOUTY/PATH/THUMB.PNG", + ]) { + assert.equal(computeCacheControl(key), IMMUTABLE, key); + } +}); + +test("mutable playlists stay short-lived (live manifests change in place)", () => { + assert.equal( + computeCacheControl("u/npub1x/live/stream.m3u8"), + "public, max-age=60, must-revalidate", + ); + assert.equal( + computeCacheControl("u/npub1x/live/stream.mpd"), + "public, max-age=60, must-revalidate", + ); +}); + +test("unknown or missing keys fall back to the 1h default, never immutable", () => { + for (const key of ["u/npub1x/uploads/file.bin", "no-extension", "", null, undefined]) { + const value = computeCacheControl(key); + assert.equal(value, "public, max-age=3600", String(key)); + assert.ok(!value.includes("immutable")); + } +}); diff --git a/tests/sw-image-cache.test.mjs b/tests/sw-image-cache.test.mjs new file mode 100644 index 00000000..753af501 --- /dev/null +++ b/tests/sw-image-cache.test.mjs @@ -0,0 +1,264 @@ +// Service-worker image cache (TODO #55, part 2): cross-origin images +// (thumbnails / avatars / banners) are served stale-while-revalidate from the +// Cache API so revisits are instant even when the host sends no cache headers. +// These tests load the REAL sw.min.js in a VM sandbox with stubbed SW globals and +// drive its actual fetch/activate handlers. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-sw-image-cache +// given: "the real sw.min.js evaluated with stubbed self/caches/fetch" +// when: "fetch events fire for cross-origin images / same-origin images / other URLs, and activate fires" +// then: "cross-origin images are cached + served stale-while-revalidate; others untouched; activate preserves ONLY the current image cache" +// observable_outcomes: +// - "cold fetch -> network response returned AND stored in bitvid-images-v1" +// - "warm fetch -> cached body served; background refresh re-fetches" +// - "network failure with a cached copy -> cached copy served" +// - "same-origin image + non-image URL -> respondWith never called" +// - "activate wipes other caches (incl. old image-cache versions) but keeps bitvid-images-v1" +// determinism_controls: +// - "vm sandbox; Map-backed CacheStorage stub; controllable fetch stub; no network" +// anti_cheat_rationale: +// prevents: ["testing a re-implementation instead of the shipped file", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; + +const swSource = await readFile(new URL("../sw.min.js", import.meta.url), "utf8"); + +function makeCacheStorage() { + const stores = new Map(); // name -> Map(url -> response) + return { + stores, + async open(name) { + if (!stores.has(name)) { + stores.set(name, new Map()); + } + const store = stores.get(name); + return { + async match(request) { + return store.get(request.url) || undefined; + }, + async put(request, response) { + store.set(request.url, response); + }, + async keys() { + return Array.from(store.keys()).map((url) => ({ url })); + }, + async delete(request) { + return store.delete(request.url); + }, + }; + }, + async keys() { + return Array.from(stores.keys()); + }, + async delete(name) { + return stores.delete(name); + }, + }; +} + +function makeResponse(body, { ok = true, type = "basic" } = {}) { + return { + body, + ok, + type, + clone() { + return makeResponse(body, { ok, type }); + }, + }; +} + +function loadSw({ fetchImpl } = {}) { + const listeners = new Map(); + const cacheStorage = makeCacheStorage(); + const sandbox = { + location: { origin: "https://bitvid.network" }, + registration: { scope: "/" }, + addEventListener: (type, handler) => listeners.set(type, handler), + skipWaiting: async () => {}, + clients: { matchAll: async () => [], claim: async () => {} }, + caches: cacheStorage, + fetch: fetchImpl || (async () => makeResponse("net")), + URL, + Response, + Headers, + ReadableStream, + MessageChannel, + Promise, + Object, + Array, + setTimeout, + clearTimeout, + console, + }; + sandbox.self = sandbox; + vm.createContext(sandbox); + vm.runInContext(swSource, sandbox); + return { listeners, cacheStorage, sandbox }; +} + +function makeFetchEvent(url, { destination = "image", method = "GET" } = {}) { + const event = { + request: { url, destination, method }, + response: null, + pending: [], + respondWith(promise) { + this.response = Promise.resolve(promise); + }, + waitUntil(promise) { + this.pending.push(Promise.resolve(promise).catch(() => {})); + }, + }; + return event; +} + +const IMG = "https://cdn.example.com/thumb.jpg"; + +test("cold cross-origin image: network response served AND cached in bitvid-images-v1", async () => { + let fetches = 0; + const { listeners, cacheStorage } = loadSw({ + fetchImpl: async () => { + fetches += 1; + return makeResponse(`net-${fetches}`); + }, + }); + + const event = makeFetchEvent(IMG); + listeners.get("fetch")(event); + const response = await event.response; + assert.equal(response.body, "net-1", "network response returned to the page"); + + await Promise.all(event.pending); + const store = cacheStorage.stores.get("bitvid-images-v1"); + assert.ok(store?.has(IMG), "response was stored in the image cache"); +}); + +test("warm cross-origin image: cached copy served, background refresh still fetches", async () => { + let fetches = 0; + const { listeners, cacheStorage } = loadSw({ + fetchImpl: async () => { + fetches += 1; + return makeResponse(`net-${fetches}`); + }, + }); + + // Warm the cache. + const first = makeFetchEvent(IMG); + listeners.get("fetch")(first); + await first.response; + await Promise.all(first.pending); + assert.equal(fetches, 1); + + // Second visit: served from cache; the revalidation fetch still happens. + const second = makeFetchEvent(IMG); + listeners.get("fetch")(second); + const response = await second.response; + assert.equal(response.body, "net-1", "cached copy served immediately"); + await Promise.all(second.pending); + assert.equal(fetches, 2, "background refresh re-fetched the image"); + assert.equal( + cacheStorage.stores.get("bitvid-images-v1").get(IMG).body, + "net-2", + "cache updated by the background refresh (self-heals changed images)", + ); +}); + +test("network failure with a cached copy: cached copy served (offline-friendly)", async () => { + let shouldFail = false; + const { listeners } = loadSw({ + fetchImpl: async () => { + if (shouldFail) { + throw new Error("offline"); + } + return makeResponse("net-1"); + }, + }); + + const warm = makeFetchEvent(IMG); + listeners.get("fetch")(warm); + await warm.response; + await Promise.all(warm.pending); + + shouldFail = true; + const offline = makeFetchEvent(IMG); + listeners.get("fetch")(offline); + const response = await offline.response; + assert.equal(response.body, "net-1", "cached copy served when the network fails"); + await Promise.all(offline.pending); +}); + +test("same-origin images and non-image requests are NOT intercepted", () => { + const { listeners } = loadSw(); + + const sameOrigin = makeFetchEvent("https://bitvid.network/assets/logo.png"); + listeners.get("fetch")(sameOrigin); + assert.equal(sameOrigin.response, null, "same-origin image left to the HTTP cache"); + + const nonImage = makeFetchEvent("https://cdn.example.com/data.json", { + destination: "fetch", + }); + listeners.get("fetch")(nonImage); + assert.equal(nonImage.response, null, "non-image request untouched"); + + const post = makeFetchEvent(IMG, { method: "POST" }); + listeners.get("fetch")(post); + assert.equal(post.response, null, "non-GET untouched"); +}); + +test("activate wipes other caches but preserves the CURRENT image cache", async () => { + const { listeners, cacheStorage } = loadSw(); + + // Seed: the live image cache, an old image-cache version, and a stray cache. + (await cacheStorage.open("bitvid-images-v1")).put({ url: IMG }, makeResponse("keep")); + await cacheStorage.open("bitvid-images-v0"); + await cacheStorage.open("some-old-cache"); + + const event = { pending: [], waitUntil(p) { this.pending.push(p); } }; + listeners.get("activate")(event); + await Promise.all(event.pending); + + const names = await cacheStorage.keys(); + assert.deepEqual(names, ["bitvid-images-v1"], "only the current image cache survives"); + assert.equal( + cacheStorage.stores.get("bitvid-images-v1").get(IMG).body, + "keep", + "cached thumbnails survive an SW update", + ); +}); + +test("opaque (cross-origin no-cors) responses are cached; failed basic responses are not", async () => { + let mode = "opaque"; + const { listeners, cacheStorage } = loadSw({ + fetchImpl: async () => + mode === "opaque" + ? makeResponse("opaque-body", { ok: false, type: "opaque" }) + : makeResponse("error-body", { ok: false, type: "basic" }), + }); + + const opaqueEvent = makeFetchEvent(IMG); + listeners.get("fetch")(opaqueEvent); + await opaqueEvent.response; + await Promise.all(opaqueEvent.pending); + assert.ok( + cacheStorage.stores.get("bitvid-images-v1")?.has(IMG), + "opaque response cached (status unreadable by design)", + ); + + mode = "error"; + const errUrl = "https://cdn.example.com/broken.png"; + const errEvent = makeFetchEvent(errUrl); + listeners.get("fetch")(errEvent); + await errEvent.response; + await Promise.all(errEvent.pending); + assert.ok( + !cacheStorage.stores.get("bitvid-images-v1").has(errUrl), + "a non-ok basic response (404/500) is not cached", + ); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 252ece55..05400b22 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1672,19 +1672,37 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. dispatches; unrelated target is a no-op). Lint + build green. ### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX) -- [ ] **Channel-profile grid still loads slowly.** Even after the coalescing + relay - timeout + parity-seed fix, the channel wall streams in slowly. Cache that channel's - events so revisits render instantly. -- [ ] **Generalize to every grid:** cache the events behind each video grid (feed tabs + - channel walls) so they render from cache immediately, then refresh in the - background and **merge in only new events** (don't blank + re-fetch). Fresh content - still matters — proposed balance: **cache-and-refresh simultaneously**, appending - new events as they arrive, with an explicit **pull-to-refresh / refresh button** - for a forced re-fetch. Decide cache store (in-memory vs IndexedDB via - `js/state/cache.js`) + a staleness/TTL policy. -- [ ] **Cache media too:** thumbnails and profile images/banners should be cached (browser - cache headers / a warm cache / persistent store) so they don't reload from scratch - on every visit. Cross-ref `js/channelProfile.js` and the card renderers. +- [x] **VERIFIED 2026-07-02: event caching already exists — no gap.** Traced end to + end: `allEvents` (incl. ingested NIP-71 videos via `nip71IngestService.injectVideo`) + is persisted to IndexedDB (`bitvid:eventsCache:v1`, `PersistenceManager` + + `EventsCacheStore`), restored on boot (`restoreLocalData`), and `loadVideos` + + the channel grid render **cache-first then merge-new** (never blank+refetch). + Bullets 1–2 were already built; the remaining slowness is only a genuinely-cold + first visit, already bounded by the coalescing/timeout/parity fixes. +- [x] **Media caching part 1 — Cache-Control on uploads — DONE 2026-07-02.** + `computeCacheControl` (js/storage/s3-multipart.js) already stamped most media + immutable; added the missing `webp`/`avif`/`ico` **and `.torrent`** (safe: + `buildR2Key` namespaces by infohash → content-addressed). Playlists (m3u8/mpd) + stay short-lived. Exported + tested (`tests/storage-cache-control.test.mjs`, 3). +- [x] **Media caching part 2 — SW image cache — DONE 2026-07-02.** `sw.min.js` now + serves **cross-origin images** (thumbnails/avatars/banners — the hosts bitvid + doesn't control headers for) **stale-while-revalidate** from `bitvid-images-v1` + (Cache API): instant on revisit, background refresh self-heals changed images, + LRU-capped at 300 entries, quota errors fail open (drop the cache, never break + image loading), and every handler error falls back to plain `fetch`. The + activate handler's wipe-all now **preserves the current image cache** (old + versions + other caches still wiped). Same-origin assets are untouched (they + ride the host HTTP cache + dist cache-purge pipeline). `app._initServiceWorker` + now registers the SW at boot when none exists (same path/scope/options as + `webtorrent.js`'s `setupServiceWorker`, so playback's later `register()` is a + no-op on the same registration). SW_VERSION bumped. Tests: + `tests/sw-image-cache.test.mjs` (6 — runs the REAL sw.min.js in a VM sandbox: + cold/warm/offline/opaque flows, non-interception, activate preservation). +- [ ] **VERIFY on unstable:** revisit a feed/channel — thumbnails + avatars should + load instantly from the SW cache (DevTools → Network shows "(ServiceWorker)"); + after an SW update the image cache survives. Optional follow-up: pull-to-refresh + / manual refresh affordance for forced re-fetch (nice-to-have; background + merge-new already keeps grids fresh). ### 56. Upload & edit modals: prompt to unlock storage (PIN popup), don't just error (UX) - [ ] When storage is locked, the **upload** and **edit** modals should open the From 84cec15483df561334b7b0abe28c004dff6fb9d1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:59:21 -0400 Subject: [PATCH 10/13] [perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Recently Added" grids loaded visibly slower after the image cache shipped. The feed's data path was untouched — the regression was the thumbnails: the SW handler had three compounding costs on a cold cache: 1. The network fetch waited behind caches.open + cache.match, delaying every thumbnail's download start. 2. caches.open ran per request instead of once. 3. trimImageCache ran a full keys() enumeration after EVERY put — a grid burst of dozens of thumbnails did dozens of O(n) enumerations, all contending with the cache.match calls of later images. Fix: start the network fetch immediately (cold path is now as fast as no SW; a warm cache still serves the cached copy for first paint), memoize the cache open, and amortize trims (every 20 puts). SW_VERSION bumped so testers pick it up. Tests: sw-image-cache (7 — adds a regression test that gates cache.match behind a promise and asserts the download starts anyway; all prior behaviors re-verified against the real sw.min.js). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- sw.min.js | 75 ++++++++++++++++++++++++----------- tests/sw-image-cache.test.mjs | 40 +++++++++++++++++++ 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/sw.min.js b/sw.min.js index caafee44..204942f7 100644 --- a/sw.min.js +++ b/sw.min.js @@ -1,7 +1,7 @@ (() => { "use strict"; - const SW_VERSION = "bitvid-sw-2026-07-02"; + const SW_VERSION = "bitvid-sw-2026-07-02.1"; const SW_METADATA = Object.freeze({ version: SW_VERSION, runtimePathPrefix: "/webtorrent/", @@ -16,6 +16,23 @@ // cache + the dist cache-purge pipeline). Bump the version to discard. const IMAGE_CACHE = "bitvid-images-v1"; const IMAGE_CACHE_MAX_ENTRIES = 300; + // Trim is O(entries) (a full keys() enumeration), so amortize it instead of + // running it after every put — a cold grid load puts dozens of thumbnails in a + // burst, and per-put trims made that burst contend with the cache.match calls + // of later images (the "grid loads slowly" regression). + const IMAGE_CACHE_TRIM_EVERY = 20; + let imageCachePromise = null; + let imagePutsSinceTrim = 0; + + function openImageCache() { + if (!imageCachePromise) { + imageCachePromise = caches.open(IMAGE_CACHE); + imageCachePromise.catch(() => { + imageCachePromise = null; + }); + } + return imageCachePromise; + } function isCacheableImageRequest(request) { if (!request || request.method !== "GET") { @@ -50,14 +67,20 @@ } } - async function putImageInCache(cache, request, response) { + async function putImageInCache(request, response) { try { + const cache = await openImageCache(); await cache.put(request, response); - await trimImageCache(cache); + imagePutsSinceTrim += 1; + if (imagePutsSinceTrim >= IMAGE_CACHE_TRIM_EVERY) { + imagePutsSinceTrim = 0; + await trimImageCache(cache); + } } catch (error) { // Quota pressure (opaque responses are padded heavily for quota // accounting) or storage failure: drop the whole image cache and keep // going — the page still got its network response. + imageCachePromise = null; try { await caches.delete(IMAGE_CACHE); } catch (cleanupError) { @@ -68,31 +91,37 @@ async function handleImageRequest(event) { const request = event.request; - const cache = await caches.open(IMAGE_CACHE); - const cached = await cache.match(request, { ignoreVary: true }); - const refresh = fetch(request) - .then((response) => { - // Cache good responses; opaque (cross-origin no-cors) counts as good — - // its status is unreadable, and these are exactly the images we target. - if (response && (response.ok || response.type === "opaque")) { - event.waitUntil(putImageInCache(cache, request, response.clone())); - } - return response; - }) - .catch((error) => { - if (cached) { - return cached; - } - throw error; - }); + // Start the network fetch IMMEDIATELY — never delay a download behind cache + // I/O. On a cold cache this makes the SW path as fast as no SW at all; on a + // warm cache the cached copy below still wins the race for first paint. + const network = fetch(request).then((response) => { + // Cache good responses; opaque (cross-origin no-cors) counts as good — + // its status is unreadable, and these are exactly the images we target. + if (response && (response.ok || response.type === "opaque")) { + event.waitUntil(putImageInCache(request, response.clone())); + } + return response; + }); + // Mark handled so a network failure can't surface as an unhandled rejection + // while we're still consulting the cache; the original promise stays + // observable for the no-cache path below. + network.catch(() => {}); + + let cached = null; + try { + const cache = await openImageCache(); + cached = await cache.match(request, { ignoreVary: true }); + } catch (error) { + // Cache unavailable — plain network behavior. + } if (cached) { - // Keep the background refresh alive after we respond. - event.waitUntil(refresh.catch(() => {})); + // Serve the cached copy instantly; the refresh continues in the background. + event.waitUntil(network.catch(() => {})); return cached; } - return refresh; + return network; } let cancelled = false; diff --git a/tests/sw-image-cache.test.mjs b/tests/sw-image-cache.test.mjs index 753af501..f506dfa0 100644 --- a/tests/sw-image-cache.test.mjs +++ b/tests/sw-image-cache.test.mjs @@ -233,6 +233,46 @@ test("activate wipes other caches but preserves the CURRENT image cache", async ); }); +test("the network fetch starts immediately — never serialized behind cache I/O", async () => { + // Regression: the first version awaited caches.open + cache.match BEFORE + // starting the download, so a cold thumbnail burst loaded visibly slower than + // with no SW at all ("Recently Added fetching slowly"). + let fetchStarted = false; + let releaseMatch; + const matchGate = new Promise((resolve) => { + releaseMatch = resolve; + }); + + const { listeners, sandbox } = loadSw({ + fetchImpl: async () => { + fetchStarted = true; + return makeResponse("net-1"); + }, + }); + // Make cache.match hang until released — the download must not wait for it. + const realOpen = sandbox.caches.open.bind(sandbox.caches); + sandbox.caches.open = async (name) => { + const cache = await realOpen(name); + return { + ...cache, + async match(request) { + await matchGate; + return cache.match(request); + }, + }; + }; + + const event = makeFetchEvent(IMG); + listeners.get("fetch")(event); + await Promise.resolve(); // let the handler take its first steps + assert.equal(fetchStarted, true, "download began while cache.match was still pending"); + + releaseMatch(); + const response = await event.response; + assert.equal(response.body, "net-1"); + await Promise.all(event.pending); +}); + test("opaque (cross-origin no-cors) responses are cached; failed basic responses are not", async () => { let mode = "opaque"; const { listeners, cacheStorage } = loadSw({ From 5466d975441d90904e2656f12d9b009c6d6b4e5f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:18:10 -0400 Subject: [PATCH 11/13] [ui] Remove NIP-71 authoring sections from upload/edit modals (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bitvid authors kind-30078 and MIRRORS to NIP-71; it never authors NIP-71 directly. Verified before removing: the My Videos mirror (buildNip71MirrorEvent) derives everything from the bitvid note itself (duration/dims are auto-captured at upload, contentWarning comes from the NSFW toggle, imeta is built from the video), and the only consumer of the form's NIP-71 metadata (publishNip71Video) is gated behind FEATURE_PUBLISH_NIP71: false — the fields fed a dead path and confused uploaders. Removed from the upload modal: "NIP-71 Technical" (content warning, duration, summary + Customize toggle) and the IMETA variant editor. Removed from the edit modal: kind (21/22) selector, published_at, alt, duration, content warning, summary, IMETA, text tracks, segments, participants, references. KEPT in both: hashtags (they feed hashtag feeds, the suggestion chips, and the mirror's t tags) and the web seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71). All the JS plumbing is missing-DOM tolerant, so no wiring changes were needed to collect. Compatibility: existing videos' NIP-71 extras survive a save. EditModal keeps the video's full stored metadata (storedNip71Metadata) and merges the edited hashtags into it on submit instead of replacing it with the reduced form's empty output (t mirror key kept in sync). Also fixes a latent pre-existing bug the reduction exposed: nip71Edited compared the submit-side copy AFTER publishedAt normalization (string→timestamp, delete-empty) against an UN-normalized original, so any video with publishedAt made every save look "edited". Both sides now share normalizeCollectedNip71. Tests: +2 in edit-modal-submit-state (stored extras survive a hashtag edit; untouched save keeps nip71Edited false). Modal/payload/mirror suites green (39 + 7). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- components/edit-video-modal.html | 572 +------------------------ components/upload-modal.html | 51 +-- js/ui/components/EditModal.js | 77 +++- tests/edit-modal-submit-state.test.mjs | 117 +++++ todo/TODO_2026-06-20_pre-launch.md | 29 ++ 5 files changed, 220 insertions(+), 626 deletions(-) diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html index 74fa9e5e..e2a13671 100644 --- a/components/edit-video-modal.html +++ b/components/edit-video-modal.html @@ -57,492 +57,14 @@ - -
-
- - NIP-71 event type - -

- Choose the video format. Kind 21 is a full video, while - Kind 22 is a short. -

-
- -
-
- -
-
- - -

- Set the canonical publish time for cross-client ordering. -

-
-
- - -

- Plain-language fallback shown when the media cannot load. -

-
-
- - -

- Optional legacy fallback in seconds (fractions allowed) for clients that expect a top-level duration. -

-
-
- - -

- Leave blank if the video is safe for all audiences. -

-
-
- -
- - -

- This becomes the event content. Keep it concise for Nostr readers. -

-
-
- -
- - NIP-71 tags - -

- Use the controls below to describe media variants, captions, chapters, participants, and references. -

- -
-
-

Image metadata (imeta)

- -
-

- Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple - image, fallback, or service values. -

-
- -
- -
-
-

- Caption & subtitle tracks (text-track) -

- -
-

- Supply caption or subtitle URLs along with file type and language so players can surface accessibility features. -

-
- -
- -
-
-

Chapters & segments (segment)

- -
-

- Outline chapters with start and end times, titles, and optional thumbnails. -

-
- -
- + +
-

Hashtags (t)

+

Hashtags

- Enter keywords; leading # characters are stripped automatically when saving. + Tag your video so it shows up in hashtag feeds. Leading # characters are stripped automatically.

- -
-
-

Participants (p)

- -
-
- -
- -
-
-

Reference links (r)

- -
-
- -
-
+