diff options
| author | Adam Soltys <[email protected]> | 2026-10-31 16:53:13 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-31 16:53:13 -0700 |
| commit | 0fbad83ed278c2cdf0c03547f60004ff37bcc262 (patch) | |
| tree | 9434a58bf71620a37c4a237d1271081b2da10283 /src | |
| parent | 8fdc99dbb6d4aa720b5c36e2a565b0a76fa81fdc (diff) | |
| download | coinosv3-0fbad83ed278c2cdf0c03547f60004ff37bcc262.tar.gz coinosv3-0fbad83ed278c2cdf0c03547f60004ff37bcc262.zip | |
The feed opened by URL paints as the restoring wallet's from its first frame: the Post button, the feed chips and the nav come with the page, not a beat after it. /feed is a public page, so it painted as a visitor's (no button, chips or nav) the moment the app ran, and the wallet's restore — synchronous work: the vault's scrypt on a cold start, the wallet's state, every feature's init, ~0.5 s on a laptop and more on a phone — re-painted it with the button only once it had run ("the post button takes a while to appear", Adam, 2026-10-31). Boot now tells before that work whether a wallet is about to open (a session list in the tab, a vault the last seal or unlock opened without a password — noted, since the vault alone can't say short of the scrypt being avoided — or watch-only accounts) and the feed, the nav and the chips paint for it; taps still go by the wallet actually being open, which it is by the time one lands. The wide-screen wallet column waits for the numbers. A visitor with no wallet on the device gets the visitor's page as before. tools/feed-boot-chrome-test.js boots the built app at /feed behind a session, a noted vault, an unnoted vault (the old order, and the unlock leaves the note) and as a visitorHEADmaster
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 44 | ||||
| -rw-r--r-- | src/features/messages.js | 18 |
2 files changed, 52 insertions, 10 deletions
@@ -585,6 +585,14 @@ let _publicChatMode = false; // welcome screen is the one screen that must never be guessed at. The boot // shell holds the frame for those few ms instead; see the dispatch below. let _bootDeciding = true; +// True until boot's restore has run. The feed is a public page: opened by +// URL it paints before the wallet restores, and it painted as a visitor's +// (no post button, chips or nav) until the restore landed a beat later — +// synchronous work (the vault's scrypt, the wallet's state, every feature's +// init), ~0.5 s on a laptop and more on a phone (Adam, 2026-10-31). When +// boot can already tell a wallet is about to open (restorePending), the +// page paints as that wallet's from its first frame. +let _restorePending = true; // Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page // starts): the app notes the phase it reaches, and clears the count once it // has lived eight seconds or leaves cleanly. A page iOS kills while opening @@ -597,7 +605,7 @@ addEventListener('pagehide', bootSettled); // Belt and braces: a boot path that returns early (or throws) must not leave // the welcome screen held back forever. Nothing legitimate is still deciding // four seconds in — the store wait alone caps at 800ms. -setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); +setTimeout(() => { _restorePending = false; if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); // A picture, full screen. Posts show images cropped to a few hundred pixels // so a feed stays a feed; this is where you actually look at one. Tap // anywhere (or Escape, or Back) to close — no chrome, nothing to aim at. @@ -859,10 +867,12 @@ function renderInner() { // The bottom nav rides every page of a signed-in wallet and a visitor's // public pages — never over sign-in, onboarding or the lock prompt. const navPage = ui.pubProf || (activeAccount() && ['wallet', 'accounts', 'accountSettings'].includes(ui.screen) && !(ui.onb || onbInProgress())); - const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null; + // (a wallet about to restore gets its nav with the page, not after) + const bottomNav = navPage && !lockAsk && (!_bootDeciding || restorePending()) ? featureHook('bottomNav') : null; if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo())); - // the wallet beside the feed and chats on a wide screen (its own page needs no twin) - const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; + // the wallet beside the feed and chats on a wide screen (its own page needs + // no twin; a wallet still restoring has no numbers to show yet) + const side = bottomNav && featScreen && !restorePending() && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; // a call, over whatever page is up (the messages feature's) const callOv = activeAccount() ? featureHook('callOverlay') : null; morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); @@ -2129,6 +2139,23 @@ function switchAccount(id) { // Restore accounts after a refresh (sessionStorage); on a fresh session, prompt // to unlock the encrypted vault if there is one, else seed from watch-only // accounts. Returns true if it handled the entry (opened or showed a prompt). +// Whether restoreAccountsState() is about to open a wallet, read from what +// it reads but without its work: a session list in this tab, a vault the +// last unlock opened without a password, or watch-only accounts. The vault +// alone can't say whether a blank password opens it short of the scrypt +// that is the cost being avoided, so the last seal or unlock leaves a note. +const VAULT_BLANK_KEY = 'btc-wallet-vault-blank'; +const noteVaultBlank = (password) => { try { if (password === '') localStorage.setItem(VAULT_BLANK_KEY, '1'); else localStorage.removeItem(VAULT_BLANK_KEY); } catch {} }; +const vaultOpensBlank = () => { try { return localStorage.getItem(VAULT_BLANK_KEY) === '1'; } catch { return false; } }; +function bootWillRestore() { + if (!unlockDeadlinePassed()) { + try { const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); if (s && Array.isArray(s.accounts) && s.accounts.length) return true; } catch {} + } + if (hasVault()) return vaultOpensBlank(); + return loadWatchAccounts().length > 0; +} +const restorePending = () => _restorePending && bootWillRestore(); + function restoreAccountsState() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} @@ -2180,6 +2207,7 @@ const passkeySecret = (pw) => typeof pw === 'string' && pw.startsWith('passkey:' const vaultPasskey = () => (loadVaultBlob() || {}).passkey || null; function sealVault(list, password) { const b = encryptVault(list, password); + noteVaultBlank(password); if (passkeySecret(password) && vaultPasskeyId) b.passkey = vaultPasskeyId; return b; } @@ -2335,7 +2363,7 @@ function submitPw() { let list; try { list = decryptVault(loadVaultBlob(), p.v0 || ''); } catch { p.error = t('pwWrong'); render(); return; } if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } - try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); } catch {} + try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); noteVaultBlank(p.v1 || ''); } catch {} if (vaultPassword != null) vaultPassword = p.v1 || ''; // keep the unlocked session in sync ui.pw = null; render(); @@ -2476,6 +2504,7 @@ async function submitPwWithPasskey() { function attemptVaultUnlock(password) { let list; try { list = decryptVault(loadVaultBlob(), password); } catch { return false; } + noteVaultBlank(password); vaultPassword = password; vaultPasskeyId = passkeySecret(password) ? vaultPasskey() : null; // Start from the durable directory (watch-only views of every known wallet), @@ -6476,6 +6505,8 @@ const ctx = { // support) — lazy so it resolves against the final FEATURES list, and a // build without the target feature simply gets null back hook: (name, ...args) => featureHook(name, ...args), + // boot is about to open a wallet (a page may paint as that wallet's already) + restorePending, // full sign-out (distinct from the padlock's lock-in-place): clear the // session and leave — offering a password if none protects the vault yet logout: () => { @@ -6924,7 +6955,8 @@ loadLocale(getLang()).finally(async () => { try { navigator.storage?.persist?.().catch(() => {}); } catch {} _bootDeciding = false; // from here on the screen is a decision, not a guess const opened = restoreAccountsState(); - if (!opened) render(); + _restorePending = false; + if (!opened || !activeAccount()) render(); bootPhase('ui'); setTimeout(bootSettled, 8000); if (WAKE_PAYLOAD != null) { diff --git a/src/features/messages.js b/src/features/messages.js index 478e054..f634866 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6307,6 +6307,13 @@ export function messagesFeature(ctx) { // ---- feeds anyone can open -------------------------------------------- // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; + // Whose page is painted: a visitor's (no wallet on this device), or the + // wallet's that boot is about to restore (ctx.restorePending). The feed, + // reached by URL, paints before that restore lands; painted as a visitor's + // it had no post button, chips or nav until the wallet opened a beat later + // (Adam, 2026-10-31). Taps go by isVisitor(): by the time one lands the + // restore, synchronous, has run. + const visitorView = () => isVisitor() && !(ctx.restorePending && ctx.restorePending()); const showFeed = (id) => { ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -13697,10 +13704,13 @@ export function messagesFeature(ctx) { function feedChips() { const items = feedList(); const cur = feedDef(); - if (cur && adhocFeeds.has(cur.id)) items.push(cur); + // (a wallet restoring behind /feed is on the visitor's copy of Popular + // for a beat: the built-in chip stands for it, not a second Popular) + const onVisitorCopy = curFeedId === 'all:visitor'; + if (cur && adhocFeeds.has(cur.id) && !onVisitorCopy) items.push(cur); return h('div', { class: 'row feed-chips', 'data-key': 'feed-chips' }, ...items.map((f) => h('button', { - class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, + class: 'feed-chip' + (f.id === curFeedId || (onVisitorCopy && f.id === EVERYTHING) ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, onClick: () => switchFeed(f.id), // someone's timeline carries the person, not a name — an empty // label left a blank black pill @@ -13779,7 +13789,7 @@ export function messagesFeature(ctx) { }, 0); } function appBottomNav() { - const visitor = isVisitor(); + const visitor = visitorView(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; // a stream (or Go live) over the shell is a place of its own: no button // is "here", so the one tapped goes there (Feed under a stream thought it @@ -16270,7 +16280,7 @@ export function messagesFeature(ctx) { const c = feedNow(); c.opened = true; const def = feedDef(); - const visitor = isVisitor(); + const visitor = visitorView(); queueMicrotask(() => { syncMediaPager(); if (listView()) watchSeen(); |
