diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 19541530..1fffe76f 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -618,31 +618,19 @@ export function createAuthSessionCoordinator(deps) { return listSyncDetail; }); - // DMs can wait for profile since they need encryption context. + // DMs are intentionally NOT eagerly loaded at login. A cold DM load + // (limit: 50) is a burst of nip-07 decrypts that competes with the + // feed-driving lists (blocks/subscriptions/hashtags) for the SAME + // single-threaded extension channel during the fragile post-login + // handshake window — under load that channel drops ("message channel + // closed") and every subsequent decrypt times out until a page refresh. + // DMs are lower priority (the user isn't viewing Messages at login) and + // already load lazily when the Messages tab is opened + // (ProfileDirectMessageActions.populateProfileMessages). The lightweight + // unread indicator (refreshUnreadDmIndicator, above) still runs so the + // badge is accurate without decrypting the full conversation history. const dmStatePromise = profileStatePromise.then(() => { - if ( - activePubkey && - this.nostrService && - typeof this.nostrService.loadDirectMessages === "function" - ) { - return this.nostrService - .loadDirectMessages({ - actorPubkey: activePubkey, - limit: 50, - initialLoad: true, - }) - .then(() => { - this.updateAuthLoadingState({ dms: "ready" }); - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to load direct messages during login:", - error, - ); - this.updateAuthLoadingState({ dms: "error" }); - }); - } - this.updateAuthLoadingState({ dms: "idle" }); + this.updateAuthLoadingState({ dms: "deferred" }); return Promise.resolve(); }); diff --git a/tests/app/login-defers-dms.test.mjs b/tests/app/login-defers-dms.test.mjs new file mode 100644 index 00000000..95f3b649 --- /dev/null +++ b/tests/app/login-defers-dms.test.mjs @@ -0,0 +1,143 @@ +// Scenario (SCN-login-defers-dms): +// Given a user logs in via the auth session coordinator, +// When the post-login hydration runs (profile + feed-driving lists settle), +// Then the coordinator MUST NOT eagerly load the full direct-message history +// (a cold limit:50 DM load is a burst of nip-07 decrypts that competes with +// blocks/subscriptions/hashtags for the single-threaded extension channel +// during the fragile handshake window — see KNOWN_BUGS #0). DMs load lazily +// when the Messages tab is opened instead. +// And the DM loading state is reported as "deferred" (not "ready"/"loading"), +// And the lightweight unread indicator still runs so the badge stays accurate. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// Minimal browser shim: handleAuthLogin reads window.location.search. +if (typeof globalThis.window === "undefined") { + globalThis.window = { location: { search: "", href: "http://localhost/" } }; +} + +const { createAuthSessionCoordinator } = await import( + "../../js/app/authSessionCoordinator.js" +); + +const ACTIVE_PUBKEY = "a".repeat(64); + +function makeCoordinator({ loadDirectMessages, refreshUnreadDmIndicator }) { + const noop = () => {}; + const asyncNoop = async () => {}; + const logger = { warn: noop, info: noop, log: noop, error: noop, debug: noop }; + + const coord = createAuthSessionCoordinator({ + devLogger: logger, + userLogger: logger, + nostrClient: { + // Signer resolves so the readiness gate completes (non-extension type so + // it doesn't attempt a real permission prompt). + ensureActiveSignerForPubkey: async () => ({ type: "nsec", pubkey: ACTIVE_PUBKEY }), + ensureExtensionPermissions: async () => ({ ok: true }), + }, + accessControl: { ensureReady: asyncNoop, getBlacklist: () => [] }, + userBlocks: { + getBlockedPubkeys: () => [], + seedWithNpubs: asyncNoop, + }, + subscriptions: { ensureLoaded: asyncNoop, lastLoadError: null }, + hashtagPreferences: { load: asyncNoop, lastLoadError: null }, + storageService: {}, + relayManager: {}, + torrentClient: {}, + getHashViewName: () => "", + setHashView: noop, + DEFAULT_NIP07_PERMISSION_METHODS: [], + RELAY_UI_BATCH_DELAY_MS: 0, + sanitizeRelayList: (x) => x, + buildDmRelayListEvent: noop, + publishEventToRelays: noop, + assertAnyRelayAccepted: noop, + queueSignEvent: noop, + bootstrapTrustedSeeds: noop, + getModerationSettings: () => ({}), + getActiveProfilePubkey: () => null, + }); + + // Collaborators the login flow touches on `this` — stub as inert so the flow + // runs end to end without real UI/services. + coord.pubkey = ACTIVE_PUBKEY; + coord.profileController = null; + coord.commentController = null; + coord.nwcSettingsService = null; + coord.authService = { + loadBlocksForPubkey: async () => true, + }; + coord.nostrService = { + loadDirectMessages, + listDirectMessageConversationSummaries: async () => [], + }; + + const inert = [ + "applyAuthenticatedUiState", + "capturePermissionPromptFromError", + "dispatchAuthChange", + "forceRefreshAllProfiles", + "maybeShowExperimentalLoginWarning", + "reinitializeVideoListView", + "renderSavedProfiles", + "refreshAllVideoGrids", + "resetPermissionPromptState", + "resetViewLoggingState", + "updateActiveProfileUI", + "updateCachedHashtagPreferences", + "updateShareNostrAuthState", + ]; + for (const name of inert) { + coord[name] = noop; + } + coord.normalizeHexPubkey = (x) => x; + coord.refreshUnreadDmIndicator = refreshUnreadDmIndicator; + coord.updateAuthLoadingState = (patch) => { + coord.authLoadingState = { ...(coord.authLoadingState || {}), ...patch }; + }; + coord.authLoadingState = { profile: "idle", lists: "idle", dms: "idle" }; + + return coord; +} + +test("login does not eagerly load DM history; it is deferred", async () => { + let loadDmCalls = 0; + let unreadIndicatorCalls = 0; + const coord = makeCoordinator({ + loadDirectMessages: async () => { + loadDmCalls += 1; + return { ok: true }; + }, + refreshUnreadDmIndicator: async () => { + unreadIndicatorCalls += 1; + }, + }); + + await coord.handleAuthLogin({ + pubkey: ACTIVE_PUBKEY, + identityChanged: true, + postLogin: { profile: { name: "tester" } }, + postLoginPromise: Promise.resolve({ profile: { name: "tester" } }), + }); + + // Let every post-login promise chain (profile -> dm/nwc, feed sync) settle. + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal( + loadDmCalls, + 0, + "login must NOT eagerly load DM history (it competes with feed lists for the nip-07 channel)", + ); + assert.equal( + coord.authLoadingState.dms, + "deferred", + "DM loading state should be reported as deferred at login", + ); + assert.ok( + unreadIndicatorCalls >= 1, + "the lightweight unread DM indicator should still run so the badge stays accurate", + ); +});