aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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
Diffstat (limited to 'src')
-rw-r--r--src/app.js44
-rw-r--r--src/features/messages.js18
2 files changed, 52 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();