aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-11 08:51:54 -0700
committerAdam Soltys <[email protected]>2026-09-11 08:51:54 -0700
commitf99a608465145271ef11d77e2451f520fbdc1dd3 (patch)
tree013887264c6e13ba418e81add32cd85fa95b8673
parentfb33e7de7de8c787ed8d906026f0e0e4270af33e (diff)
downloadcoinosv3-f99a608465145271ef11d77e2451f520fbdc1dd3.tar.gz
coinosv3-f99a608465145271ef11d77e2451f520fbdc1dd3.zip
Restore profile editor drafts across refreshes
-rw-r--r--src/app.js24
-rw-r--r--src/features/messages.js18
-rw-r--r--tools/profile-draft-test.js113
3 files changed, 144 insertions, 11 deletions
diff --git a/src/app.js b/src/app.js
index e53a7d7..57b947d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -614,7 +614,7 @@ wallet.subscribe(scheduleRender);
// views — most Spending payments open one of these, and leaving them out
// meant the phone's native Back skipped past the history list entirely
// (it landed on whatever tab minted the previous entry).
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -624,9 +624,15 @@ function navSnapshot() {
if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed };
if (ui.userSearch) s.userSearch = { q: '', rows: null };
if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' };
+ // Keep the editable fields in this history entry so a refresh restores the
+ // draft. Copy them: later keystrokes must not mutate earlier snapshots.
+ s.profEdit = ui.profilePk && ui.profEdit ? Object.fromEntries(
+ ['uname', 'name', 'about', 'picture', 'banner'].map(k => [k, ui.profEdit[k] || ''])) : null;
+ if (s.profEdit) s.profEdit.touched = { ...ui.profEdit.touched };
+ s.profEditFilled = !!s.profEdit && !!ui.profEditFilled;
return s;
}
-const navSig = (s) => JSON.stringify(s);
+const navSig = ({ profEdit, profEditFilled, ...s }) => JSON.stringify({ ...s, profEdit: !!profEdit });
let navStack = []; // in-memory mirror of the history entries (to detect an in-app Back)
let navIndex = -1;
let restoringHistory = false; // true while applying a popstate (suppresses pushing)
@@ -636,7 +642,15 @@ function syncHistory() {
try {
const snap = navSnapshot();
const sig = navSig(snap);
- if (navIndex >= 0 && sig === navSig(navStack[navIndex])) return; // no navigation change
+ if (navIndex >= 0 && sig === navSig(navStack[navIndex])) {
+ // Typing changes the draft, not the page. Replace the current entry
+ // instead of making Back walk through every version of the form.
+ if (JSON.stringify(snap) !== JSON.stringify(navStack[navIndex])) {
+ history.replaceState({ nav: snap, i: navIndex }, '');
+ navStack[navIndex] = snap;
+ }
+ return;
+ }
// Every screen change is a new history entry. (We deliberately don't try to
// detect in-app "back" navigations — an A→B→A pattern is indistinguishable
// from a genuine back, so guessing corrupts the stack. An in-app back just
@@ -659,7 +673,7 @@ function restoreNavFromHistory() {
try {
const nav = BOOT_NAV;
if (!nav || ui.screen !== 'wallet') return;
- for (const f of NAV_FIELDS) if (f in nav) ui[f] = nav[f];
+ for (const f of NAV_FIELDS) if (f in nav) ui[f] = f === 'profEdit' && nav[f] ? structuredClone(nav[f]) : nav[f];
navStack = [nav];
navIndex = 0;
history.replaceState({ nav, i: 0 }, ''); // undo any boot-render clobber
@@ -672,7 +686,7 @@ window.addEventListener('popstate', (e) => {
const snap = (st && st.nav) || navSnapshot();
restoringHistory = true;
try {
- for (const f of NAV_FIELDS) ui[f] = f in snap ? snap[f] : null;
+ for (const f of NAV_FIELDS) ui[f] = f === 'profEdit' && snap[f] ? structuredClone(snap[f]) : f in snap ? snap[f] : null;
if (st && typeof st.i === 'number') navIndex = st.i;
else { const i = navStack.findIndex((s) => navSig(s) === navSig(snap)); if (i >= 0) navIndex = i; }
render();
diff --git a/src/features/messages.js b/src/features/messages.js
index a01c063..e24c3f9 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2661,6 +2661,8 @@ export function messagesFeature(ctx) {
function profileScreen() {
const pk = ui.profilePk;
const mine = isMe(pk) || (ctx.shownPubkey && pk === ctx.shownPubkey());
+ // History can outlive an account switch; only restore our own editor.
+ if (!mine && ui.profEdit) { ui.profEdit = null; ui.profEditFilled = false; }
const logoutBtn = () => h('button', {
class: 'btn-block', style: 'color:var(--red,#c0392b);display:flex;align-items:center;justify-content:center;gap:8px',
onClick: () => { ui.logoutConfirm = true; render(); },
@@ -2755,10 +2757,9 @@ export function messagesFeature(ctx) {
if (mine && ui.profEdit && !ui.profEditFilled && full !== undefined) {
ui.profEditFilled = true;
const e = ui.profEdit;
- if (!e.name) e.name = full.display_name || full.name || '';
- if (!e.about) e.about = full.about || '';
- if (!e.picture) e.picture = full.picture || '';
- if (!e.banner) e.banner = full.banner || '';
+ if (!e.name && !e.touched?.name) e.name = full.display_name || full.name || '';
+ for (const key of ['about', 'picture', 'banner'])
+ if (!e[key] && !e.touched?.[key]) e[key] = full[key] || '';
}
const name = displayName(pk);
const npub = npubOf(pk) || pk;
@@ -2770,7 +2771,11 @@ export function messagesFeature(ctx) {
// render per keystroke so the page-top preview (name/picture/banner)
// tracks the draft live — the morph never rewrites a focused field,
// so this can't fight the typing
- onInput: (ev) => { ui.profEdit[key] = ev.target.value; render(); },
+ onInput: (ev) => {
+ ui.profEdit[key] = ev.target.value;
+ (ui.profEdit.touched ||= {})[key] = true;
+ render();
+ },
}));
// A lightning address worth showing: not an npub-shaped machine address
// (npub1…@some.relay duplicates the npub below) and not the same string
@@ -2844,7 +2849,8 @@ export function messagesFeature(ctx) {
onInput: (ev) => {
ui.profEdit.uname = ev.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, '');
ev.target.value = ui.profEdit.uname;
- if (ui.profEdit.migrationName) { ui.profEdit.migrationName = null; render(); }
+ ui.profEdit.migrationName = null;
+ render();
},
}),
h('span', { class: 'muted', style: 'white-space:nowrap;padding:0 8px' }, '@' + myAddr.split('@')[1]))) : null,
diff --git a/tools/profile-draft-test.js b/tools/profile-draft-test.js
new file mode 100644
index 0000000..243f8ec
--- /dev/null
+++ b/tools/profile-draft-test.js
@@ -0,0 +1,113 @@
+// The actual profile editor must keep its draft across repeated refreshes
+// without adding a browser-history entry for each keystroke.
+// Run: bun tools/profile-draft-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { seedPubkey } from '../src/nostr.js';
+import { buildHtml } from '../build.js';
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const origin = `http://localhost:${server.port}`;
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', e => errors.push(e.message));
+const click = async label => {
+ await page.waitForFunction(label => [...document.querySelectorAll('button')].some(b => b.textContent.trim().toLowerCase() === label.toLowerCase()), {}, label);
+ const button = await page.evaluateHandle(label => [...document.querySelectorAll('button')].find(b => b.textContent.trim().toLowerCase() === label.toLowerCase()), label);
+ await button.asElement().click();
+ await button.dispose();
+ await new Promise(r => setTimeout(r, 400));
+};
+const fields = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('label.field')]
+ .filter(l => l.querySelector('input,textarea')).map(l => [l.querySelector('.lab')?.textContent, l.querySelector('input,textarea').value])));
+const edit = (label, value) => page.evaluate(({ label, value }) => {
+ const el = [...document.querySelectorAll('label.field')].find(l => l.querySelector('.lab')?.textContent === label)?.querySelector('input,textarea');
+ if (!el) throw new Error('missing field: ' + label);
+ el.value = value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+}, { label, value });
+const editor = () => page.waitForSelector('label.field textarea');
+try {
+ // Keep this generated test identity local: no relay traffic or API writes.
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', req => req.url().startsWith(origin) || req.url().startsWith('data:') ? req.continue() : req.abort());
+ await page.goto(origin);
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await click('Get started');
+ await click('Import existing');
+ await page.waitForSelector('textarea');
+ const mnemonic = generateMnemonic(wordlist);
+ await page.type('textarea', mnemonic);
+ await click('Open wallet');
+ await page.waitForSelector('.header-avatar');
+ const pk = seedPubkey(mnemonic, '', 0);
+ await page.evaluate(pk => {
+ for (const base of Object.keys(localStorage).filter(k => /^btc-wallet-cache:[0-9a-f]+$/.test(k))) {
+ localStorage.setItem(base + ':profiles', JSON.stringify({ [pk]: { name: 'Published name', t: Date.now() } }));
+ localStorage.setItem(base + ':names', JSON.stringify({ name: 'published', domain: 'coinos.io' }));
+ }
+ }, pk);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.header-avatar');
+ await page.click('.header-avatar');
+ await click('Edit profile');
+ await editor();
+ const historyLength = await page.evaluate(() => history.length);
+ const initial = await fields();
+ const displayLabel = Object.keys(initial).find(k => /display|^name$/i.test(k));
+ await edit(displayLabel, 'Draft ✨');
+ await edit('About', 'An unfinished bio\nwith a second line');
+ await edit('Picture URL', origin + '/draft-picture.webp');
+ await edit('Cover photo URL', origin + '/draft-cover.webp');
+ await edit('Username', 'draftusername');
+ const expected = await fields();
+ assert.equal(await page.evaluate(() => history.length), historyLength);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await editor();
+ assert.deepEqual(await fields(), expected);
+ console.log('✓ refreshing keeps the editor and all five draft fields');
+
+ await edit('About', '');
+ await edit('Username', 'secondedit');
+ const second = await fields();
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await editor();
+ assert.deepEqual(await fields(), second);
+ console.log('✓ edits after restoration and intentionally empty fields survive another refresh');
+
+ await click('Cancel');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => [...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'Edit profile'));
+ assert.equal(await page.$('label.field textarea'), null);
+ await click('Edit profile');
+ await editor();
+ assert.equal((await fields()).Username, 'published');
+ assert.equal((await fields())[displayLabel], 'Published name');
+ console.log('✓ cancel leaves the editor closed after refresh and discards the draft');
+
+ await page.evaluate(() => {
+ const st = history.state;
+ st.nav.profilePk = 'b'.repeat(64);
+ history.replaceState(st, '');
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.profile-avatar');
+ assert.equal(await page.$('label.field textarea'), null);
+ assert.deepEqual(errors, []);
+ console.log('✓ a restored editor cannot appear on another identity’s profile');
+} catch (err) {
+ console.log('Profile test screen:', (await page.evaluate(() => document.body.innerText)).slice(0, 1600));
+ console.log('Page errors:', errors);
+ throw err;
+} finally {
+ await browser.close();
+ server.stop(true);
+}