diff --git a/js/app.js b/js/app.js index 99f028d9..9acbf248 100644 --- a/js/app.js +++ b/js/app.js @@ -983,8 +983,6 @@ class Application { } async _initAccessControl() { - const promises = []; - const aclRefreshPromise = accessControl .refresh() .then(() => { @@ -1005,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) => { @@ -1014,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) { diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index cc2b96e6..af2d4902 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 @@ -201,12 +205,10 @@ export function createAuthSessionCoordinator(deps) { if (detail?.identityChanged) { this.resetViewLoggingState(); - // Account switch: clear the PREVIOUS account's cached DMs + stop its - // subscription before the new account loads. The DM store is app-wide on - // nostrService, so resetting only the modal's view left the old account's - // messages on screen after a switch (e.g. NIP-07 → nsec) — logout clears it - // the same way. keepSnapshot keeps each account's persisted cache so - // switching back stays fast. + // 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 }); @@ -666,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 === @@ -791,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); @@ -1838,11 +1847,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/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/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); +});