aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js51
1 files changed, 47 insertions, 4 deletions
diff --git a/src/app.js b/src/app.js
index 416fd59..eb51e2f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -12,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, uploadStream, 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';
@@ -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')),
@@ -3855,6 +3895,7 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
+ 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 || '')) {
@@ -3863,15 +3904,17 @@ async function publicMediaUpload(file) {
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) });
+ 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'));