aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-31 16:53:13 -0700
committerAdam Soltys <[email protected]>2026-10-31 16:53:13 -0700
commit0fbad83ed278c2cdf0c03547f60004ff37bcc262 (patch)
tree9434a58bf71620a37c4a237d1271081b2da10283
parent8fdc99dbb6d4aa720b5c36e2a565b0a76fa81fdc (diff)
downloadcoinosv3-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.js44
-rw-r--r--src/features/messages.js18
-rw-r--r--tools/feed-boot-chrome-test.js113
3 files changed, 165 insertions, 10 deletions
diff --git a/src/app.js b/src/app.js
index f9e326a..69c6275 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);