diff options
| -rw-r--r-- | src/app.js | 23 | ||||
| -rw-r--r-- | src/features/ark.js | 20 | ||||
| -rw-r--r-- | src/features/messages.js | 4 | ||||
| -rw-r--r-- | src/style.css | 17 | ||||
| -rw-r--r-- | tools/amount-field-unit-test.js | 16 | ||||
| -rw-r--r-- | tools/ark/custody-foreign-test.js | 102 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 7 |
7 files changed, 177 insertions, 12 deletions
@@ -1209,31 +1209,44 @@ let unit = (() => { // been fetched — a third position showing blanks is worse than two that work. function toggleUnit() { const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc']; + const was = unit; unit = ring[(ring.indexOf(unit) + 1) % ring.length]; try { localStorage.setItem(UNIT_KEY, unit); } catch {} + // An amount already typed into the send form is the same money in the new + // unit, not the same digits: 1000 sats does not become 1000 BTC. Whatever + // doesn't parse is left as typed. + for (const r of (ui.send && ui.send.recipients) || []) { + if (!r.amount || !String(r.amount).trim()) continue; + const sat = parseField(r.amount, was); + if (sat) r.amount = amountField(sat); + } render(); } // A sats figure as the amount field types it in the current unit — what a // Max button or a fixed-amount invoice fills in. Fiat is floored to the // cent so a filled-in maximum never parses back to more than was offered. -// The figure it filled is remembered with its exact sats: a fiat maximum +// The figures it filled are remembered with their exact sats: a fiat maximum // rounded to the cent would otherwise parse back a few sats short and leave -// them behind. Typing anything else parses as typed. -let lastFill = null; +// them behind. Typing anything else parses as typed. Several are kept so a +// form with more than one amount round-trips each of them. +const fills = new Map(); function amountField(sat) { let s; const r = unit === 'fiat' && rateNow(); if (unit === 'sats') s = String(sat); else if (r) s = (Math.floor((sat / 1e8) * r * 100) / 100).toFixed(2); else s = (sat / 1e8).toFixed(8); - lastFill = { s, unit, sat }; + fills.delete(unit + '|' + s); + fills.set(unit + '|' + s, sat); + while (fills.size > 16) fills.delete(fills.keys().next().value); return s; } function parseField(v, u = unit) { - if (lastFill && lastFill.unit === u && String(v).trim() === lastFill.s) return lastFill.sat; + const k = u + '|' + String(v).trim(); + if (fills.has(k)) return fills.get(k); return parseAmount(v, u, rateNow()); } diff --git a/src/features/ark.js b/src/features/ark.js index eccc3b8..739dd5e 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2677,7 +2677,7 @@ export function arkFeature(ctx) { const view = arkView(); const info = (view && view.info) || (cachedArkInfo(cfg && cfg.ark) || {}).info || null; const honors = !!(cfg && cfg.honorsExpired); - const cur = honors ? getCustody() : 'self'; + const cur = getCustody(); // never 'custodial' on a server that doesn't honor expired coins const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0; const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || []; const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; @@ -3402,10 +3402,20 @@ export function arkFeature(ctx) { // happening anyway, for next to nothing; the Manage page consolidates on // request. const CUSTODY_DEFAULT = 'custodial'; + // The tier in force: the stored choice, except that "coinos holds my coins" + // exists only on a server that honors a coin past its timelock. Anywhere + // else (Second, a custom captaind) an expired coin is gone, so renewal is + // automatic there whatever is stored — the stored default stays on the + // record for a later switch back to a coinos server. From 2026-10-22 to + // 10-23 the stored default applied everywhere, and a wallet on such a + // server renewed nothing and warned of nothing, in the app and the worker. function getCustody() { let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null; if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026) - return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + const tier = CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + if (tier !== 'custodial') return tier; + const cfg = getArkConfig(); + return cfg && !cfg.honorsExpired ? 'self' : tier; } function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); } const custodyPolicy = () => renewPolicy(getCustody(), getNetwork()); @@ -3416,8 +3426,10 @@ export function arkFeature(ctx) { // (there is no cancel; a spent input voids one) — they were priced when // registered and renew one coin each, once. function applyCustody(tier) { - if (!CUSTODY_TIERS.includes(tier) || tier === getCustody()) return; - setCustody(tier); + if (!CUSTODY_TIERS.includes(tier)) return; + const same = tier === getCustody(); + setCustody(tier); // the record keeps the choice even when it is already in force here + if (same) return; arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null; if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {}); writeBgMirror().catch(() => {}); diff --git a/src/features/messages.js b/src/features/messages.js index 030d9fa..dea454c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -9309,7 +9309,7 @@ export function messagesFeature(ctx) { }; return h('div', { class: 'col live-page', style: 'gap:12px' }, ctx.brandHeader(!ui.pubProf && wallet.loaded), - h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'card col live-main', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹') : backBtn(() => { ui.liveStream = null; }), @@ -9361,7 +9361,7 @@ export function messagesFeature(ctx) { watchersRow(w), liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null, ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null), - h('div', { class: 'card col', style: 'gap:8px' }, + h('div', { class: 'card col live-chat-card', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('liveChat')), h('div', { class: 'live-chat', 'data-key': 'live-chat:' + s.a, diff --git a/src/style.css b/src/style.css index 275bb59..384b6d6 100644 --- a/src/style.css +++ b/src/style.css @@ -929,6 +929,23 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; } .live-zap .live-line-body b { color: var(--accent); } .live-chat .chat-row { padding-right: 2px; } +/* on phones the stream is the page, the way Nostrich lays one out: the + picture runs edge to edge and the chat under it is a plain full-width + list of lines, no bubbles — the card walls step outside #app's padding */ +@media (max-width: 520px) { + .live-page > .card { + margin-left: -16px; margin-right: -16px; padding: 10px 0 12px; + border-left: none; border-right: none; border-radius: 0; box-shadow: none; + } + .live-page > .live-chat-card { margin-top: 0; padding-top: 6px; border-top: none; } + .live-page > .card > :not(.live-player, .live-chat) { margin-left: 14px; margin-right: 14px; } + .live-page .live-player { border-radius: 0; } + .live-page .live-chat { padding: 10px 14px 0; max-height: 60vh; } + .live-page .live-title { font-size: 17px; } + .live-chat .chat-row { gap: 10px; } + .live-chat .chat-bubble { display: block; background: transparent; border: 0; border-radius: 0; padding: 0; font-size: 15px; } + .live-chat .chat-quote { margin-bottom: 2px; } +} .bc-preview.mirror { transform: scaleX(-1); } .bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; } .go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); } diff --git a/tools/amount-field-unit-test.js b/tools/amount-field-unit-test.js index b984064..596969d 100644 --- a/tools/amount-field-unit-test.js +++ b/tools/amount-field-unit-test.js @@ -2,6 +2,8 @@ // showing: in fiat that is money to the cent, floored so a filled-in maximum // never parses back to more sats than were offered — not a BTC figure under a // CAD label (2026-10-08: Max on an ark recipient filled 0.00004978 'CAD'). +// Switching the unit with an amount already typed into the send form converts +// it (2026-10-23): the same money in the new unit, round-tripping exactly. // Run: bun tools/amount-field-unit-test.js import assert from 'node:assert/strict'; import puppeteer from './puppeteer.js'; @@ -9,7 +11,7 @@ import puppeteer from './puppeteer.js'; const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, plugins: [{ name: 'amount-field-test', setup(build) { build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` -window.amtApp = { ctx, toggleUnit, get unit() { return unit; } }; +window.amtApp = { ctx, ui, toggleUnit, get unit() { return unit; } }; ` })); build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); } }] }); @@ -37,6 +39,13 @@ try { while (amtApp.unit !== 'sats') amtApp.toggleUnit(); out.sats = ctx.amountField(62212); amtApp.toggleUnit(); out.btc = ctx.amountField(62212); + // typed into the send form, then the unit toggled around the ring + while (amtApp.unit !== 'sats') amtApp.toggleUnit(); + const rs = amtApp.ui.send.recipients; + rs[0].amount = '62212'; rs.push({ address: '', amount: '93.318' }, { address: '', amount: '' }, { address: '', amount: 'abc' }); + out.ring = []; + for (let i = 0; i < 3; i++) { amtApp.toggleUnit(); out.ring.push([amtApp.unit, ...rs.map((r) => r.amount)]); } + rs.splice(1); return out; }); console.log(r); @@ -46,6 +55,11 @@ try { assert.equal(r.typed, 62207, 'the same money typed as something else parses as typed'); assert.equal(r.sats, '62212'); assert.equal(r.btc, '0.00062212'); + assert.deepEqual(r.ring, [ + ['btc', '0.00062212', '0.00000093', '', 'abc'], + ['fiat', '93.31', '0.13', '', 'abc'], + ['sats', '62212', '93', '', 'abc'], + ], 'a typed amount follows the unit around the ring and comes back exact; blanks and junk are left alone'); assert.deepEqual(errors, []); console.log('✅ amount fills follow the display unit'); } finally { await browser.close(); server.stop(true); } diff --git a/tools/ark/custody-foreign-test.js b/tools/ark/custody-foreign-test.js new file mode 100644 index 0000000..148622d --- /dev/null +++ b/tools/ark/custody-foreign-test.js @@ -0,0 +1,102 @@ +// On a server that does not honor a coin past its timelock (Second, a custom +// captaind) "coinos holds my coins" is not a tier: whatever is stored, the +// tier in force is "self" (or "exit" when chosen), the card offers only those +// two, and the renewal pass registers backstops and renews what is due. +// Real app with a local Ark manager stub; nothing is sent anywhere. +// Run: bun tools/ark/custody-foreign-test.js +import assert from 'node:assert/strict'; +import puppeteer from '../puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, + plugins: [{ name: 'custody-foreign-test', setup(build) { + build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` +window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; }, + setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } }; +` })); + build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents: + (await Bun.file(path).text()).replace(" return {\n id: 'ark',", ` + window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, applyCustody, maybeAutoRefresh, autoRefreshCalls: [] }; + return {\n id: 'ark',`) })); + 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) => new URL(req.url).pathname === '/' + ? 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 }); + 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'); + // a custom server: no honorsExpired on its record, like Second's preset + localStorage.setItem('btc-wallet-ark-provider:regtest', 'custom'); + localStorage.setItem('btc-wallet-ark-custom:regtest', JSON.stringify({ ark: 'http://localhost:3535', esplora: 'http://localhost:30002' })); + }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.coopApp?.ready); + await page.evaluate(async (mn) => { + await coopApp.enterWallet(mn, '', { fresh: true, generated: true }); + coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); + coopApp.wallet.scan = async () => {}; + const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 288, ppm: 2000 }, { thresholdBlocks: 1008, ppm: 4000 }, { thresholdBlocks: 2016, ppm: 5000 }]; + window.coopManager = { + honorsExpired: false, + state: { vtxos: [ + { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 2 }, // weeks left + { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 969760, depth: 1 }, // 9 blocks from its timelock + ], actions: [], movements: [], scheduled: [] }, + info: { network: 'regtest', vtxoExpiryDelta: 4032, roundIntervalSecs: 3600, refreshFees: { baseFeeSat: 0, ppmExpiryTable: table } }, _tipH: 969751, + chain: { tipHeight: async () => 969751 }, + vtxos() { return this.state.vtxos; }, + _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; }, + balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; }, + refreshFee(inputs, tip) { + let u = 0; + for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0); + return Math.ceil(u / 1e6); + }, + async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; }, + async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; }, + unseenReceives() { return []; }, ackReceives() {}, + address() { return 'test-ark-address'; }, + }; + coopFeature.setManager(coopManager); + coopApp.setSpending(true); + coopApp.openWalletSettings('spending'); + }, generateMnemonic(wordlist)); + await page.waitForSelector('.ark-custody'); + + // nothing stored: the tier in force is "self", and the card offers no custodial option + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self'); + assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), null, 'nothing was written to the record'); + const opts = await page.$$eval('#ark-custody-select option', (os) => os.map((o) => [o.value, o.selected])); + assert.deepEqual(opts, [['self', true], ['exit', false]]); + console.log('✓ a server that does not honor expired coins has no "coinos holds my coins" tier; "self" is in force'); + + // the stored default from a coinos wallet does not switch renewals off here + await page.evaluate(() => coopApp.wallet.saveFeatureState('arkCustody', 'custodial')); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self'); + console.log('✓ a stored "custodial" choice reads as "self" on such a server'); + + // the renewal pass runs: backstop orders are registered and the coin at its timelock renews now + const calls = await page.evaluate(async () => { coopFeature.autoRefreshCalls.length = 0; await coopFeature.maybeAutoRefresh(coopManager); return coopFeature.autoRefreshCalls; }); + assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered (regtest margin 6)'); + assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin close to its timelock is renewed now'); + console.log('✓ automatic renewal runs on such a server with nothing opted into'); + + // the exit tier can still be chosen, and is kept + await page.evaluate(() => coopFeature.applyCustody('exit')); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit'); + assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), 'exit'); + console.log('✓ "exit" can be chosen there and is saved'); + assert.deepEqual(errors, [], 'no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js index ef76043..2be6051 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -197,6 +197,12 @@ try { check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000)); await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click()); check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000)); + const lay = await page.evaluate(() => { + const r = (q) => document.querySelector(q)?.getBoundingClientRect(); + const p = r('.live-page .live-player'), c = r('.live-page .live-chat'); + return { w: document.body.clientWidth, player: p && [p.left, p.width], chat: c && [c.left, c.width] }; + }); + check('on a phone the picture runs edge to edge and the chat is as wide', lay.player && lay.player[0] === 0 && lay.player[1] === lay.w && lay.chat && lay.chat[0] === 0 && lay.chat[1] === lay.w, JSON.stringify(lay)); await page.goBack(); await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000); await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); @@ -217,6 +223,7 @@ try { check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000)); const rowSel = '.live-message[data-key="' + chat.id + '"]'; check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft')); + check('...drawn plain on a phone, no bubble walls', await page.evaluate((q) => { const b = document.querySelector(q); return b && getComputedStyle(b).borderStyle === 'none' && getComputedStyle(b).display === 'block'; }, rowSel + ' .chat-bubble')); check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id)); check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id)); await page.click(rowSel + ' .chat-bubble'); |
