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 | |
| 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
| -rw-r--r-- | src/app.js | 44 | ||||
| -rw-r--r-- | src/features/messages.js | 18 | ||||
| -rw-r--r-- | tools/feed-boot-chrome-test.js | 113 |
3 files changed, 165 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(); diff --git a/tools/feed-boot-chrome-test.js b/tools/feed-boot-chrome-test.js new file mode 100644 index 0000000..91e5188 --- /dev/null +++ b/tools/feed-boot-chrome-test.js @@ -0,0 +1,113 @@ +// The feed opened by URL (/feed) paints as the restoring wallet's from its +// first frame. It painted as a visitor's — no post button, chips or nav — +// until the restore landed a beat later (the vault's scrypt, the wallet's +// state, every feature's init: ~0.5 s on a laptop, more on a phone), and +// the button "took a while to appear" (Adam, 2026-10-31). Boot now tells +// a wallet is about to open (a session list in the tab, a vault the last +// unlock opened without a password, watch-only accounts) before the work, +// and the page paints for it. A visitor with no wallet still gets none. +// Run: bun tools/feed-boot-chrome-test.js +import puppeteer from './puppeteer.js'; +import { buildHtml } from '../build.js'; +import { encryptVault } from '../src/wallet.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +let fails = 0; +const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); if (!ok) fails++; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5331, fetch: (req) => { + const p = new URL(req.url).pathname; + if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const mnemonic = generateMnemonic(wordlist); +const account = { id: 'boot-test', label: 'Boot', type: 'full', mnemonic, passphrase: '', network: 'regtest', persisted: true }; +const vault = encryptVault([{ label: account.label, mnemonic, passphrase: '', network: 'regtest' }], ''); + +// Every mutation batch gets a number; the first batch each piece of the page +// showed up in says whether it came with the page or after it. +const watch = () => { + window.__seen = {}; window.__batch = 0; + const SEL = { page: '.feed-page', fab: '.feed-post-fab', chips: '.feed-chips', nav: '.app-bottom-nav', side: '.wallet-side' }; + new MutationObserver(() => { + window.__batch++; + for (const [k, s] of Object.entries(SEL)) if (!(k in window.__seen) && document.querySelector(s)) { window.__seen[k] = { batch: window.__batch, at: Math.round(performance.now()) }; if (k === 'fab') window.__fab0 = document.querySelector(s); } + }).observe(document, { childList: true, subtree: true }); +}; +async function load(name, seed) { + const ctx = await browser.createBrowserContext(); // its own storage: each scenario starts clean + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(watch); + await page.evaluateOnNewDocument(seed, { account, vault }); + await page.goto('http://localhost:5331/feed', { waitUntil: 'domcontentloaded' }); + await sleep(3500); + const r = await page.evaluate(() => ({ + seen: window.__seen, errors: [], + fabSame: !!window.__fab0 && document.querySelector('.feed-post-fab') === window.__fab0, + chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim() + (c.classList.contains('on') ? '*' : '')), + fab: !!document.querySelector('.feed-post-fab'), nav: !!document.querySelector('.app-bottom-nav'), + walletOpen: !!document.querySelector('.app-nav-profile, .chat-avatar'), + blankNote: localStorage.getItem('btc-wallet-vault-blank'), + text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 120), + })); + r.errors = errors; + console.log(`[${name}] ${JSON.stringify(r.seen)} chips=${r.chips.join('|')}`); + await ctx.close(); + return r; +} +// with the page, or (the nav rides the boot shell too) before it +const withPage = (r, k) => r.seen[k] && r.seen.page && r.seen[k].batch <= r.seen.page.batch; + +try { + // 1. A reload: the tab's session list says which wallet is coming. + let r = await load('session', ({ account }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); + }); + check('session: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); + check('session: the chips paint with the page', withPage(r, 'chips')); + check('session: the nav paints with the page', withPage(r, 'nav')); + check('session: the wallet then opens behind it', r.fab && r.nav && r.walletOpen, r.text); + check('session: the button is the same node through the restore (no blink)', r.fabSame); + check('session: Popular stood for the visitor copy, then the wallet\'s own chips', r.chips.length >= 2 && r.chips.filter((c) => /Popular/.test(c)).length === 1, r.chips.join('|')); + check('session: no page errors', r.errors.length === 0, r.errors.join(' | ')); + + // 2. A cold start: a vault the last unlock opened without a password. + r = await load('vault', ({ vault }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); + localStorage.setItem('btc-wallet-vault-blank', '1'); + }); + check('vault: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); + check('vault: the nav paints with the page', withPage(r, 'nav')); + check('vault: the wallet opens behind it', r.fab && r.walletOpen, r.text); + check('vault: the same node through the scrypt restore', r.fabSame); + check('vault: no page errors', r.errors.length === 0, r.errors.join(' | ')); + + // 3. The same vault before any unlock left its note: boot can't tell, so + // the page paints as a visitor's and grows the button when the wallet + // opens — and the unlock leaves the note for next time. + r = await load('vault, no note', ({ vault }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); + }); + check('unnoted vault: the button still comes, after the page', r.fab && r.seen.fab && r.seen.page && r.seen.fab.batch > r.seen.page.batch, JSON.stringify(r.seen)); + check('unnoted vault: the unlock leaves the note', r.blankNote === '1'); + + // 4. A visitor: no wallet on this device, nothing to paint for. + r = await load('visitor', () => { localStorage.setItem('btc-wallet-network', 'regtest'); }); + check('visitor: the feed paints, without a post button', r.seen.page && !r.fab && !r.seen.fab, JSON.stringify(r.seen)); + check('visitor: no page errors', r.errors.length === 0, r.errors.join(' | ')); +} finally { + await browser.close(); + server.stop(true); +} +console.log(fails ? `\n❌ ${fails} failing` : '\n✅ the feed paints for the wallet that is coming'); +process.exit(fails ? 1 : 0); |
