diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 42 | ||||
| -rw-r--r-- | src/calls.js | 66 | ||||
| -rw-r--r-- | src/features/ark.js | 23 | ||||
| -rw-r--r-- | src/features/messages.js | 46 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/style.css | 6 |
6 files changed, 157 insertions, 31 deletions
@@ -2974,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 @@ -3631,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')), diff --git a/src/calls.js b/src/calls.js index 66d6f16..6f18aec 100644 --- a/src/calls.js +++ b/src/calls.js @@ -71,6 +71,8 @@ export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdp // trickle() false for a signer that's slow or asks each time (a // bunker, Amber): the candidates go inside the offer or // answer instead of one signed message each +// warm(pk) a call with them may be coming: look up what sending to +// them takes (their relays, TURN) ahead of it // RTCPeerConnection (tests may pass their own) export function createCalls(env) { const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection; @@ -103,8 +105,10 @@ export function createCalls(env) { endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS); } + // (iceCandidatePoolSize: the routes — TURN's included, which take a round + // trip to allocate — are gathered from the start, not from the answer on) async function connection(c) { - const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle' }); + const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 }); c.pc = pc; c.remote = new MediaStream(); pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); }; @@ -121,17 +125,47 @@ export function createCalls(env) { if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); } else if (s === 'failed') end('failed', c); }; - for (const t of c.local.getTracks()) pc.addTrack(t, c.local); + if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local); return pc; } - // without trickle: the description once its candidates are gathered (a few seconds at most) + // While a call rings: its connection made and the offer set, the routes + // gathering — everything but the microphone and camera, which wait for + // Answer. Answering then only adds them and replies (it used to do all of + // this after the tap, seconds of "Connecting…"). + async function prepare(c) { + const pc = await connection(c); + if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; } + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + return pc; + } + // our tracks into the transceivers the offer made + function attachLocal(c) { + const trs = c.pc.getTransceivers(); + for (const track of c.local.getTracks()) { + const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped); + if (tr) { + tr.sender.replaceTrack(track); + tr.direction = 'sendrecv'; + try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} + } else c.pc.addTrack(track, c.local); + } + } + // Without trickle: the description once it holds routes enough — one + // through the router (srflx) and one through TURN (relay) — or after a + // second and a half. Waiting for gathering to finish took up to 4 s (TURN + // over TCP and TLS is slow to give up), all of it after the tap. async function described(c) { const pc = c.pc; - if (!c.trickle && pc.iceGatheringState !== 'complete') { + const enough = () => pc.iceGatheringState === 'complete' + || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp)); + if (!c.trickle && !enough()) { await new Promise((resolve) => { - const done = () => { if (pc.iceGatheringState === 'complete') resolve(); }; - pc.addEventListener('icegatheringstatechange', done); - setTimeout(resolve, 4000); + const check = () => { if (enough()) resolve(); }; + pc.addEventListener('icegatheringstatechange', check); + pc.addEventListener('icecandidate', check); + setTimeout(resolve, 1500); }); } return pc.localDescription.sdp; @@ -169,12 +203,18 @@ export function createCalls(env) { c.state = 'connecting'; changed(); try { - c.local = await env.media(c.type, c.facing); + const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]); + c.local = local; if (call !== c) { stopMedia(c); return; } - const pc = await connection(c); - await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); - c.remoteSet = true; - await addCandidates(c); + let pc = ready; + if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c); + else { // nothing made while it rang (or it failed): all of it now + try { pc && pc.close(); } catch {} + pc = await connection(c); + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + } await pc.setLocalDescription(await pc.createAnswer()); const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c))); await env.send(c.peer, signed); @@ -261,6 +301,8 @@ export function createCalls(env) { const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' }; ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000))); changed(); + try { if (env.warm) env.warm(from); } catch {} + n.prep = prepare(n).catch(() => null); return; } case CALL.ANSWER: { diff --git a/src/features/ark.js b/src/features/ark.js index 1619ce4..abb297a 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1430,6 +1430,25 @@ export function arkFeature(ctx) { ); } + // a movement as a line of the history export (Settings → Wallet): what + // was paid, the fee beside it, and the change to the balance — a failed + // payment moved nothing; a renewal moved nothing but its fee + function arkCsvRow(m) { + if (m.status === 'failed') return null; + const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type); + const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0; + const amount = m.type === 'refresh' ? 0 : m.amountSat || 0; + const viaLn = m.type.startsWith('ln-') || m.viaLn; + return { + ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type, + rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark', + amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount, + status: m.status === 'complete' ? 'complete' : 'pending', + ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '', + note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '', + }; + } + const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now(); // A Lightning payment (a zap, mostly) still settling: the money has left // the balance, so history shows it now as the Sent row it will become. @@ -3956,7 +3975,7 @@ export function arkFeature(ctx) { const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash)); const lnPending = (s.actions || []) .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash)) - .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; }); + .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; }); // Same-payment duplicates (the old settle race, or a snapshot adopted // whole before the merge learned to dedupe) fold at display time too — // the earliest telling wins, everywhere, however the state got here. @@ -4009,7 +4028,7 @@ export function arkFeature(ctx) { out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] }); } } - return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }))); + return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }))); }, historyDetail() { if (ui.arkExitDetail) { diff --git a/src/features/messages.js b/src/features/messages.js index 54c9cfe..8f0ed42 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7666,6 +7666,7 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; + if (mediaMode === 'live') return; if (!listView()) { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) @@ -8569,13 +8570,13 @@ export function messagesFeature(ctx) { const s = liveStatus(ev); return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); } - // the row of streams above a feed's posts - function liveStrip(def) { - if (mediaMode === 'articles') return null; - refreshLive(); - const evs = liveForFeed(def).slice(0, 12); - if (!evs.length) return null; - return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => { + // the feed's Live chip: its streams on now, as a grid of tiles + function liveGrid(def) { + const evs = liveForFeed(def).slice(0, 60); + if (!evs.length) return liveAsking + ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) + : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')); + return h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map((ev) => { const host = liveHost(ev), n = liveViewers(ev); return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, @@ -9120,6 +9121,7 @@ export function messagesFeature(ctx) { mayRing: async (pk) => mayRing(pk), changed: () => { callRing(); render(); }, ended: (rec) => { logCall(rec).catch(() => {}); }, + warm: (pk) => callWarm(pk), // a key of our own signs each candidate at once; a remote signer gets // them all inside the offer or answer instead trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, @@ -9238,6 +9240,17 @@ export function messagesFeature(ctx) { } } catch {} } + // A call with them may be coming (it rings, or their conversation is open): + // their relays looked up, TURN fetched — the seconds the first send or + // answer used to spend on it. Not through Amber: a prompt to open a chat. + const warmed = new Set(); + function callWarm(pk) { + if (!pk || warmed.has(pk)) return; + warmed.add(pk); + inboxFor(pk).catch(() => {}); + const login = hook('nostrLoginIdentity'); + if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {}); + } function startCall(peer, type) { if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } const cur = callsNow().call; @@ -12745,7 +12758,7 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'live' || m === 'articles') mediaMode = m; } catch {} // Notes and Conversations are lists of posts (the feed's own state, or its // Conversations view); the rest are grids read from the media index. const listView = () => mediaMode === 'all' || mediaMode === 'replies'; @@ -13443,8 +13456,13 @@ export function messagesFeature(ctx) { class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m, onClick: () => setMediaMode(m), }, label); + // Live wears a red dot while someone in the feed is streaming + const def = feedDef(curFeedId); + const onAir = def && liveForFeed(def).length > 0; return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); + b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), + b('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')), + b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -14868,8 +14886,7 @@ export function messagesFeature(ctx) { ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), - mediaModes(), - liveStrip(def), + (refreshLive(), mediaModes()), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -14898,6 +14915,7 @@ export function messagesFeature(ctx) { : h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('feedNoQuery')), h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) + : mediaMode === 'live' ? liveGrid(def) : (c.booting || c.status === 'loading') && !visible.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length @@ -14909,7 +14927,8 @@ export function messagesFeature(ctx) { // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) + mediaMode === 'live' ? null + : c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) @@ -16467,6 +16486,7 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); + if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)); // a first message may go by White Noise: have their KeyPackage looked up before Send wnKnow(peer); @@ -17312,7 +17332,7 @@ export function messagesFeature(ctx) { clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; - if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; callRing(); } + if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); } listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; diff --git a/src/i18n.js b/src/i18n.js index b7432b0..fa21732 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -224,6 +224,9 @@ const STR = { moveToSavingDesc: 'Park money in ordinary on-chain Bitcoin.', moveToSavingBtn: 'Move to Savings', spendingLabel: 'Spending', + historyExportTitle: 'Transaction history', + historyExportDesc: 'Download every payment in Spending and Savings as a CSV file, for a spreadsheet or your accountant.', + historyExportBtn: 'Export CSV', savingLabel: 'Savings', balanceSwitch: 'Switch to {name}', movingLabel: 'Moving…', @@ -348,10 +351,12 @@ const STR = { mediaConversations: "Conversations", mediaImages: "Images", mediaVideos: "Videos", + mediaLive: "Live", mediaArticles: "Articles", mediaNoArticles: "No articles in this feed yet.", mediaNoImages: "No pictures in this feed yet.", mediaNoVideos: "No vertical videos in this feed yet.", + mediaNoLive: "Nobody in this feed is live right now.", feedRelays: 'Relays', feedRelaysFormHelp: 'Read this feed from these relays only. Leave empty for the usual ones.', feedAllToggle: 'Every post on these relays (a firehose)', diff --git a/src/style.css b/src/style.css index 235cb4b..fcef5a0 100644 --- a/src/style.css +++ b/src/style.css @@ -894,9 +894,9 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .chat-avatar.live-ring:not(.mini)::after { content: 'LIVE'; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%); padding: 0 4px; border-radius: 3px; font-size: 9px; font-weight: 700; line-height: 13px; letter-spacing: .04em; color: #fff; background: #e0245e; box-shadow: 0 0 0 2px var(--paper); text-transform: none; } -.live-strip { display: flex; gap: 10px; overflow-x: auto; padding: 2px 0 4px; scrollbar-width: none; } -.live-strip::-webkit-scrollbar { display: none; } -.live-tile { flex: 0 0 168px; display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; +.live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px 10px; padding: 2px 0 4px; } +.live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 5px; vertical-align: 1px; } +.live-tile { display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; } .live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--line) center / cover no-repeat; } |
