aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 16:36:25 -0700
committerAdam Soltys <[email protected]>2026-10-01 16:36:42 -0700
commit62b6b80d9c8d03d6caadb0648059ba94e34f8b1d (patch)
tree5b000d01723975b091fdaa1bda20e6ff84139dd8
parentbf42c148664cc97c6b3fd0c66014883de875adc7 (diff)
downloadcoinosv3-62b6b80d9c8d03d6caadb0648059ba94e34f8b1d.tar.gz
coinosv3-62b6b80d9c8d03d6caadb0648059ba94e34f8b1d.zip
Clear abandoned payments, reset profile Pay and restore native messaging Back
-rw-r--r--src/app.js32
-rw-r--r--src/features/ark.js7
-rw-r--r--src/features/messages.js17
-rw-r--r--src/features/names.js1
-rw-r--r--src/features/zaps.js1
-rw-r--r--src/recipient-search.js2
-rw-r--r--src/style.css12
-rw-r--r--tools/profile-actions-test.js206
8 files changed, 261 insertions, 17 deletions
diff --git a/src/app.js b/src/app.js
index 54404d4..2d9611a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -9,7 +9,7 @@ import { qrSvg } from './qr.js';
import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { assetUrl } from './asset-url.js';
-import { npubOf, seedPubkey, neventOf } from './nostr.js';
+import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
@@ -114,15 +114,27 @@ const ui = {
sendResult: null, // { txid } | { signedHex, txid }
busy: false,
};
+let sendPageOpen = false, sendPageRendered = false;
+
+function clearSend() {
+ ui.send = blankSend();
+ ui.draft = null; ui.broadcastTx = null; ui.sendResult = null; ui.sendError = '';
+ featureAll('resetSend');
+ sendSearcher.clear(false);
+ sendSearch.rows = null; sendSearch.sync = null; sendSearch.pick = null; sendSearch.el = null;
+ clearTimeout(sendRevealTimer);
+}
// Take the wallet to the Send pane: any payment detail left open (a tapped
// history row — which claims the whole tab area, whichever tab is picked)
// closes first. A feature that jumped straight to ui.tab = 'send' used to
// land on a renewal's detail card instead of the form.
-function showSend() {
+// A new recipient also clears previous payment reviews and results.
+function showSend({ fresh = false } = {}) {
ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null;
ui.bump = null;
ui.sendError = '';
+ if (fresh) clearSend();
ui.tab = 'send';
}
@@ -754,6 +766,7 @@ function renderInner() {
// there. Declining still lands back exactly where the tap happened,
// because nothing else moves. Onboarding keeps its precedence.
const lockAsk = ui.pw && ui.pw.purpose === 'lock' && !(ui.onb || onbInProgress());
+ sendPageRendered = false;
const screen =
(lockAsk && h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()))
|| featureHook('screenView')
@@ -774,6 +787,10 @@ function renderInner() {
// deliberate choice and still paints; this one waits.
? h('div', { class: 'col', style: 'gap:16px' }, BOOT_NAV ? null : brandHeader(false))
: shouldOnboard() ? onboardScreen() : unlockScreen());
+ // Track the pane actually rendered: a profile or chat hides Send even
+ // when its wallet tab is still selected. Reviews/results stay in Send.
+ if (sendPageOpen && !sendPageRendered) clearSend();
+ sendPageOpen = sendPageRendered;
// Navigation animates; background repaints must not. The key is every
// ui field that decides which page is on screen.
const navKey = [ui.screen, ui.tab === 'settings', ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity,
@@ -4564,11 +4581,11 @@ function tabContent() {
// getting back to it is scrolling, not hunting for the toggle. A history
// DETAIL (a tapped row) still claims the whole area — you're reading one
// payment, not browsing — as does the receive celebration takeover.
- const pane = ui.tab === 'receive' ? receiveTab() : sendTab();
const hist = historyTab();
const detailOpen = ui.bump || ui.txDetail || ui.arkMoveDetail
|| ui.arkReconDetail || ui.arkExitDetail || ui.giftDetail;
if (detailOpen) return hist;
+ const pane = ui.tab === 'receive' ? receiveTab() : sendTab();
return h('div', { class: 'col', style: 'gap:16px' },
pane,
h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')),
@@ -4710,6 +4727,7 @@ function loadSeedCard() {
}
function sendTab() {
+ sendPageRendered = true;
// Watch-only wallet (e.g. restored after a session wipe without "Save to
// device"): prompt to re-enter the seed before spending.
if (wallet.watchOnly) {
@@ -5096,8 +5114,8 @@ function recipientRow(s, r, i) {
),
check,
via ? h('div', { class: 'row gap6 send-via', style: 'align-items:center' },
- via.pk ? featureHook('avatarNode', via.pk) : null,
- h('span', { class: 'small' }, via.name),
+ via.pk && !single ? featureHook('avatarNode', via.pk) : null,
+ via.pk && single ? null : h('span', { class: 'small' }, via.name),
h('span', { class: 'small faint' }, t('sendNameHint'))) : null,
suggest,
r._ready ? h('div', { class: 'input-group' },
@@ -5145,6 +5163,9 @@ function sendForm() {
// show only the destination input (a Lightning invoice auto-advances instead).
const ready = destReady((s.recipients[0] || {}).address);
const plainAddr = (s.recipients[0] || {}).address;
+ const via = s.recipients[0]?.via;
+ const recipientPk = s.recipients.length === 1
+ ? (via?.ark === plainAddr && via.pk) || parseNostrPubkey(plainAddr) : null;
const arkDest = s.recipients.length === 1 && !!featureHook('hideSendControls', plainAddr);
const feeOpts = [
['economyFee', t('feeEconomy')],
@@ -5155,6 +5176,7 @@ function sendForm() {
return h(
'div',
{ class: 'card col' },
+ recipientPk ? featureHook('profileChip', recipientPk, 'lg') : null,
h(
'div',
{ class: 'field' },
diff --git a/src/features/ark.js b/src/features/ark.js
index 566c402..8fe21cc 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1895,7 +1895,7 @@ export function arkFeature(ctx) {
function arkZapView() {
// While resolving, show the SHAPE of the card that's coming — same
- // title, same recipient chip, a spinner where the form will be. The
+ // title, same recipient portrait, a spinner where the form will be. The
// screen fills in rather than being replaced.
if (ui.arkZap && ui.arkZap.status === 'lookup') return zapSkeleton(ui.arkZap.pk, ui.arkZap.npub);
if (ui.arkZapped) {
@@ -3488,6 +3488,11 @@ export function arkFeature(ctx) {
}
return false;
},
+ resetSend() {
+ ui.arkLnFromSavings = null; ui.arkLnFromSavingsOk = false; ui.arkOffboardSend = null;
+ ui.arkLnPaid = null; ui.arkLnPay = null; ui.arkZapped = null; ui.arkZap = null;
+ ui.arkSent = null; ui.arkSend = null;
+ },
sendView() {
if (ui.arkLnFromSavings) return arkLnFromSavingsView();
if (ui.arkOffboardSend) return arkOffboardSendView();
diff --git a/src/features/messages.js b/src/features/messages.js
index 7ebc28e..54cbfa3 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2964,10 +2964,10 @@ export function messagesFeature(ctx) {
//
// The thumbnail we keep locally is the whole picture at this size, so
// a 30px circle never asks the network for the original again. The
- // 64px profile avatar layers them: CSS paints the first layer that
+ // Larger profile/payment avatars layer them: CSS paints the first layer that
// has arrived, so the thumbnail shows instantly and the full-size
// original takes over on top when it lands.
- ? h('div', { class: cls + ' ava-img', style: 'background-image:' + avatarBg(p, cls.includes('profile-avatar')) })
+ ? h('div', { class: cls + ' ava-img', style: 'background-image:' + avatarBg(p, cls.includes('profile-avatar') || cls.includes('send-avatar')) })
: fallbackAvatar(h, pk, p.name, cls);
// A face we keep painting is one worth keeping a thumbnail of.
if (!feedPaint && p && p.picture) makeThumb(pk, p);
@@ -7924,7 +7924,8 @@ export function messagesFeature(ctx) {
ui.noteThread = null;
ui.userSearch = null;
ui.chatOpen = false;
- ctx.showSend(); // and never a lingering payment detail
+ ctx.showSend({ fresh: true });
+ ui.send.recipients[0].address = npubStr;
render();
hook('matchSendText', npubStr);
} }, t('profPay')),
@@ -7988,7 +7989,8 @@ export function messagesFeature(ctx) {
...noteOverlays());
}
- const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹');
+ const backBtn = (toParent) => h('button', { class: 'iconbtn chat-back',
+ onClick: () => ctx.goBack(toParent) }, '‹');
// ---- the post composer ----------------------------------------------------
// Shared by the profile page and the feed. A picture is uploaded the moment
@@ -9982,10 +9984,11 @@ export function messagesFeature(ctx) {
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {
const big = size === 'lg';
- return h('span', {
- class: 'zap-chip' + (big ? ' lg' : ''),
+ return h(big ? 'button' : 'span', {
+ class: big ? 'send-person' : 'zap-chip',
+ type: big ? 'button' : undefined,
onClick: (e) => { e.stopPropagation(); if (onOpen) onOpen(); else openProfile(pk); },
- }, avatar(pk, 'chat-avatar ' + (big ? 'chip-lg' : 'mini'), false),
+ }, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false),
h('span', { class: big ? '' : 'small' }, displayName(pk)));
},
// A sign-in (Google, passkey, key) that found no wallet on its identity:
diff --git a/src/features/names.js b/src/features/names.js
index a34334f..8f37b36 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -929,6 +929,7 @@ export function namesFeature(ctx) {
if (!ui.send || ui.send.recipients.length !== 1) return false;
return beginResolve(text);
},
+ resetSend() { ui.nameResolve = null; },
// DNS resolution takes visible time — say so under the recipient field
// instead of leaving a picked name looking ignored.
sendFormNote(a) {
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d6b2271..7a5f335 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -464,6 +464,7 @@ export function zapsFeature(ctx) {
begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
return true;
},
+ resetSend() { ui.zap = null; },
sendView() { return zapView(); },
// Settings → Nostr: the one-tap zap amount.
nostrSettingsCards() {
diff --git a/src/recipient-search.js b/src/recipient-search.js
index 20083ee..8d1ae84 100644
--- a/src/recipient-search.js
+++ b/src/recipient-search.js
@@ -252,7 +252,7 @@ export function makeSearcher(onUpdate) {
if (my === seq) onUpdate(q, rows);
}, wait);
},
- clear() { seq++; clearTimeout(timer); onUpdate('', null); },
+ clear(notify = true) { seq++; clearTimeout(timer); if (notify) onUpdate('', null); },
};
}
diff --git a/src/style.css b/src/style.css
index 91b8dd4..f77c7d9 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1224,9 +1224,15 @@ body::after {
border: 0; border-radius: 7px; background: var(--accent-soft); color: var(--muted); cursor: pointer;
}
.amt-max:hover { background: var(--surface2); color: var(--ink); }
-/* recipient chip: large variant for pay/zap screens */
-.zap-chip.lg { gap: 8px; padding: 4px 12px 4px 4px; font-weight: 600; }
-.chat-avatar.chip-lg { width: 30px; height: 30px; font-size: 11px; flex-shrink: 0; }
+/* The selected person leads the payment form, with their name below. */
+.send-person {
+ display: flex; flex-direction: column; align-items: center; align-self: center;
+ max-width: 100%; gap: 12px; padding: 12px; border: 0; border-radius: 0;
+ background: transparent; text-align: center; font-size: 18px; font-weight: 650;
+ overflow-wrap: anywhere;
+}
+.send-person:hover { background: transparent; }
+.chat-avatar.send-avatar { width: 96px; height: 96px; font-size: 28px; }
/* the clickable way to change account (swiping only helps on touch) */
.balance-switch {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
diff --git a/tools/profile-actions-test.js b/tools/profile-actions-test.js
new file mode 100644
index 0000000..e015b6d
--- /dev/null
+++ b/tools/profile-actions-test.js
@@ -0,0 +1,206 @@
+// Send clears on leaving; profile Pay starts fresh; messaging Back pops history.
+// Run: bun tools/profile-actions-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 { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { cacheKeyFor } from '../src/wallet.js';
+const peer = getPublicKey(generateSecretKey()), nextPeer = getPublicKey(generateSecretKey());
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'profile-actions-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.profileActions = { ui, wallet, render, showSend, enterWallet,
+ get ready() { return !_bootDeciding; },
+ openProfile: (pk) => featureHook('openProfile', pk) };
+` }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch(req) {
+ if (/^\/punks\/\d+\.webp$/.test(new URL(req.url).pathname)) return new Response(Bun.file('static' + new URL(req.url).pathname));
+ return new URL(req.url).pathname === '/' || new URL(req.url).pathname.startsWith('/npub1')
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
+ 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', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-ark-provider:regtest', 'off'); });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.profileActions?.ready);
+ const mnemonic = generateMnemonic(wordlist);
+ await page.evaluate(([base, peer, nextPeer]) => localStorage.setItem(base + ':profiles', JSON.stringify({
+ [peer]: { name: 'Alice', picture: '/punks/18.webp', t: Date.now() },
+ [nextPeer]: { name: 'Bob', picture: '/punks/54.webp', t: Date.now() },
+ })), [cacheKeyFor(mnemonic + '\n'), peer, nextPeer]);
+ await page.evaluate(async (mn) => {
+ await profileActions.enterWallet(mn, '', { fresh: true, generated: true });
+ profileActions.ui.onb = null;
+ localStorage.removeItem('btc-wallet-onb-step');
+ profileActions.render();
+ }, mnemonic);
+ await page.waitForSelector('.app-bottom-nav');
+ const openProfile = async (pk) => {
+ await page.evaluate((pk) => profileActions.openProfile(pk), pk);
+ await page.waitForSelector('.prof-actions');
+ };
+ const action = (label) => page.evaluate((label) => [...document.querySelectorAll('.prof-actions button')].find((b) => b.textContent.trim().replace(/^⚡\s*/, '') === label).click(), label);
+
+ // Exercise each real success view without making any payment.
+ const successes = [
+ { sendResult: { txid: '1'.repeat(64) } },
+ { arkLnPaid: { amountSat: 21, meta: { name: 'Previous person' } } },
+ { arkZapped: { amountSat: 21, npub: npubEncode(peer) } },
+ { arkSent: { amountSat: 21 } },
+ ];
+ for (const previous of successes) {
+ await page.evaluate((previous) => {
+ const { ui, render, showSend } = profileActions;
+ ui.profilePk = null; ui.chatOpen = false;
+ showSend({ fresh: true });
+ Object.assign(ui, previous);
+ render();
+ }, previous);
+ assert.equal(await page.$('input.mono-input'), null, 'previous payment displays its success view');
+ await openProfile(nextPeer);
+ await action('Pay');
+ await page.waitForSelector('input.mono-input');
+ assert.equal(await page.$eval('input.mono-input', (e) => e.value), npubEncode(nextPeer));
+ assert(!(await page.$('.check-badge')), 'previous success must not win over the new payment form');
+ const state = await page.evaluate(() => ({
+ recipients: profileActions.ui.send.recipients.map(({ address, amount }) => ({ address, amount })),
+ results: ['sendResult', 'arkLnPaid', 'arkZapped', 'arkSent'].map((k) => profileActions.ui[k]),
+ }));
+ assert.deepEqual(state.recipients, [{ address: npubEncode(nextPeer), amount: '' }]);
+ assert(state.results.every((v) => v === null));
+ }
+ console.log('✓ Profile Pay replaces prior on-chain, Lightning, zap and Ark success screens with a fresh recipient form');
+
+ await openProfile(peer);
+ await page.evaluate(() => {
+ const { ui } = profileActions;
+ ui.send.recipients = [{ address: 'old person', amount: '5' }, { address: 'another old person', amount: '7' }];
+ ui.send.max = true;
+ ui.draft = { stale: true }; ui.broadcastTx = { stale: true }; ui.sendError = 'Previous error';
+ ui.arkSend = { stale: true }; ui.arkLnPay = { stale: true }; ui.arkZap = { stale: true };
+ ui.arkLnFromSavings = { stale: true }; ui.arkLnFromSavingsOk = true; ui.arkOffboardSend = { stale: true };
+ ui.zap = { stale: true }; ui.nameResolve = { text: '[email protected]' };
+ });
+ await action('Pay');
+ await page.waitForSelector('input.mono-input');
+ assert.equal(await page.$eval('input.mono-input', (e) => e.value), npubEncode(peer));
+ assert(await page.evaluate(() => {
+ const { ui } = profileActions;
+ return !ui.send.max && !ui.arkLnFromSavingsOk && ui.send.recipients.length === 1 && ui.send.recipients[0].amount === ''
+ && ['draft', 'broadcastTx', 'arkSend', 'arkLnPay', 'arkZap', 'arkLnFromSavings', 'arkOffboardSend', 'zap', 'nameResolve', 'sendError'].every((k) => !ui[k]);
+ }), 'old review, resolution, amount, max and extra recipients are cleared');
+ console.log('✓ Fresh profile payment also clears prior drafts and recipient amounts');
+
+ const portrait = await page.$eval('.send-person', (e) => {
+ const a = e.querySelector('.send-avatar').getBoundingClientRect(), name = e.querySelector('span').getBoundingClientRect();
+ const card = e.closest('.card').getBoundingClientRect(), style = getComputedStyle(e);
+ return { width: a.width, height: a.height, centered: Math.abs(a.x + a.width / 2 - card.x - card.width / 2) < 1,
+ nameBelow: name.top >= a.bottom, border: style.borderWidth, background: style.backgroundColor, name: e.textContent };
+ });
+ assert.deepEqual(portrait, { width: 96, height: 96, centered: true, nameBelow: true, border: '0px', background: 'rgba(0, 0, 0, 0)', name: 'Alice' });
+ await page.screenshot({ path: '/tmp/coinos-send-profile.png' });
+ console.log('✓ Recipient portrait is 96px, centered above the name, without a badge border or background');
+
+ const assertBlank = async () => {
+ assert(await page.evaluate(() => {
+ const { ui } = profileActions;
+ return ui.send.recipients.length === 1 && ui.send.recipients[0].address === '' && ui.send.recipients[0].amount === ''
+ && !ui.send.max && ui.send.coins.size === 0 && !ui.arkLnFromSavingsOk
+ && ['draft', 'broadcastTx', 'sendResult', 'sendError', 'arkSend', 'arkSent', 'arkLnPay', 'arkLnPaid', 'arkZap', 'arkZapped',
+ 'arkLnFromSavings', 'arkOffboardSend', 'zap', 'nameResolve'].every((k) => !ui[k]);
+ }), 'leaving Send clears the recipient, amount, selection, drafts and all feature payment states');
+ };
+ const pendingSend = async () => {
+ await page.evaluate((npub) => {
+ const { ui, render, showSend } = profileActions;
+ ui.profilePk = null; ui.chatOpen = false; ui.msgView = null;
+ showSend({ fresh: true });
+ ui.send.recipients[0] = { address: npub, amount: '21' };
+ render(); render();
+ }, npubEncode(peer));
+ assert.deepEqual(await page.evaluate(() => profileActions.ui.send.recipients.map(({ address, amount }) => ({ address, amount }))),
+ [{ address: npubEncode(peer), amount: '21' }], 'background renders retain an in-progress payment on Send');
+ // Stale hidden feature state is deliberately seeded after painting the form.
+ await page.evaluate(() => {
+ const { ui } = profileActions;
+ ui.draft = { stale: true }; ui.broadcastTx = { stale: true }; ui.sendResult = { stale: true }; ui.sendError = 'Previous error';
+ ui.arkSend = { stale: true }; ui.arkSent = { stale: true }; ui.arkLnPay = { stale: true }; ui.arkLnPaid = { stale: true };
+ ui.arkZap = { stale: true }; ui.arkZapped = { stale: true }; ui.arkLnFromSavings = { stale: true };
+ ui.arkLnFromSavingsOk = true; ui.arkOffboardSend = { stale: true }; ui.zap = { stale: true }; ui.nameResolve = { text: '[email protected]' };
+ });
+ };
+ for (const label of ['Wallet', 'Messages', 'Feed', 'Notifications']) {
+ await pendingSend();
+ await page.evaluate((label) => document.querySelector(`.app-nav-button[aria-label="${label}"]`).click(), label);
+ await assertBlank();
+ }
+ await pendingSend();
+ await page.evaluate(() => document.querySelector('.send-person').click());
+ await page.waitForSelector('.prof-actions');
+ assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
+ await assertBlank();
+ await page.goBack();
+ await page.waitForSelector('input.mono-input');
+ await assertBlank();
+ assert.equal(await page.$('.send-person'), null, 'returning to Send has no old recipient portrait');
+
+ // Native Back/Forward also leave and reopen the Send pane, without a draft.
+ await page.evaluate(() => { const { ui, render } = profileActions; ui.tab = 'receive'; render(); });
+ await pendingSend();
+ await page.goBack();
+ await page.waitForFunction(() => profileActions.ui.tab === 'receive');
+ await assertBlank();
+ await page.goForward();
+ await page.waitForSelector('input.mono-input');
+ await assertBlank();
+ console.log('✓ Leaving Send for navigation tabs, a recipient profile or browser Back clears it; returning starts blank');
+
+ await openProfile(peer);
+ const before = await page.evaluate(() => ({ i: history.state.i, length: history.length, path: location.pathname }));
+ await action('Messages');
+ await page.waitForSelector('.chat-head .chat-back');
+ assert.equal(await page.evaluate(() => profileActions.ui.msgPeer), peer);
+ await page.evaluate(() => Promise.all([...document.querySelectorAll('.anim-page')].flatMap((n) => n.getAnimations()).map((a) => a.finished.catch(() => {}))));
+ await page.click('.chat-head .chat-back');
+ await page.waitForSelector('.prof-actions');
+ assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
+ assert.deepEqual(await page.evaluate(() => ({ i: history.state.i, length: history.length, path: location.pathname })), { ...before, length: before.length + 1 },
+ 'on-screen Back pops the DM entry and restores the exact profile history entry');
+ await page.goForward();
+ await page.waitForSelector('.chat-head .chat-back');
+ assert.equal(await page.evaluate(() => profileActions.ui.profilePk), null);
+ assert.equal(await page.evaluate(() => profileActions.ui.msgPeer), peer);
+ await page.goBack();
+ await page.waitForSelector('.prof-actions');
+ assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
+ console.log('✓ Messaging Back returns to its profile, and native browser Forward/Back traverse the same entries');
+
+ // A conversation opened from the chat list still returns to that list.
+ await page.evaluate((peer) => {
+ const { ui, render } = profileActions;
+ ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'home'; render();
+ ui.msgView = 'dm'; ui.msgPeer = peer; render();
+ }, nextPeer);
+ await page.waitForSelector('.chat-head .chat-back');
+ await page.evaluate(() => Promise.all([...document.querySelectorAll('.anim-page')].flatMap((n) => n.getAnimations()).map((a) => a.finished.catch(() => {}))));
+ await page.click('.chat-head .chat-back');
+ await page.waitForFunction(() => profileActions.ui.msgView === 'home');
+ assert.equal(await page.evaluate(() => profileActions.ui.profilePk), null);
+ assert.deepEqual(errors, []);
+ console.log('✓ Conversations opened from the chat list return to it; no browser errors');
+} finally { await browser.close(); server.stop(true); }