diff --git a/js/app.js b/js/app.js
index 16c5dd24..f9803039 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,
+ );
+ });
}
}
@@ -959,8 +983,6 @@ class Application {
}
async _initAccessControl() {
- const promises = [];
-
const aclRefreshPromise = accessControl
.refresh()
.then(() => {
@@ -981,6 +1003,36 @@ class Application {
);
});
+ // Admin-pane state is UI, not a boot dependency — never block boot on it.
+ const refreshAdminPane = () => {
+ if (!this.profileController) {
+ return;
+ }
+ Promise.resolve()
+ .then(() => this.profileController.refreshAdminPaneState())
+ .catch((error) => {
+ devLogger.warn(
+ "Failed to update admin pane after connecting to Nostr:",
+ error,
+ );
+ });
+ };
+ refreshAdminPane();
+
+ // Stale-while-revalidate boot: when the admin lists hydrated from the
+ // localStorage cache (every visit after the first), canAccess() is already
+ // accurate, so DON'T serialize boot behind the relay refresh — it continues
+ // in the background and the whitelist/blacklist change listeners re-filter
+ // the grids + re-apply trusted seeds when it lands. This was the
+ // "Fetching moderation filters…" cold-start stall (up to 15s before the
+ // feed could even start).
+ if (accessControl.isHydrated?.()) {
+ void aclRefreshPromise.then(refreshAdminPane);
+ return;
+ }
+
+ // First-ever visit (no cached lists): wait, so a whitelist-mode instance
+ // never renders an unfiltered/empty feed.
// Safeguard: Do not block app initialization indefinitely if relays are slow/unresponsive.
// 15s gives plenty of time for a healthy connection but prevents E2E test timeouts (60s).
const timeoutPromise = new Promise((resolve) => {
@@ -990,22 +1042,8 @@ class Application {
}, 15000);
});
- promises.push(Promise.race([aclRefreshPromise, timeoutPromise]));
-
- if (this.profileController) {
- promises.push(
- Promise.resolve()
- .then(() => this.profileController.refreshAdminPaneState())
- .catch((error) => {
- devLogger.warn(
- "Failed to update admin pane after connecting to Nostr:",
- error,
- );
- })
- );
- }
-
- await Promise.all(promises);
+ await Promise.race([aclRefreshPromise, timeoutPromise]);
+ refreshAdminPane();
}
async _syncSessionActorBlacklist(trigger) {
@@ -1226,7 +1264,9 @@ class Application {
// encrypted); safe no-op when nothing is cached or for NIP-07/46.
try {
if (typeof nostrClient.restoreUnlockedSigner === "function") {
- await nostrClient.restoreUnlockedSigner(normalizedSaved);
+ await nostrClient.restoreUnlockedSigner(normalizedSaved, {
+ validator: this.buildAccessControlValidator(),
+ });
}
} catch (error) {
devLogger.warn(
@@ -2148,7 +2188,9 @@ class Application {
!isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)
) {
try {
- const restored = await nostrClient.restoreUnlockedSigner(normalized);
+ const restored = await nostrClient.restoreUnlockedSigner(normalized, {
+ validator: this.buildAccessControlValidator(),
+ });
if (
restored?.restored &&
isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)
@@ -2222,6 +2264,22 @@ class Application {
return { ok: true, unlocked: true };
}
+ // Access-control gate passed into keep-unlocked signer restores so a
+ // since-blocked account's cached key is forgotten instead of restoring.
+ buildAccessControlValidator() {
+ return (pk) => {
+ if (
+ accessControl &&
+ typeof accessControl.canAccess === "function" &&
+ !accessControl.canAccess(pk)
+ ) {
+ const error = new Error("Access restricted for this account.");
+ error.code = "access-denied";
+ throw error;
+ }
+ };
+ }
+
// True when the active (or given) account currently has a cached "keep
// unlocked" key — i.e. there is something to lock (TODO #51).
isSessionKeptUnlocked(pubkey) {
diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js
index 6bb442fa..ea3059e3 100644
--- a/js/app/authSessionCoordinator.js
+++ b/js/app/authSessionCoordinator.js
@@ -15,6 +15,10 @@ import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecry
import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js";
import { showPasswordPrompt } from "../ui/promptDialog.js";
import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js";
+import {
+ createListCacheProbe,
+ waitForListCaches,
+} from "./feedListCacheGate.js";
/**
* Kick a background watch-history refresh after login, once the feed-driving
@@ -200,6 +204,20 @@ export function createAuthSessionCoordinator(deps) {
if (detail?.identityChanged) {
this.resetViewLoggingState();
+
+ // Account switch: clear the PREVIOUS account's app-wide DM store + stop
+ // its subscription (logout does the same); resetting only the modal view
+ // left old messages on screen. keepSnapshot preserves each account's
+ // persisted DM 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();
@@ -650,11 +668,8 @@ export function createAuthSessionCoordinator(deps) {
authLoadingState: this.authLoadingState,
});
- // Now that the NEW identity's lists (blocks/subscriptions/hashtags) have
- // settled, backfill the profile modal if it is open — so switching
- // accounts with the modal visible refreshes the pane the user is viewing
- // with the freshly-loaded data instead of leaving the previous account's
- // list on screen until a manual refresh. No-op when the modal is closed.
+ // The NEW identity's lists settled: backfill the open profile modal so a
+ // switch refreshes the visible pane (no-op when the modal is closed).
if (
this.profileController &&
typeof this.profileController.refreshOpenPanesForActiveIdentity ===
@@ -775,11 +790,21 @@ export function createAuthSessionCoordinator(deps) {
// lowered so settled results arrive faster. 8s prevents the feed from
// being blocked while still giving most lists time to decrypt.
const FEED_SYNC_TIMEOUT_MS = 8000;
+ // Cache-aware early release (rationale in feedListCacheGate.js): open the
+ // gate as soon as all three list caches are valid for this pubkey.
+ const listCacheReadyPromise = waitForListCaches(
+ createListCacheProbe({
+ userBlocks,
+ subscriptions,
+ hashtagPreferences: this.hashtagPreferences,
+ normalizeHexPubkey: (value) => this.normalizeHexPubkey(value),
+ activePubkey,
+ }),
+ { timeoutMs: FEED_SYNC_TIMEOUT_MS },
+ );
const feedSyncPromise = Promise.race([
- Promise.allSettled([
- listStatePromise,
- profileStatePromise,
- ]),
+ Promise.allSettled([listStatePromise, profileStatePromise]),
+ listCacheReadyPromise,
new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)),
]).catch(() => null);
@@ -974,15 +999,14 @@ export function createAuthSessionCoordinator(deps) {
clearWatchHistoryConversationKeyCache();
clearWatchHistoryDecryptedChunkCache();
- // Forget any cached "keep unlocked" key for the account being logged out, so
- // logout actually re-locks it (TODO #51).
+ // Forget the cached "keep unlocked" key so logout re-locks (TODO #51).
+ // MUST read previousPubkey: authService.logout already nulled the active
+ // pubkey, so detail.pubkey/this.pubkey are empty here.
try {
- const loggedOutPubkey = detail?.pubkey || this.pubkey;
- if (
- loggedOutPubkey &&
- typeof nostrClient.forgetUnlockedSigner === "function"
- ) {
- nostrClient.forgetUnlockedSigner(loggedOutPubkey);
+ const loggedOutPubkey =
+ detail?.previousPubkey || detail?.pubkey || this.pubkey;
+ if (loggedOutPubkey) {
+ nostrClient.forgetUnlockedSigner?.(loggedOutPubkey);
}
} catch (error) {
devLogger.warn("Failed to forget cached unlock during logout:", error);
@@ -1687,14 +1711,14 @@ export function createAuthSessionCoordinator(deps) {
}
}
- // Removing a saved profile must also forget its cached "keep unlocked" key.
+ // A removed profile must not leave keys on the device: forget its cached
+ // "keep unlocked" key + stored credentials (nsec key, NIP-46 session).
try {
- if (typeof nostrClient.forgetUnlockedSigner === "function") {
- nostrClient.forgetUnlockedSigner(normalizedTarget);
- }
+ nostrClient.forgetUnlockedSigner?.(normalizedTarget);
+ nostrClient.forgetStoredAccount?.(normalizedTarget);
} catch (error) {
devLogger.warn(
- "[Application] Failed to forget cached unlock for removed profile:",
+ "[Application] Failed to forget credentials for removed profile:",
error,
);
}
@@ -1822,11 +1846,9 @@ export function createAuthSessionCoordinator(deps) {
return context;
}
- // Block lists are NIP-04 encrypted. A reloaded nsec session can lose its
- // in-memory key; re-unlock it (one passphrase prompt) rather than failing
- // with a blanket signer error (TODO #57). Only short-circuit on the
- // user-driven outcomes — anything else falls through so the mutation's own
- // signer error (e.g. an unresponsive NIP-07 extension) still surfaces.
+ // Block lists are NIP-04 encrypted: re-unlock a reloaded nsec signer via
+ // one passphrase prompt (TODO #57). Only user-driven outcomes short-circuit;
+ // everything else falls through to the mutation's own signer errors.
if (typeof this.ensureEncryptionCapableSigner === "function") {
const ensured = await this.ensureEncryptionCapableSigner({
pubkey: actorHex,
diff --git a/js/app/feedListCacheGate.js b/js/app/feedListCacheGate.js
new file mode 100644
index 00000000..a7acf78a
--- /dev/null
+++ b/js/app/feedListCacheGate.js
@@ -0,0 +1,79 @@
+// Cache-aware early release for the post-login feed gate. The login flow holds
+// the video grid until blocks/subscriptions/hashtag lists settle (so the feed
+// never flashes unfiltered content), time-boxed at several seconds. But on every
+// warm boot those services hydrate valid per-pubkey CACHES almost immediately —
+// filtering with cached lists is accurate, and the relay sync keeps running in
+// the background (its change events re-filter when fresh data lands). This probe
+// lets the gate open as soon as all three caches are usable instead of waiting
+// for full relay sync + NIP-07 decryption ("Fetching subscriptions…" stall).
+
+// Builds a () => boolean probe: true when all three list services hold a valid
+// cache for the active pubkey. Mirrors the hasValid*Cache checks used by the
+// login list-sync bookkeeping.
+export function createListCacheProbe({
+ userBlocks,
+ subscriptions,
+ hashtagPreferences,
+ normalizeHexPubkey,
+ activePubkey,
+} = {}) {
+ const normalize =
+ typeof normalizeHexPubkey === "function"
+ ? normalizeHexPubkey
+ : (value) => (typeof value === "string" ? value.trim().toLowerCase() : "");
+ const target = normalize(activePubkey);
+
+ return () => {
+ if (!target) {
+ return false;
+ }
+ const blocksReady =
+ Boolean(userBlocks) &&
+ userBlocks.loaded === true &&
+ normalize(userBlocks.activePubkey) === target;
+ const subscriptionsReady =
+ Boolean(subscriptions) &&
+ subscriptions.loaded === true &&
+ normalize(subscriptions.currentUserPubkey) === target;
+ const hashtagsReady =
+ Boolean(hashtagPreferences) &&
+ hashtagPreferences.loaded === true &&
+ normalize(hashtagPreferences.activePubkey) === target;
+ return blocksReady && subscriptionsReady && hashtagsReady;
+ };
+}
+
+// Resolves "cache" as soon as the probe passes, or null at the timeout. Polls on
+// a short interval — the caches hydrate within the first ticks on warm boots.
+export function waitForListCaches(
+ probe,
+ { timeoutMs = 8000, intervalMs = 150 } = {},
+) {
+ return new Promise((resolve) => {
+ if (typeof probe !== "function") {
+ resolve(null);
+ return;
+ }
+ const startedAt = Date.now();
+ const check = () => {
+ let ready = false;
+ try {
+ ready = probe() === true;
+ } catch (error) {
+ ready = false;
+ }
+ if (ready) {
+ resolve("cache");
+ return;
+ }
+ if (Date.now() - startedAt >= timeoutMs) {
+ resolve(null);
+ return;
+ }
+ setTimeout(check, intervalMs);
+ };
+ check();
+ });
+}
+
+export default waitForListCaches;
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/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/js/nostr/client.js b/js/nostr/client.js
index 4b75b729..41f72c4b 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -2539,9 +2539,10 @@ export class NostrClient {
try {
opts.validator(adapter.pubkey);
} catch (error) {
- // Don't leave a since-blocked key persisted on this device.
+ // Don't leave the since-blocked key persisted on this device. Targeted:
+ // only THIS account's entry — the no-arg clear wiped every saved key.
try {
- clearStoredSessionActorEntry();
+ clearStoredSessionActorEntry(adapter.pubkey);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
@@ -2738,8 +2739,11 @@ export class NostrClient {
// Restore an nsec signer from the "keep unlocked" cache without a passphrase
// (TODO #51). Reads the cached decrypted key for `targetPubkey`, verifies it
// derives to that pubkey (defense-in-depth; a mismatch forgets the cache), and
- // registers it app-wide. Returns { restored: boolean }.
- async restoreUnlockedSigner(targetPubkey) {
+ // registers it app-wide. Pass a `validator` (access-control gate, like
+ // unlockStoredSessionActor's) where available — a since-blocked account's
+ // cached key is then forgotten instead of silently restoring its signer.
+ // Returns { restored: boolean }.
+ async restoreUnlockedSigner(targetPubkey, { validator } = {}) {
const pubkey =
typeof targetPubkey === "string" && HEX64_REGEX.test(targetPubkey.trim().toLowerCase())
? targetPubkey.trim().toLowerCase()
@@ -2771,6 +2775,17 @@ export class NostrClient {
return { restored: false, reason: "pubkey-mismatch" };
}
+ // Access-control gate (mirrors unlockStoredSessionActor): a since-blocked
+ // account must not silently restore its signer — forget its cached key.
+ if (typeof validator === "function") {
+ try {
+ validator(pubkey);
+ } catch (error) {
+ forgetUnlockedKey(pubkey);
+ return { restored: false, reason: "access-denied", error };
+ }
+ }
+
try {
const adapter = await this.registerPrivateKeySigner({
privateKey: privateKeyHex,
@@ -3064,6 +3079,28 @@ export class NostrClient {
return this.signerManager.clearStoredSessionActor();
}
+ // Forget ONE removed account's stored credentials (remembered nsec key +
+ // NIP-46 remote-signer session) without touching the active session. Used by
+ // the profile switcher's remove-account flow so a removed profile doesn't
+ // leave its keys on the device.
+ forgetStoredAccount(pubkey) {
+ const normalized =
+ typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
+ if (!normalized) {
+ return;
+ }
+ try {
+ this.signerManager.forgetStoredSessionActor(normalized);
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to forget stored nsec key:", error);
+ }
+ try {
+ clearStoredNip46Session(normalized);
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to forget stored NIP-46 session:", error);
+ }
+ }
+
/**
* Logs out the current user and clears all session state.
*
diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js
index 6338d4fc..e4e9b521 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;
}
@@ -189,12 +203,36 @@ export class SignerManager {
return newActor.pubkey;
}
- clearStoredSessionActor() {
- clearStoredSessionActorEntry();
+ // Forget a stored (remembered) key. Pass the account's pubkey — the no-arg
+ // form only clears the legacy v1 slot and must never be used to log out one
+ // account (it used to wipe EVERY saved account's key).
+ clearStoredSessionActor(pubkey) {
+ clearStoredSessionActorEntry(pubkey);
this.sessionActor = null;
this.lockedSessionActor = null;
}
+ // Forget ONE account's remembered key WITHOUT touching the current session's
+ // in-memory actors (used when removing a non-active saved profile). Only drops
+ // the in-memory references if they belong to the removed account.
+ forgetStoredSessionActor(pubkey) {
+ const target =
+ typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
+ if (!target) {
+ return;
+ }
+ clearStoredSessionActorEntry(target);
+ const matches = (actor) =>
+ typeof actor?.pubkey === "string" &&
+ actor.pubkey.trim().toLowerCase() === target;
+ if (matches(this.lockedSessionActor)) {
+ this.lockedSessionActor = null;
+ }
+ if (matches(this.sessionActor) && !this.sessionActor?.privateKey) {
+ this.sessionActor = null;
+ }
+ }
+
async ensureActiveSignerForPubkey(pubkey) {
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
@@ -1069,8 +1107,15 @@ export class SignerManager {
previousSessionActor.persisted !== true;
if (shouldClearStoredSession) {
- this.lockedSessionActor = null;
- this.clearStoredSessionActor();
+ // Forget ONLY the departing account's remembered key. The no-arg clear
+ // used to wipe the whole per-account map, so logging out one non-persisted
+ // nsec login destroyed every OTHER saved account's key. Skip entirely when
+ // we can't name the account — never wipe-all implicitly.
+ const clearTarget = previousSessionActor.pubkey || previousPubkey || "";
+ if (clearTarget) {
+ this.lockedSessionActor = null;
+ this.clearStoredSessionActor(clearTarget);
+ }
}
if (
diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js
index 18b98b99..1cba12bd 100644
--- a/js/nostr/sessionActor.js
+++ b/js/nostr/sessionActor.js
@@ -677,19 +677,19 @@ export function clearStoredSessionActor(pubkey) {
return;
}
- clearStoredSessionActorIndexedDb();
-
+ // No target pubkey: clear ONLY the legacy v1 "last saved" slot. Wiping the
+ // per-account v2 map here deleted EVERY saved account's remembered nsec key —
+ // the no-arg callers were logout/teardown paths for ONE account, so logging
+ // out (or a blocked-key cleanup) silently destroyed the other saved accounts'
+ // keys and broke switching back to them. Same bug class as the NIP-46
+ // session-map wipe. A specific account is forgotten via
+ // clearStoredSessionActor(pubkey), which remains targeted.
if (typeof localStorage !== "undefined") {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
- try {
- localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
- } catch (error) {
- devLogger.warn("[nostr] Failed to clear session actor map:", error);
- }
}
}
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/js/services/trustBootstrap.js b/js/services/trustBootstrap.js
index e6f262c9..36f1df8e 100644
--- a/js/services/trustBootstrap.js
+++ b/js/services/trustBootstrap.js
@@ -68,6 +68,17 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
return { ok: true, timedOut: false };
}
+ // Cached admin lists (hydrated synchronously from localStorage at
+ // construction) are enough to seed trust — don't stall boot behind the relay
+ // refresh. If the refresh later changes editors/whitelist/blacklist, the
+ // change listeners registered in bootstrapTrustedSeeds re-apply the seeds.
+ if (
+ typeof accessControl.isHydrated === "function" &&
+ accessControl.isHydrated()
+ ) {
+ return { ok: true, timedOut: false, fromCache: true };
+ }
+
let timedOut = false;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
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/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/EditModal.js b/js/ui/components/EditModal.js
index 098a62f1..51fdd842 100644
--- a/js/ui/components/EditModal.js
+++ b/js/ui/components/EditModal.js
@@ -101,6 +101,11 @@ export class EditModal {
this.nip71SectionKey = "edit";
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
+ // Full stored NIP-71 metadata for the active video. The form now edits only
+ // hashtags (the rest of the NIP-71 section was removed for a cleaner UI), so
+ // legacy extras (imeta, text tracks, participants, …) must be merged back in
+ // on save rather than replaced by the reduced form's empty output.
+ this.storedNip71Metadata = null;
}
addEventListener(type, listener, options) {
@@ -385,6 +390,7 @@ export class EditModal {
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
+ this.storedNip71Metadata = null;
this.setSubmitState({ pending: false });
return;
}
@@ -430,6 +436,7 @@ export class EditModal {
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
+ this.storedNip71Metadata = null;
this.activeVideo = null;
this.setSubmitState({ pending: false });
}
@@ -511,7 +518,19 @@ export class EditModal {
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
+ // Apply the SAME normalization submit applies to its collected copy, so the
+ // nip71Edited JSON comparison is between identically-shaped objects. Without
+ // this, submit's publishedAt string→timestamp / delete-empty step made every
+ // save look "edited" even when nothing changed.
+ this.normalizeCollectedNip71(this.originalNip71Metadata);
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
+ // Keep the video's FULL stored NIP-71 metadata (pre-form): the reduced form
+ // only round-trips hashtags, and submit merges them into this so legacy
+ // extras survive a save.
+ this.storedNip71Metadata =
+ video.nip71 && typeof video.nip71 === "object"
+ ? this.cloneNip71Metadata(video.nip71)
+ : null;
this.activeVideo = editContext;
if (this.root) {
@@ -888,7 +907,29 @@ export class EditModal {
}
}
- submit() {
+ // Normalize a collectSection() result in place: convert the datetime-local
+ // publishedAt string back to unix seconds and drop it when empty/invalid.
+ // Applied to BOTH the open-time original and the submit-time copy so the
+ // nip71Edited comparison sees identically-shaped objects.
+ normalizeCollectedNip71(metadata) {
+ if (!metadata || typeof metadata.publishedAt !== "string") {
+ return metadata;
+ }
+ const dateStr = metadata.publishedAt;
+ if (dateStr) {
+ const dt = new Date(dateStr);
+ if (!isNaN(dt.getTime())) {
+ metadata.publishedAt = Math.floor(dt.getTime() / 1000);
+ } else {
+ delete metadata.publishedAt;
+ }
+ } else {
+ delete metadata.publishedAt;
+ }
+ return metadata;
+ }
+
+ async submit() {
if (this.pendingSubmit) {
return;
}
@@ -996,21 +1037,7 @@ export class EditModal {
const rawNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
const currentNip71 = this.cloneNip71Metadata(rawNip71);
-
- // Convert publishedAt back to Unix timestamp (seconds)
- if (currentNip71 && typeof currentNip71.publishedAt === "string") {
- const dateStr = currentNip71.publishedAt;
- if (dateStr) {
- const dt = new Date(dateStr);
- if (!isNaN(dt.getTime())) {
- currentNip71.publishedAt = Math.floor(dt.getTime() / 1000);
- } else {
- delete currentNip71.publishedAt;
- }
- } else {
- delete currentNip71.publishedAt;
- }
- }
+ this.normalizeCollectedNip71(currentNip71);
const hasImetaSource = Array.isArray(currentNip71?.imeta)
? currentNip71.imeta.some((variant) => {
if (!variant || typeof variant !== "object") {
@@ -1076,6 +1103,50 @@ 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. 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 &&
+ !finalMagnet &&
+ 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;
+ }
+ } 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
@@ -1126,7 +1197,25 @@ export class EditModal {
const currentNip71Json = JSON.stringify(currentNip71);
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
- updatedData.nip71 = currentNip71;
+ // The form now edits ONLY hashtags. Merge them into the video's full stored
+ // NIP-71 metadata so legacy extras (imeta, text tracks, participants, …) are
+ // preserved on save instead of being replaced by the reduced form's empty
+ // fields. (Both sides of the nip71Edited comparison round-trip through the
+ // same reduced form, so it still fires exactly on hashtag changes.)
+ let finalNip71 = currentNip71;
+ if (this.storedNip71Metadata && typeof this.storedNip71Metadata === "object") {
+ const preserved = this.cloneNip71Metadata(this.storedNip71Metadata) || {};
+ const editedHashtags = Array.isArray(currentNip71?.hashtags)
+ ? currentNip71.hashtags
+ : [];
+ preserved.hashtags = editedHashtags;
+ if (Array.isArray(preserved.t)) {
+ // Some stored payloads mirror hashtags under `t`; keep them in sync.
+ preserved.t = editedHashtags;
+ }
+ finalNip71 = preserved;
+ }
+ updatedData.nip71 = finalNip71;
updatedData.nip71Edited = nip71Edited;
const originalEvent = {
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/VideoModal.js b/js/ui/components/VideoModal.js
index a612b40e..4ea29c04 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 });
}
}
@@ -3026,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/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/sw.min.js b/sw.min.js
index d415e4c2..204942f7 100644
--- a/sw.min.js
+++ b/sw.min.js
@@ -1,12 +1,129 @@
(() => {
"use strict";
- const SW_VERSION = "bitvid-sw-2026-02-10";
+ const SW_VERSION = "bitvid-sw-2026-07-02.1";
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;
+ // 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") {
+ 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(request, response) {
+ try {
+ const cache = await openImageCache();
+ await cache.put(request, response);
+ 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) {
+ // fail open
+ }
+ }
+ }
+
+ async function handleImageRequest(event) {
+ const request = event.request;
+
+ // 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) {
+ // Serve the cached copy instantly; the refresh continues in the background.
+ event.waitUntil(network.catch(() => {}));
+ return cached;
+ }
+ return network;
+ }
+
let cancelled = false;
function broadcastLifecycle(eventType, details = {}) {
@@ -46,7 +163,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 +177,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 +191,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/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();
+ });
+});
diff --git a/tests/edit-modal-submit-state.test.mjs b/tests/edit-modal-submit-state.test.mjs
index 56d4ecfc..e2bfb2ee 100644
--- a/tests/edit-modal-submit-state.test.mjs
+++ b/tests/edit-modal-submit-state.test.mjs
@@ -310,3 +310,120 @@ test('does not show missing video error after modal closes', async () => {
'should not surface missing video error when closed',
);
});
+
+// NIP-71 extras must survive a save now that the edit form only exposes hashtags
+// (the rest of the NIP-71 section was removed for a cleaner UI). A hashtag edit
+// must merge into the video's stored metadata — never replace it with the reduced
+// form's empty fields.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-edit-nip71-preserved
+// given: "a video whose stored nip71 carries imeta/textTracks/participants"
+// when: "the user edits hashtags in the reduced form and submits"
+// then: "updatedData.nip71 keeps the extras AND the new hashtags; nip71Edited reflects the hashtag change"
+// observable_outcomes:
+// - "imeta/textTracks/participants/publishedAt survive the save"
+// - "hashtags (and a mirrored t key) update to the edited set"
+// - "no hashtag change -> nip71Edited stays false"
+// determinism_controls:
+// - "JSDOM modal against the real component HTML; no network"
+// anti_cheat_rationale:
+// prevents: ["asserting the wiping (broken) behavior", "over-mocking internal logic"]
+// relaxation:
+// did_relax_any_assertion: false
+
+test('editing hashtags preserves stored NIP-71 extras instead of wiping them', async () => {
+ const modal = createModal({ eventTarget: new EventTarget() });
+ await modal.load({ container });
+
+ const storedNip71 = {
+ publishedAt: 1700000000,
+ imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
+ textTracks: [{ url: 'https://cdn.example.com/v.vtt', type: 'captions' }],
+ participants: [{ pubkey: 'a'.repeat(64) }],
+ hashtags: ['old'],
+ t: ['old'],
+ };
+
+ const video = {
+ id: 'video-nip71',
+ pubkey: 'pubkey1',
+ title: 'Sample video',
+ url: 'https://example.com/video.mp4',
+ thumbnail: '',
+ description: '',
+ enableComments: true,
+ isPrivate: false,
+ isNsfw: false,
+ isForKids: false,
+ nip71: storedNip71,
+ };
+
+ await modal.open(video);
+
+ // Edit hashtags through the reduced form (same path as the UI's Add hashtag).
+ const entry = modal.nip71FormManager.addRepeaterEntry('edit', 't');
+ modal.nip71FormManager.setFieldValue(entry, 'value', 'newtag');
+
+ const submissions = [];
+ modal.addEventListener('video:edit-submit', (event) => {
+ submissions.push(event.detail);
+ });
+
+ await modal.submit();
+
+ assert.equal(submissions.length, 1, 'submit emitted');
+ const savedNip71 = submissions[0].updatedData.nip71;
+ assert.equal(submissions[0].updatedData.nip71Edited, true, 'hashtag change detected');
+
+ assert.deepEqual(savedNip71.imeta, storedNip71.imeta, 'imeta preserved');
+ assert.deepEqual(savedNip71.textTracks, storedNip71.textTracks, 'text tracks preserved');
+ assert.deepEqual(savedNip71.participants, storedNip71.participants, 'participants preserved');
+ assert.equal(savedNip71.publishedAt, storedNip71.publishedAt, 'publishedAt preserved');
+ assert.deepEqual(savedNip71.hashtags, ['old', 'newtag'], 'edited hashtags applied');
+ assert.deepEqual(savedNip71.t, ['old', 'newtag'], 'mirrored t key kept in sync');
+});
+
+test('submitting without touching hashtags leaves nip71Edited false and extras intact', async () => {
+ const modal = createModal({ eventTarget: new EventTarget() });
+ await modal.load({ container });
+
+ const storedNip71 = {
+ imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
+ hashtags: ['keepme'],
+ };
+
+ const video = {
+ id: 'video-nip71-b',
+ pubkey: 'pubkey1',
+ title: 'Sample video',
+ url: 'https://example.com/video.mp4',
+ thumbnail: '',
+ description: '',
+ enableComments: true,
+ isPrivate: false,
+ isNsfw: false,
+ isForKids: false,
+ nip71: storedNip71,
+ };
+
+ await modal.open(video);
+
+ const submissions = [];
+ modal.addEventListener('video:edit-submit', (event) => {
+ submissions.push(event.detail);
+ });
+
+ await modal.submit();
+
+ assert.equal(submissions.length, 1);
+ assert.equal(submissions[0].updatedData.nip71Edited, false, 'no hashtag change');
+ assert.deepEqual(
+ submissions[0].updatedData.nip71.imeta,
+ storedNip71.imeta,
+ 'extras intact on an untouched save',
+ );
+ assert.deepEqual(submissions[0].updatedData.nip71.hashtags, ['keepme']);
+});
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",
+ );
+});
diff --git a/tests/feed-list-cache-gate.test.mjs b/tests/feed-list-cache-gate.test.mjs
new file mode 100644
index 00000000..b15d9a04
--- /dev/null
+++ b/tests/feed-list-cache-gate.test.mjs
@@ -0,0 +1,106 @@
+// Cold-load "first impression" fix: the post-login feed gate can open as soon as
+// blocks/subscriptions/hashtags hold valid per-pubkey CACHES (warm boot), instead
+// of waiting for the full relay sync + decryption. These test the probe + waiter
+// that implement the early release.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-feed-list-cache-gate
+// given: "list services with/without valid per-pubkey caches"
+// when: "createListCacheProbe / waitForListCaches run"
+// then: "probe is true only when ALL THREE caches match the active pubkey; the waiter resolves 'cache' early or null at timeout"
+// observable_outcomes:
+// - "all three caches valid + matching pubkey -> probe true"
+// - "any cache missing / stale pubkey / not loaded -> probe false"
+// - "waiter resolves 'cache' once the probe flips true (caches hydrating late)"
+// - "waiter resolves null at timeout when caches never hydrate (cold boot)"
+// determinism_controls:
+// - "plain state objects; short real timers (interval 5ms, timeout 40ms)"
+// 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 {
+ createListCacheProbe,
+ waitForListCaches,
+} from "../js/app/feedListCacheGate.js";
+
+const PUB = "A".repeat(64); // exercise normalization (upper → lower)
+
+function services({ blocks = true, subs = true, tags = true, pubkey = PUB } = {}) {
+ return {
+ userBlocks: { loaded: blocks, activePubkey: pubkey },
+ subscriptions: { loaded: subs, currentUserPubkey: pubkey },
+ hashtagPreferences: { loaded: tags, activePubkey: pubkey },
+ };
+}
+
+const normalize = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
+
+test("probe is true only when ALL three caches are loaded for the active pubkey", () => {
+ const ok = createListCacheProbe({
+ ...services(),
+ normalizeHexPubkey: normalize,
+ activePubkey: PUB.toLowerCase(),
+ });
+ assert.equal(ok(), true);
+
+ for (const partial of [
+ services({ blocks: false }),
+ services({ subs: false }),
+ services({ tags: false }),
+ services({ pubkey: "b".repeat(64) }), // caches belong to another account
+ ]) {
+ const probe = createListCacheProbe({
+ ...partial,
+ normalizeHexPubkey: normalize,
+ activePubkey: PUB.toLowerCase(),
+ });
+ assert.equal(probe(), false);
+ }
+});
+
+test("probe is false with no active pubkey or missing services", () => {
+ assert.equal(
+ createListCacheProbe({ ...services(), normalizeHexPubkey: normalize, activePubkey: "" })(),
+ false,
+ );
+ assert.equal(
+ createListCacheProbe({ normalizeHexPubkey: normalize, activePubkey: PUB })(),
+ false,
+ );
+});
+
+test("waiter resolves 'cache' once caches hydrate (late flip)", async () => {
+ const state = { ready: false };
+ setTimeout(() => {
+ state.ready = true;
+ }, 15);
+ const result = await waitForListCaches(() => state.ready, {
+ timeoutMs: 500,
+ intervalMs: 5,
+ });
+ assert.equal(result, "cache");
+});
+
+test("waiter resolves null at timeout when caches never hydrate (cold boot)", async () => {
+ const result = await waitForListCaches(() => false, {
+ timeoutMs: 40,
+ intervalMs: 5,
+ });
+ assert.equal(result, null);
+});
+
+test("a throwing probe is treated as not-ready (never breaks the gate)", async () => {
+ const result = await waitForListCaches(
+ () => {
+ throw new Error("boom");
+ },
+ { timeoutMs: 30, intervalMs: 5 },
+ );
+ assert.equal(result, null);
+});
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);
+ });
+ });
});
diff --git a/tests/session-actor-multi-account.test.mjs b/tests/session-actor-multi-account.test.mjs
index a54cf89c..6b0eec7e 100644
--- a/tests/session-actor-multi-account.test.mjs
+++ b/tests/session-actor-multi-account.test.mjs
@@ -4,7 +4,7 @@
// key survived, so switching among saved nsec accounts silently failed.
//
// test_integrity_note:
-// change_type: ["new_tests"]
+// change_type: ["new_tests", "spec_correction"]
// scenarios:
// - id: SCN-nsec-multi-account-storage
// given: "two nsec accounts each persist their encrypted key"
@@ -83,15 +83,27 @@ test("no-arg read returns the last-saved account as the default", () => {
assert.equal(readStoredSessionActorEntry().privateKeyEncrypted, "cipher-B");
});
-test("no-arg clear wipes every stored account (logout)", () => {
+// spec_correction: the previous test asserted "no-arg clear wipes every stored
+// account (logout)". That encoded a bug: the no-arg clear is reached from
+// per-account paths (SignerManager.logout's non-persisted-nsec cleanup, the
+// blocked-key cleanup in registerPrivateKeySigner), so logging out ONE account
+// silently destroyed every OTHER saved account's remembered key and broke
+// switching back to them — the same bug class as the NIP-46 session-map wipe.
+// Correct behavior: no-arg clears only the legacy v1 "last saved" slot; the
+// per-account v2 map survives. Forgetting a specific account uses the targeted
+// clearStoredSessionActor(pubkey) (tested above).
+test("no-arg clear drops the legacy v1 slot but KEEPS per-account saved keys", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
- clearStoredSessionActor();
+ clearStoredSessionActor(); // e.g. one account's logout cleanup
- assert.deepEqual(listStoredSessionActorPubkeys(), []);
- assert.equal(readStoredSessionActorEntry(PUB_A), null);
- assert.equal(readStoredSessionActorEntry(), null);
+ // Both accounts remain switchable — their remembered keys survive.
+ assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort());
+ assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A");
+ assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
+ // The legacy single-slot default is gone.
+ assert.equal(localStorage.getItem(SESSION_ACTOR_STORAGE_KEY), null);
});
test("a legacy v1 single-slot entry migrates into the per-pubkey map", () => {
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..f506dfa0
--- /dev/null
+++ b/tests/sw-image-cache.test.mjs
@@ -0,0 +1,304 @@
+// 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("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({
+ 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/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs
index 4199e211..803d9e3f 100644
--- a/tests/video-modal-controllers.test.mjs
+++ b/tests/video-modal-controllers.test.mjs
@@ -190,3 +190,121 @@ 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");
+});
+
+// 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 d4ac2ba3..c3687a2b 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
@@ -1654,21 +1659,50 @@ 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
- 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
@@ -1725,3 +1759,32 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
(b) like/dislike a video, (c) block/unblock a creator, (d) subscribe to a channel,
(e) send a DM — each should show ONE passphrase prompt (not a blanket error), then
work; the single unlock should cover the rest until the next reload.
+
+### 58. Remove NIP-71 authoring UI from upload/edit modals (clean UI)
+- [x] **DONE 2026-07-02.** Decision (maintainer): bitvid authors kind-30078 and
+ MIRRORS to NIP-71 — it does not author NIP-71 directly — so the NIP-71 form
+ sections were confusing dead weight. Verified before removing: the My Videos
+ mirror (`buildNip71MirrorEvent`) derives EVERYTHING from the bitvid note
+ (title/url/magnet/thumbnail/description, duration+dims auto-captured at upload,
+ contentWarning from the NSFW toggle, hashtags), and the only consumer of the
+ form's NIP-71 metadata (`publishNip71Video`) is gated behind
+ `FEATURE_PUBLISH_NIP71: false`.
+ Removed: upload modal's "NIP-71 Technical" (content warning / duration /
+ summary+Customize) + IMETA variant editor; edit modal's kind selector,
+ published_at, alt, duration, content warning, summary, IMETA, text tracks,
+ segments, participants, references. KEPT: hashtags in both modals (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).
+ Compatibility: existing videos' NIP-71 extras are PRESERVED on save — EditModal
+ keeps the full stored metadata (`storedNip71Metadata`) and merges the edited
+ hashtags into it instead of replacing it with the reduced form's empty output.
+ Also fixed a latent bug this exposed: `nip71Edited` compared a normalized
+ submit-side copy against an unnormalized original (publishedAt string vs
+ timestamp), so every save looked "edited"; both sides now share
+ `normalizeCollectedNip71`. Tests: +2 in `tests/edit-modal-submit-state.test.mjs`
+ (extras survive a hashtag edit; untouched save → nip71Edited false).
+- [ ] **Post-launch cleanup (Option B, optional):** delete the dormant
+ `FEATURE_PUBLISH_NIP71` auto-publish path, the unused `Nip71FormManager`
+ sections (imeta/text-track/segment/p/r + scalar inputs), and the nip71
+ plumbing through `videoNotePayload` that only the removed forms fed.
+ RevertModal keeps its READ-ONLY use of the manager (version-history display).