diff options
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 244 |
1 files changed, 154 insertions, 90 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, 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'; @@ -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,15 +3895,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')); @@ -6612,18 +6689,29 @@ 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; - const SCOPE = '.feed-page, .notes-feed'; + // The wheel coasts whenever a feed or a thread is the page being scrolled + // — wherever the pointer happens to be: over the posts, the header, the + // rail, the wallet column beside the feed on a wide screen (Adam, + // 2026-10-16: "when my cursor is hovered over the right side bar or any + // part of the page that's not the feed, wheeling scrolls without + // coasting. can we make it coast no matter where my pointer is?") — + // except over something that takes the wheel itself: a text box, the + // chips strip, the lightbox, the pager, a box that scrolls on its own. + const SCOPE = '.feed-page, .notes-feed, .thread-page'; // the pages that coast (a thread scrolls the same as a feed) const TAU = 325; // ms: how quickly a coast dies down (the phone's feel) const VMAX = 16; // px/ms const inScope = (t) => { const el = t && t.closest ? t : t && t.parentElement; - if (!el || !el.closest(SCOPE)) return false; - if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; + if (!el || !document.querySelector(SCOPE)) return false; + // (A video or an audio player is part of the feed: a notch over a clip + // coasts like one over the words — the wheel does nothing to a player + // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a + // video it doesn't initiate a coast, just a single notch".) + if (el.closest('input, textarea, select, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; // something inside that scrolls on its own (a caption, a list) keeps its wheel for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false; @@ -6653,48 +6741,66 @@ if (INTENT_URI) { if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); } }; // ---- the wheel - // A turn back against the coast is a reader who flew past the post they - // wanted and is hunting for it: the page is caught at once, and that notch - // and the next few are short, quick steps (a quarter of a notch's travel, - // settling fast) rather than a fresh flight the other way. Each notch on - // in the same direction grows the step back towards a full one, so a few - // flicks have the wheel flying again; a turn back while hunting starts the - // small steps over, and a pause ends the hunt — the next notch is the - // plain push from rest it has always been. - const HUNT = [0.25, 0.35, 0.5, 0.7, 0.85]; // of a notch's travel, notch by notch - const HUNT_TAU = 110; // ms: a hunting step settles quickly - const HUNT_IDLE = 2000; // ms without a notch, and the hunt is over - let hunt = -1, huntDir = 0, huntAt = 0; // the next HUNT entry while hunting - const halt = () => { stop(); hunt = -1; }; + // A turn back against a page that is flying is a brake: that notch only + // eases the coast to a stop (a quick easing, not a dead halt), and goes + // nowhere the other way. The next notch the same way is a gentle start + // back — half a notch's travel at half the speed — and the ones after + // wind up as usual. So the reader flying past a post stops on the first + // turn back, creeps back on the second, and gets going on the third. + // Against a coast that is merely strolling, or one that only just died, + // there is nothing worth braking: that turn back is the gentle start + // itself. (A spell of quarter-size "hunting" notches ramping back up over + // five turns was tried here and felt like a stutter and a slow patch; a + // full notch then two without wind-up felt like an overshoot; a gentle + // push straight away from a flight still overshot — Adam, 2026-10-16: + // "the first click of the wheel should just stop the coasting/flying + // maybe with a quick easing rather than a sudden stop and then the next + // notch should start going slowly in reverse and the next ones should + // start speeding up".) + const REVERSE = 0.5; // of a notch: the gentle start back + const BRAKE_V = 1.2; // px/ms: flying, worth a brake (a notch from rest peaks near 0.9) + const BRAKE_TAU = 40; // ms: the brake's easing (a flight at 8 px/ms stops within ~300 px) + const BRAKE_FOR = 2500; // ms: how long after a brake the next notch that way is the gentle start + let brake = null; // { dir, until }: the turn back that braked, awaiting its gentle start + // (lastDir/lastMoveAt: the tick's, above) let smoothUntil = 0; // a trackpad's events were just seen: hands off window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return; const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX); - if (!notch) { smoothUntil = e.timeStamp + 400; halt(); return; } + if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; } if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return; e.preventDefault(); const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; const dir = Math.sign(px); if (!dir) return; const now = e.timeStamp; - if (hunt >= 0 && now - huntAt > HUNT_IDLE) hunt = -1; - // the way the page is going: its coast, the coast that just died, or the hunt - const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : hunt >= 0 ? huntDir : 0; - if (going && dir !== going) { stop(); hunt = 0; huntDir = dir; } - else if (hunt >= 0 && ++hunt >= HUNT.length) hunt = -1; - if (hunt >= 0) { - // A reversal catches the old coast immediately. Discard its fractional - // step and frame clock too: after a busy frame, applying the NEW velocity - // to the OLD elapsed time made the first reverse step jump. Give the - // notch a small response now, then a quick coast over the rest of its - // (short) travel; quick flicks still add up. - huntAt = now; - const travel = px * 3 * HUNT[hunt]; - const first = dir * Math.min(16, Math.abs(travel / HUNT_TAU) * 16); + // the way the page is going: its coast, or the coast that only just died + const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : 0; + const gentle = () => { + // The gentle start back catches whatever coast is left immediately. + // Discard its fractional step and frame clock too: after a busy frame, + // applying the NEW velocity to the OLD elapsed time made the first + // reverse step jump. A small response now, then the rest of its + // (half) travel coasts. + stop(); + brake = null; + const travel = px * 3 * REVERSE; + const first = dir * Math.min(16, Math.abs(travel / TAU) * 16); window.scrollBy({ top: first, behavior: 'instant' }); lastDir = dir; lastMoveAt = now; - coast(v + (travel - first) / HUNT_TAU, HUNT_TAU); + coast((travel - first) / TAU, TAU); + }; + if (going && dir !== going && Math.abs(v) >= BRAKE_V) { + // flying: this notch only brakes — the coast eases out over a few + // frames (from now: the frames a busy thread missed don't travel first) + tau = BRAKE_TAU; acc = 0; last = performance.now(); + brake = { dir, until: now + BRAKE_FOR }; + } else if (going && dir !== going) { + gentle(); + } else if (brake && brake.dir === dir && now < brake.until) { + gentle(); // the notch after a brake: the gentle start back } else { + brake = null; // A notch into a page already moving pushes harder: the faster it // goes, the more each turn adds (up to 2.5x), so keeping the wheel // turning winds the speed up instead of settling at a stroll. From @@ -6704,51 +6810,9 @@ if (INTENT_URI) { } }, { passive: false }); // anything else that moves the page takes over from a coast - window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) halt(); }); - window.addEventListener('touchstart', halt, { passive: true }); - // ---- grab and flick - let drag = null; - document.addEventListener('pointerdown', (e) => { - if (raf) halt(); // 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; }); + window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); }); + window.addEventListener('touchstart', stop, { passive: true }); + document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does })(); window.addEventListener('load', () => { |
