aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js201
1 files changed, 140 insertions, 61 deletions
diff --git a/src/app.js b/src/app.js
index f904cc3..1337300 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,4 @@
+import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js';
// Bitcoin Wallet — UI controller (vanilla DOM, no framework).
//
// State lives in `ui` + the singleton `wallet`. Mutating handlers call render(),
@@ -11,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
-import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -584,6 +585,15 @@ 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;
+// 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
+// does neither, so the third start in a row opens in safe mode.
+function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }
+function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} }
+const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot);
+document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); });
+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.
@@ -853,7 +863,9 @@ function renderInner() {
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;
- morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : [])]);
+ // 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] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {}
if (fpath) {
@@ -909,7 +921,7 @@ wallet.subscribe(scheduleRender);
// lightbox: a full-screen photo is a place too — the phone's Back must close
// it, not pop the screen under it (which once walked a viewer straight back
// to the start page, so the photo's × then looked like a logout).
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'liveStream', 'goLive', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -917,6 +929,7 @@ function navSnapshot() {
// results streaming in) snapshot only what IDENTIFIES the screen — a
// keystroke must never mint a history entry.
if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed };
+ if (ui.liveStream) s.liveStream = { a: ui.liveStream.a, seed: ui.liveStream.seed };
if (ui.userSearch) s.userSearch = { q: '', rows: null };
if (ui.feedEdit) s.feedEdit = { ...ui.feedEdit, q: '', rows: null, pq: '', packRows: null, pt: null };
if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' };
@@ -939,7 +952,7 @@ let restoringHistory = false; // true while applying a popstate (suppresses push
// copy it, send it, reload it — the same paths the deep links already open.
// Every other screen keeps whatever path it had (the till at /pos, the
// plain app at /); only a path we set ourselves is taken back to /.
-const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i;
+const OWN_PATH = /^\/(nevent|note|npub|naddr)1[a-z0-9]+\/?$/i;
function navUrl(snap) {
try {
const fp = featureHook('navPath', snap); // a feed with a public address
@@ -1196,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());
}
@@ -1920,7 +1946,7 @@ function identities() {
function clearFeatureNav() {
ui.arkCoinsPage = null; ui.arkExitPage = null;
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false;
- ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null;
+ ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.zapSetup = null;
}
// Sign out of the CURRENT identity only. "Log out" locks every account on
@@ -2970,7 +2996,7 @@ function settingsTab() {
switch (ui.settingsPage) {
// the recovery phrase first: it is the one thing on this page that
// matters if the phone is lost tonight
- case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a)] : []);
+ case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
@@ -3027,11 +3053,33 @@ function registerLinkHandler(scheme) {
toast(t('linkHandlerAsked'));
} catch (e) { toast(t('linkHandlerRefused')); }
}
+function mediaServersCard() {
+ if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n');
+ const save = (reset = false) => {
+ try {
+ if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; }
+ else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n');
+ toast(t('mediaServersSaved'));
+ render();
+ } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); }
+ };
+ return h('div', { class: 'card col', 'data-key': 'media-servers' },
+ h('h3', {}, t('mediaServersTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')),
+ h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'),
+ placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false',
+ onInput: (e) => { ui.mediaServersDraft = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')),
+ h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset'))));
+}
+
function nostrSettingsView() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
...featureAll('nostrSettingsCards'),
+ mediaServersCard(),
// The browser's own "open these links here" registry: njump's "Your
// default web app" is a web+nostr: link, and it opens whichever site
// asked for that scheme last. Needs a tap (the browser asks).
@@ -3220,7 +3268,7 @@ function goHome() {
ui.profilePk = null;
ui.profEdit = null;
ui.userSearch = null;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.zapSetup = null;
ui.arkExitPage = null;
ui.settingsPage = null;
@@ -3605,6 +3653,46 @@ function pubkeyCard(a) {
);
}
+// Every payment in both balances as a CSV file, for a spreadsheet or an
+// accountant: Savings' on-chain transactions and Spending's movements (the
+// rows the features list in History, each with its `csv` line), oldest
+// first, priced in the chosen currency at the time each happened.
+function historyCsv() {
+ const cur = getCurrency();
+ const lines = [];
+ for (const tx of wallet.history || []) {
+ const out = tx.net < 0, fee = out ? tx.fee || 0 : 0;
+ lines.push({ ts: tx.confirmed && tx.blockTime ? tx.blockTime * 1000 : Date.now(), account: t('savingLabel'), type: out ? 'send' : 'receive', rail: 'onchain',
+ amount: out ? tx.net + fee : tx.net, fee, net: tx.net, status: tx.confirmed ? 'complete' : 'pending', ref: tx.txid, to: '', invoice: '', note: '' });
+ }
+ for (const [sel, account] of [['savings', t('savingLabel')], ['spending', t('spendingLabel')]])
+ for (const e of featureAll('historyEntries', wallet.history || [], sel)) if (e.csv) lines.push({ ...e.csv, account });
+ lines.sort((a, b) => a.ts - b.ts);
+ const q = (v) => (v == null ? '' : /[",\n\r]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v));
+ const head = ['Date (UTC)', 'Account', 'Type', 'Rail', 'Amount (sats)', 'Fee (sats)', 'Net (sats)', `Net (${cur} at the time)`, 'Status', 'Transaction / payment hash', 'To', 'Invoice', 'Note'];
+ const rows = lines.map((l) => {
+ const { rate, stale } = rateAt(l.ts / 1000);
+ const fiat = rate && !stale ? ((l.net / 1e8) * rate).toFixed(2) : '';
+ return [new Date(l.ts).toISOString().replace('T', ' ').slice(0, 19), l.account, l.type, l.rail, l.amount, l.fee || 0, l.net, fiat, l.status, l.ref, l.to, l.invoice, l.note];
+ });
+ return [head, ...rows].map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
+}
+function exportHistory() {
+ const name = `coinos-history-${new Date().toISOString().slice(0, 10)}.csv`;
+ const url = URL.createObjectURL(new Blob([historyCsv()], { type: 'text/csv;charset=utf-8' }));
+ const a = h('a', { href: url, download: name, style: 'display:none' });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+function historyExportCard() {
+ return h('div', { class: 'card col' },
+ h('h3', {}, t('historyExportTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('historyExportDesc')),
+ h('button', { class: 'btn-block history-export', disabled: !wallet.loaded, onClick: exportHistory }, t('historyExportBtn')));
+}
+
function autolockCard(a) {
return h('div', { class: 'card col' },
h('h3', {}, t('autolockTitle')),
@@ -3829,15 +3917,26 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
- // a phone video is shrunk on the device first (see video-compress.js)
+ const signEvent = await mediaUploadSigner(wallet, featureHook);
+ // a phone video is shrunk on the device first (see video-compress.js);
+ // a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
mediaProgress('shrink', 0);
- const { compressVideo } = await import('./video-compress.js');
+ const { compressVideo, streamVideo } = await import('./video-compress.js');
+ const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
+ if (stream) {
+ mediaProgress('upload', 0);
+ const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
+ return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
+ }
+ mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
- return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
} catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity'));
+ if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized'));
if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
throw new Error(t('msgUploadFailed'));
@@ -4401,6 +4500,7 @@ function walletScreen() {
brandHeader(true),
h('div', { class: 'mt16' }, balanceCard()),
ui.offlineFallback && wallet.offline ? offlineBanner() : null,
+ safeBootNotice(),
backupNotice(),
...featureAll('walletNotices'),
tabsBar(),
@@ -4459,6 +4559,24 @@ function backupNotice() {
} }, t('backupReminderGo')));
}
+// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
+// row, so this boot made no automatic connections. Say so, name the phase
+// the last boot reached, and offer the way back.
+function safeBootNotice() {
+ if (!safeBoot()) return null;
+ const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?';
+ const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'),
+ 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n');
+ return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' },
+ h('b', {}, t('safeBootTitle')),
+ h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })),
+ h('div', { class: 'row', style: 'gap:8px' },
+ h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')),
+ h('button', { class: 'btn-sm', onClick: (e) => {
+ navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {});
+ } }, t('safeBootCopy'))));
+}
+
function offlineBanner() {
return h(
'div',
@@ -6612,9 +6730,8 @@ if (INTENT_URI) {
// The feed scrolls like a phone on a desktop. A mouse wheel's notch gives
// it a push that coasts and slows, so a few turns travel a few posts and
// quick turns stack up; a trackpad (which already coasts) is left alone.
-// And a mouse can grab the feed and flick it: drag up or down, let go while
-// moving and it keeps going. A drag that starts sideways is still a text
-// selection, and a drag that moved isn't a click on what it started on.
+// (A mouse drag used to grab the feed and flick it; it selects text again —
+// Adam, 2026-10-18: "i'd rather be able to select text normally".)
(() => {
if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// The wheel coasts whenever a feed or a thread is the page being scrolled
@@ -6736,49 +6853,7 @@ if (INTENT_URI) {
// anything else that moves the page takes over from a coast
window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); });
window.addEventListener('touchstart', stop, { passive: true });
- // ---- grab and flick
- let drag = null;
- document.addEventListener('pointerdown', (e) => {
- if (raf) stop(); // a click catches a coasting page, like a finger does
- if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return;
- drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] };
- });
- document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out
- document.addEventListener('pointermove', (e) => {
- if (!drag) return;
- if (!(e.buttons & 1)) { drag = null; return; }
- if (drag.sel) return;
- if (!drag.on) {
- const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y);
- if (dx < 6 && dy < 6) return;
- // clearly sideways: selecting text (and still not a click on the post)
- if (dx > 2 * dy) { drag.sel = true; return; }
- drag.on = true;
- document.documentElement.classList.add('grab-scrolling');
- try { getSelection().removeAllRanges(); } catch {}
- }
- window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' });
- drag.ly = e.clientY;
- drag.pts.push([e.timeStamp, e.clientY]);
- while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift();
- });
- const end = (e) => {
- if (!drag) return;
- const d = drag; drag = null;
- if (!d.on && !d.sel) return;
- if (d.on) {
- document.documentElement.classList.remove('grab-scrolling');
- const p = d.pts, a = p[0], b = p[p.length - 1];
- // released while moving: throw it; held still first: just let go
- if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 2.4);
- }
- // the press moved: what it started on isn't opened (Chrome still clicks it)
- const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
- document.addEventListener('click', eat, { capture: true, once: true });
- setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 300);
- };
- document.addEventListener('pointerup', end);
- document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; });
+ document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does
})();
window.addEventListener('load', () => {
@@ -6790,6 +6865,7 @@ window.addEventListener('load', () => {
}, 1000);
});
loadLocale(getLang()).finally(async () => {
+ bootPhase('boot');
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
// Public read-only community chat: no wallet, no login — the join material
// ships in the bundle, so the page decrypts client-side (src/public-chat.js).
@@ -6815,6 +6891,7 @@ loadLocale(getLang()).finally(async () => {
// and a wallet opening in fiat should open with a number in it.
refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {});
setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000);
+ bootPhase('store');
const storeReady = featureHook('arkStoreReady');
// the IndexedDB caches (profiles, reports, …) open alongside: a wallet's
// caches are read as soon as it opens
@@ -6825,6 +6902,8 @@ loadLocale(getLang()).finally(async () => {
_bootDeciding = false; // from here on the screen is a decision, not a guess
const opened = restoreAccountsState();
if (!opened) render();
+ bootPhase('ui');
+ setTimeout(bootSettled, 8000);
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is
// nothing to answer with, so say so now.