diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 44 | ||||
| -rw-r--r-- | src/features/messages.js | 84 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/nostr.js | 55 | ||||
| -rw-r--r-- | src/style.css | 1 |
5 files changed, 161 insertions, 24 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..e1468ce 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ import { mediaServers } from '../media-servers.js'; // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, + subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.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'; @@ -6940,7 +6947,7 @@ export function messagesFeature(ctx) { if (!url || mediaReady.has(url)) return Promise.resolve(); const known = videoDims.get(url) || dim; if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); } - if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve(); + if (typeof document === 'undefined' || videoUnknown.has(url) || clipGone(url)) return Promise.resolve(); // (gone: nothing to measure) if (mediaWarming.has(url)) return mediaWarming.get(url); // the 64 KB probe first: it reads the shape from the file's own header // and costs nothing like a player's metadata load of an index-last file @@ -7985,7 +7992,7 @@ export function messagesFeature(ctx) { // it (with sound — the reader asked for this one). The box is left alone // by the morph from here, or a repaint would take the cover off. function heavyCover(box, url, onPlay) { - if (box.querySelector('.vid-heavy')) return; + if (box.querySelector('.vid-heavy') || box.classList.contains('gone')) return; box._skipMorph = true; const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'), onClick: (e) => { @@ -8089,13 +8096,34 @@ export function messagesFeature(ctx) { const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url : streamsHere(url) ? hlsOf(url) : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A clip whose file its host no longer has: the player is the first to + // know (Primal's blossom answers a 404 with no CORS headers, so the + // probe couldn't read it and the box sat as a dead "Tap to play" — a + // post whose video "won't play", Adam, 2026-11-01). It says so where the + // player stood, with the file's link, and the grid leaves the clip out + // for a day, as it does a 404 the probe could read. The copy is tried + // first: a file the browser can't decode fails the same way, and the + // copy plays that one. + function clipDead(v, url) { + noteVideoInfo(url, { goneAt: Date.now() }); + v._gone = true; + v.controls = false; + const box = v.parentElement; + if (!box) return; + box._skipMorph = true; + box.classList.add('gone'); + for (const el of box.querySelectorAll('.vid-heavy, .vid-pp, .vid-sound')) el.remove(); + if (!box.querySelector('.vid-nopic')) box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoGone')), + h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile')))); + } // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. + // One whose original won't load at all tries the copy too; the copy + // failing as well means the file is gone (clipDead). function guardPicture(v, url) { - if (!vmediaOk(url)) return; const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA); - v.addEventListener('loadedmetadata', () => { + if (vmediaOk(url)) v.addEventListener('loadedmetadata', () => { if (v.videoWidth || onCopy() || h264Failed.has(url)) return; noPicture.add(url); const want = !v.paused; @@ -8103,7 +8131,18 @@ export function messagesFeature(ctx) { if (want) v.play().catch(() => {}); }); v.addEventListener('error', () => { - if (!onCopy()) return; + if (!v.getAttribute('src')) return; // let go of (scrolled off), not failed + if (!onCopy()) { + if (vmediaOk(url) && !h264Failed.has(url) && !v._origFailed) { + v._origFailed = true; + const want = !v.paused; + v.setAttribute('src', h264Of(url)); + if (want) v.play().catch(() => {}); + return; + } + clipDead(v, url); + return; + } // the stream failed: the copy or the original, as it would have been if (isHls(v.getAttribute('src'))) { hlsFailed.add(url); @@ -8113,6 +8152,7 @@ export function messagesFeature(ctx) { return; } h264Failed.add(url); + if (v._origFailed) { clipDead(v, url); return; } // neither the file nor a copy of it: gone const want = !v.paused; v.setAttribute('src', url); if (want) v.play().catch(() => {}); @@ -8143,7 +8183,7 @@ export function messagesFeature(ctx) { // — a second seek bar — stay off; the middle button and the sound button // are its controls then (Adam: "hide the main seek bar … and only show a // moments seek bar"). Otherwise the player's controls, once it has woken. - const nativeControls = (v) => { v.controls = !v._ownBar; }; + const nativeControls = (v) => { v.controls = !v._ownBar && !v._gone; }; function ownBar(player, on) { const box = player.closest('.note-video-box'); if (!box || player._ownBar === on) return; @@ -8237,8 +8277,7 @@ export function messagesFeature(ctx) { // learned now, used by the next paint of any row with this clip onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); }, // a touch's tap was taken at pointerup (below): its click, if one comes, is spent - onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, - onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); + onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); } }); guardPicture(v, url); // With its own controls up, Chrome on Android keeps a tap on the clip for // them (it shows or hides the bar) and the page never gets the click — so @@ -8321,6 +8360,7 @@ export function messagesFeature(ctx) { () => { box._inView = true; clearTimeout(box._release); + if (v._gone) return; // nothing to load, and nothing to ask if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) { v.setAttribute('src', playSrc(url)); v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true }); @@ -8349,6 +8389,9 @@ export function messagesFeature(ctx) { try { v.removeAttribute('src'); v.load(); } catch {} }, 2500); }); + // known gone already (this player, or one before it, found the file + // missing): said at once, no request made + if (clipGone(url)) clipDead(v, url); return box; } @@ -13697,10 +13740,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 +13825,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 +16316,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(); @@ -18465,10 +18511,14 @@ export function messagesFeature(ctx) { resumed(awayMs) { // a blink between apps didn't kill anything if (awayMs && awayMs < 5_000) return; - resubscribeStreams(); // DMs and rooms, whether or not chat is on screen - if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true; - if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); - else refreshFeed({ force: true }).catch(() => {}); + // the sockets first: after a freeze they read open and are dead, and + // a refresh sent on them finds nothing (wakeRelays) + wakeRelays().catch(() => {}).then(() => { + resubscribeStreams(); // DMs and rooms, whether or not chat is on screen + if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return; + if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); + else refreshFeed({ force: true }).catch(() => {}); + }); return true; }, // A zap flow reporting how its payment went, so the chip that appeared diff --git a/src/i18n.js b/src/i18n.js index 636362e..fdcd1e1 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -303,6 +303,7 @@ const STR = { pagerPrevPic: 'Previous picture', pagerNextPic: 'Next picture', videoNoPicture: "This browser can’t show this video’s picture", + videoGone: "This video couldn’t be loaded from its host", videoOpenFile: "Open the file", wnDevicesTitle: 'Group invitations', wnDevicesHelp: 'White Noise and other Marmot apps invite one of your devices, and only that device gets the group. Keep the invitations coming to the device you actually use.', diff --git a/src/nostr.js b/src/nostr.js index c77fc27..f0b47d1 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -186,7 +186,12 @@ pool.ensureRelay = async (url, params) => { relay._coinosRL = true; const notice = relay.onnotice; relay.onnotice = (msg) => { - if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); + // Primal's "too many concurrent REQs" is advice, not a refusal: every + // REQ past its twenty is answered all the same (measured: 60 at once, + // 60 EOSEs, 12 notices). Cooling it off for 30 s at every boot — when + // the relay lists, profiles and the feed all ask at once — only lost + // us its answers. A CLOSED with that reason still counts (below). + if (RATE_LIMITED.test(String(msg)) && !/concurrent/i.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); try { notice?.call(relay, msg); } catch {} }; const subscribe = relay.subscribe.bind(relay); @@ -284,6 +289,54 @@ export function resetRelay(url) { try { r.close(); } catch {} pool.relays.delete(n); } +// Back from a freeze (a phone's backgrounded tab: timers stopped, the +// network's idea of every socket long gone), the pool's sockets still read +// OPEN, and anything sent on them vanishes. Left to itself the library +// notices one in up to 29 s (its ping) + 20 s (the ping's timeout), then +// waits 10 s more before redialing — a minute of a feed that won't catch up +// ("no new notes since 11:35?", Adam, 2026-11-01). So on resume every open +// socket is asked one tiny REQ; one that answers within the deadline is +// fine, one that doesn't is closed and redialed now, its subscriptions +// refired by the library with `since` set to the last event each saw. A +// relay already waiting out a reconnect backoff is dialed now too. +const WAKE_PING_MS = 2500; +const DUMMY_ID = 'a'.repeat(64); +function pingRelay(r, ms) { + return new Promise((resolve) => { + let done = false; + const fin = (ok) => { if (done) return; done = true; clearTimeout(t); resolve(ok); }; + const t = setTimeout(() => fin(false), ms); + try { + const sub = r.subscribe([{ ids: [DUMMY_ID], limit: 0 }], { + label: '<wake-ping>', + oneose: () => { fin(true); try { sub.close(); } catch {} }, + onclose: () => fin(true), // a relay refusing the REQ is a relay that answers + eoseTimeout: ms + 1000, // ours fires first: the library's would count as an EOSE + }); + } catch { fin(false); } + }); +} +function redialRelay(r, why) { + console.warn('nostr: ' + r.url + ' ' + why + ' — redialing'); + // the dead socket's close would reach the library long after (a closing + // handshake nobody answers), and undo the new connection: detached first + const ws = r.ws; + try { if (ws) { ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); } } catch {} + try { r.handleHardClose(why); } catch {} // the library's own path: subscriptions kept, a reconnect queued + if (r.reconnectTimeoutHandle) { clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; } + r.connect().catch(() => {}); // now, not after the backoff +} +export async function wakeRelays(ms = WAKE_PING_MS) { + const checks = []; + for (const r of pool.relays.values()) { + if (r.connected && r.ws && r.ws.readyState === 1) checks.push(pingRelay(r, ms).then((ok) => { if (!ok) redialRelay(r, 'silent after resume'); })); + else if (!r.connectionPromise && r.reconnectTimeoutHandle) { + clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; + r.connect().catch(() => {}); + } + } + await Promise.all(checks); +} // the relays a subscription on this list actually dials (sick ones skipped) export const liveRelayList = (list) => liveRelays(list); // ...and the ones a one-shot read goes to (rate-limited ones skipped too) diff --git a/src/style.css b/src/style.css index f26fac3..cef3663 100644 --- a/src/style.css +++ b/src/style.css @@ -1846,6 +1846,7 @@ body::after { color: #fff; font-size: 14px; text-align: center; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); } .vid-nopic a { color: #fff; text-decoration: underline; pointer-events: auto; } +.note-video-box.gone .note-video { max-height: 160px !important; aspect-ratio: 16 / 9; } .media-tile-cost { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, .8); font-size: 12px; font-weight: 600; background: #16181d; |
