diff options
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 141 |
1 files changed, 86 insertions, 55 deletions
@@ -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, 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'; @@ -853,7 +854,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 +912,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 +920,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 +943,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 @@ -1920,7 +1924,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 +2974,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 +3031,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 +3246,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 +3631,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,10 +3895,18 @@ function mediaProgress(stage, fraction) { } async function publicMediaUpload(file) { try { - // a phone video is shrunk on the device first (see video-compress.js) + // 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, (event) => wallet.nostrSign(event), { 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); @@ -6612,9 +6686,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 +6809,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', () => { |
