aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js81
-rw-r--r--src/features/messages.js89
-rw-r--r--src/i18n.js4
3 files changed, 135 insertions, 39 deletions
diff --git a/src/app.js b/src/app.js
index 3ed0b33..23a1d42 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1338,14 +1338,31 @@ function unitTag(cls = '') {
// ================================================================ UNLOCK
// The create/import card on its own — the unlock screen wraps it in the
// classic chrome, the onboarding wizard's "Get started" step shows it bare.
+// The front door's seed pages (first run, or "Sign into another account"):
+// no tabs, and the page's own Back steps through the create flow.
+function seedPages() {
+ return ui.screen === 'unlock' && !!((ui.onb && ui.onb.step === 'seed') || (ui.fromWallet && ui.addFlow));
+}
+// Back within the create flow: the check → the passphrase → the words → the
+// heads-up. False when there's no earlier step (the caller leaves the flow).
+const CREATE_PREV = { gen: 'intro', pass: 'gen', confirm: 'pass' };
+function createStepBack() {
+ if (ui.unlockTab !== 'create' || ui.entropyPage || !CREATE_PREV[ui.createStep]) return false;
+ ui.createStep = CREATE_PREV[ui.createStep];
+ ui.unlockError = '';
+ render();
+ return true;
+}
+
function unlockCard() {
// The entropy page takes the card over entirely — seed words, tabs and
// other controls step aside until Submit or Back.
if (ui.entropyPage) return h('div', { class: 'card col' }, entropyPage());
// Inside the first-run wizard the words are already on screen: no tabs,
// just a line underneath for whoever brought their own seed (and one back).
- const wizard = ui.screen === 'unlock' && ((ui.onb && ui.onb.step === 'seed') || (ui.fromWallet && ui.addFlow));
- const swap = wizard && ui.createStep !== 'confirm' ? h('button', {
+ const wizard = seedPages();
+ // only before the words are up: once they're shown, it was already offered
+ const swap = wizard && (ui.unlockTab === 'import' || ui.createStep === 'intro') ? h('button', {
class: 'linklike small', style: 'align-self:center',
onClick: () => {
clearSeedDrafts(); ui.unlockError = '';
@@ -1434,6 +1451,7 @@ function unlockScreen() {
h('h2', { class: 'onb-title' }, t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')),
unlockCard(),
ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => {
+ if (createStepBack()) return;
const from = ui.unlockTab === 'import' ? 'doors' : 'welcome';
clearSeedDrafts(); ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.unlockError = '';
ui.addStep = from; render();
@@ -1603,33 +1621,47 @@ function createPane() {
h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => copy(ui.draftMnemonic) },
h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>' }),
t('copy')),
- optionsPanel(),
h(
'button',
{
id: 'seed-next',
class: 'btn-primary btn-block',
- onClick: () => {
- // catch a mistyped passphrase here, while the field to fix it is
- // still on screen — the check only asks for one word
- if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
- ui.unlockError = t('passphraseMismatch'); render(); return;
- }
- ui.confirm = pickConfirm(words);
- ui.unlockError = '';
- ui.createStep = 'confirm';
- render();
- },
+ onClick: () => { ui.unlockError = ''; ui.createStep = 'pass'; render(); },
},
t('next')
)
);
}
+ // The passphrase, on a page of its own (Adam, 2026-11-07): what it is,
+ // that it's optional but recommended, and the field — a small "Add
+ // passphrase" button under the words was easy to miss.
+ if (ui.createStep === 'pass') {
+ const words = ui.draftMnemonic.split(' ');
+ const next = () => {
+ // a typo here IS a different wallet: caught while the fields are up
+ if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
+ ui.unlockError = t('passphraseMismatch'); render(); return;
+ }
+ ui.confirm = pickConfirm(words);
+ ui.unlockError = '';
+ ui.createStep = 'confirm';
+ render();
+ };
+ return h(
+ 'div',
+ { class: 'col' },
+ h('h3', { style: 'margin:0' }, t('passStepTitle')),
+ h('p', { style: 'margin:0' }, t('passStepBody', { n: words.length })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('passStepNote')),
+ ...passFields(),
+ h('button', { id: 'pass-next', class: 'btn-primary btn-block', onClick: next }, t(ui.passphrase ? 'next' : 'passStepNone')),
+ );
+ }
// The check: which word is #5? One tap from a few of the phrase's own
// words, or Skip (Adam, 2026-11-07) — no typing.
const q = ui.confirm;
- if (!q || !q.options) { ui.createStep = 'gen'; return createPane(); }
+ if (!q || !q.options) { ui.createStep = 'pass'; return createPane(); }
return h(
'div',
{ class: 'col' },
@@ -1647,7 +1679,8 @@ function createPane() {
}, w))),
q.wrong && q.wrong.length ? h('div', { class: 'notice err' }, t('quizWrong')) : null,
h('button', { id: 'seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); } }, t('skip')),
- h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'gen'; ui.unlockError = ''; render(); } }, t('quizBack')),
+ // outside the seed pages (no Back of their own), a way back to the words
+ seedPages() ? null : h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'pass'; ui.unlockError = ''; render(); } }, t('back')),
);
}
@@ -1753,22 +1786,28 @@ function optionsPanel() {
// already typed keeps it open.
if (!ui.passOpen && !ui.passphrase) {
return [h('button', {
- class: 'btn-ghost btn-sm', type: 'button', style: 'align-self:flex-start',
+ class: 'btn-block', type: 'button',
onClick: (e) => { e.preventDefault(); ui.passOpen = true; render(); },
}, t('addPassphrase'))];
}
+ return passFields({ label: true });
+}
+
+// The passphrase field (+ its confirmation once something is typed). The
+// create flow's own page explains it above; the import pane labels it here.
+function passFields({ label = false } = {}) {
const pass = h(
'label',
{ class: 'field' },
h('span', { class: 'lab' },
- t('passphraseOptional'),
+ t(label ? 'passphraseOptional' : 'passphrase'),
// People assume a password logs in or encrypts; a BIP39 passphrase does
// neither, and getting that wrong loses money — worth a sentence here.
h('button', {
class: 'linklike small', type: 'button', style: 'margin-left:8px',
onClick: (e) => { e.preventDefault(); ui.passInfoOpen = !ui.passInfoOpen; render(); },
}, t('passWhatsThis'))),
- ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')),
+ ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : label ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')) : null,
h(
'div',
{ class: 'input-group' },
@@ -1797,7 +1836,7 @@ function optionsPanel() {
class: 'mono-input',
autocomplete: 'off',
value: ui.confirmPass,
- onInput: (e) => (ui.confirmPass = e.target.value),
+ onInput: (e) => { ui.confirmPass = e.target.value; if (ui.unlockError && ui.confirmPass === ui.passphrase) { ui.unlockError = ''; render(); } },
})
)
: null;
@@ -4412,7 +4451,7 @@ function onboardScreen() {
title(t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')),
unlockCard(),
// the entropy page brings its own Back — one is enough
- ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')),
+ ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { if (createStepBack()) return; ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')),
]);
}
if (o.step === 'signin') {
diff --git a/src/features/messages.js b/src/features/messages.js
index 90bfaaf..6c4f5f8 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -760,14 +760,38 @@ export function messagesFeature(ctx) {
let allUnsubs = [];
let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt
+ // Is the page under the reader's finger, or still coasting from a flick?
+ // Background work (a repaint for a face that arrived, writing the profile
+ // cache) waits for it to settle: on Adam's Pixel a flick through the feed
+ // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued
+ // behind it (2026-11-07, "flick to scroll … not getting up to speed …
+ // clicking around feels sluggish"). Never longer than `most` ms, though:
+ // a slow, steady read must still see what arrived.
+ let pageMovedAt = 0, fingerDown = false;
+ if (typeof window !== 'undefined') {
+ window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true });
+ window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true });
+ for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true });
+ }
+ const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200;
+ const whenSettled = (fn, most = 1500, step = 150) => {
+ const until = Date.now() + most;
+ const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); };
+ go();
+ };
+
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
+ const since = Date.now();
const run = () => {
// not under a pager in motion: the page beneath is out of sight, and
// its repaint (40–55 ms on Adam's phone) dropped frames from flights
// through the pictures (2026-10-17) — it waits for the landing
if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; }
+ // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph
+ // in the middle of a fling is a dropped frame and a late tap
+ if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
@@ -826,11 +850,14 @@ export function messagesFeature(ctx) {
// memory and is written back once per burst: a batch of thirty profiles
// landing during a scroll used to parse and re-serialize the whole blob
// thirty times over, on the main thread, mid-scroll.
- let profBlob = null, profFlush = null;
+ let profBlob = null, profFlush = null, profFlushSince = 0;
const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room
const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {}));
- function flushProfiles() {
- profFlush = null;
+ function flushProfiles(force) {
+ // up to 5 s for the page to settle: stringifying this blob (thumbnails
+ // and all, up to ~2 MB) is a long main-thread task
+ if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; }
+ profFlush = null; profFlushSince = 0;
const s = profBlob;
if (!s) return;
// Who stays when space runs out: your own faces, the people you follow,
@@ -848,7 +875,10 @@ export function messagesFeature(ctx) {
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen strangers give theirs up first. The row itself
// stays — that face just paints the way it used to.
- let size = JSON.stringify(s).length;
+ // (an estimate, not a JSON.stringify of the lot just to measure it:
+ // a row is ~430 chars plus its thumbnail)
+ let size = 0;
+ for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length;
if (size > THUMB_BUDGET) {
// never our own: the header face is the one that must paint offline
for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) {
@@ -875,7 +905,7 @@ export function messagesFeature(ctx) {
? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
- if (!profFlush) profFlush = setTimeout(flushProfiles, 500);
+ if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); }
}
// A punk picture is OUR OWN art. Every coinos user who keeps the default
// publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and
@@ -1081,6 +1111,39 @@ export function messagesFeature(ctx) {
blobProbing.set(url, task);
return task;
}
+ // Centre-crop to a square (the way background-size:cover paints it),
+ // shrink, and encode — off the main thread where the browser allows: the
+ // crop and resize through createImageBitmap, the encode through an
+ // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it
+ // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07).
+ async function encodeThumb(bmp) {
+ const side = Math.min(bmp.width, bmp.height);
+ if (!side) throw new Error('empty');
+ const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2;
+ if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) {
+ let small = null;
+ try {
+ small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' });
+ const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX);
+ oc.getContext('2d').drawImage(small, 0, 0);
+ const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); });
+ let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 });
+ if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 });
+ if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder
+ const data = await asUrl(blob);
+ if (data.length > THUMB_MAX) throw new Error('too big');
+ return data;
+ } finally { try { small && small.close(); } catch {} }
+ }
+ const c = document.createElement('canvas');
+ c.width = c.height = THUMB_PX;
+ c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX);
+ let data = c.toDataURL('image/webp', 0.75);
+ if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
+ if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5);
+ if (data.length > THUMB_MAX) throw new Error('too big');
+ return data;
+ }
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway
@@ -1152,18 +1215,8 @@ export function messagesFeature(ctx) {
scheduleRepaint(); // the big face, where one is up, can paint from the copy now
}
bmp = await createImageBitmap(await res.blob());
- // centre-crop to a square, the way background-size:cover paints it
- const side = Math.min(bmp.width, bmp.height);
- if (!side) throw new Error('empty');
- const c = document.createElement('canvas');
- c.width = c.height = THUMB_PX;
- c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2,
- side, side, 0, 0, THUMB_PX, THUMB_PX);
- let data = c.toDataURL('image/webp', 0.75);
- if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
- if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder
- if (data.length > THUMB_MAX) throw new Error('too big');
- done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX });
+ const data = await encodeThumb(bmp);
+ whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }));
} catch (e) {
// a quick TypeError is the CORS refusal (a dead host takes longer)
// only a refusal corsFetch confirmed (a body cut off mid-download
@@ -19062,7 +19115,7 @@ export function messagesFeature(ctx) {
follows = null; followsAt = 0; feed = null; relayLists = null;
mutes = null; mutesAt = 0;
stCache = null; stAt = 0; // the next account's state, not this one's
- if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
+ if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped
profBlob = null;
timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
diff --git a/src/i18n.js b/src/i18n.js
index a985ff9..b45638e 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -201,6 +201,10 @@ const STR = {
seedIntroPrivate: 'Anyone who sees them can take your coins, so make sure no one is looking at your screen.',
seedIntroShow: 'Show seed phrase',
next: 'Next',
+ passStepTitle: 'Add a passphrase?',
+ passStepBody: 'A passphrase is an extra secret you choose yourself, on top of your {n} words. Anyone who finds your seed phrase still can’t open your wallet without it.',
+ passStepNote: 'It’s optional, but recommended. Write it down with your seed phrase: you’ll need both to recover your wallet, exactly as written, and nobody can reset it for you.',
+ passStepNone: 'Continue without one',
skip: 'Skip',
quizPrompt: 'Which one is word #{n}?',
quizWrong: 'Not that one. Check what you wrote down.',