// Generated, do not edit. sha256:bcb22b0408193ecc7c34d5e55a86c15a89be89fdd2d96b8d21f03771f4179be1 import { localDate } from '../../shared/time.js'; import { listAccounts } from '../data/accounts.js'; import { listLabels, createLabel } from '../data/labels.js'; import { getPref, setPref } from '../data/preferences.js'; import { walletTxEventMap, onchainAsset, syncAddressLinks } from '../data/tx-links.js'; import { sourceBalance, accountOnchainBalance } from '../data/wallet-addresses.js'; import { listWalletSources } from '../data/wallet-sources.js'; import { listSourceTxs, setTxLabels, labelChannelTxs } from '../data/wallet-txs.js'; import { syncTxProposals } from '../data/proposals.js'; import { t, localeTag } from '../i18n/index.js'; import { fmtInt, fmtUnit, val } from '../ui/format.js'; import { matchDot, openMatchPopover } from '../ui/match-popover.js'; import { copyButton } from '../ui/copy-button.js'; import { CHEVRON, ARROW_DOWN, ARROW_UP } from '../ui/icons.js'; import { isOnchainSource, scanSource } from '../onchain.js'; import { loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js'; import { originHtml } from '../ui/price-origin.js'; import { labelComboMarkup, labelComboMount } from '../ui/label-combobox.js'; import { resolveSelection } from '../ui/filter.js'; import { volgMasonry } from '../ui/masonry.js'; import { latestScanLog } from '../data/scan-logs.js'; import { TARGETS, targetHash } from '../../shared/config.js'; import { esc } from '../ui/escape.js'; import { emptyStateHtml } from '../ui/empty-state.js'; import { holdAttention } from '../ui/attention-ring.js'; import { sourceBadgeText } from '../ui/source-badge.js'; import { isLightningSource, scanLightningSource, lightningAccountSats, countMovements } from '../lightning/lightning-scan.js'; import { memoLine } from '../lightning/memo-line.js'; const ACCOUNTS_HASH = targetHash(TARGETS.ACCOUNTS); const trunc = s => (s && s.length > 18) ? `${s.slice(0, 8)}…${s.slice(-6)}` : (s || ''); let selectedAccounts = null; let eventMap = new Map(); let btc = null; let shownTxIds = new Set(); let newTxIds = new Set(); let scanBaseline = null; const FILTER_SCOPE = 'table.tracking'; const FILTER_KEY = 'accounts'; function saveFilter() { setPref(FILTER_SCOPE, FILTER_KEY, [...selectedAccounts]).catch(() => { }); } const COLLAPSE_KEY = 'collapsed'; let collapsedSet = null; let stopMasonry = null; async function loadCollapsed() { const ids = await getPref(FILTER_SCOPE, COLLAPSE_KEY, null); return new Set(Array.isArray(ids) ? ids : []); } function saveCollapsed() { setPref(FILTER_SCOPE, COLLAPSE_KEY, [...collapsedSet]).catch(() => { }); } export const title = () => t('tracking.title'); export const headerInColumn = true; function btcAsset() { return btc; } async function accountsWithSources() { const accounts = await listAccounts(); const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id))); return accounts.filter((a, i) => bronnen[i].length > 0); } function fmtDate(key) { const d = new Date(key + 'T00:00:00'); return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' }); } function fmtDateLong(key) { const d = new Date(key + 'T00:00:00'); return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'long', year: 'numeric' }); } function amt(amount) { const a = btcAsset(); return val(a ? fmtUnit(amount, a) : fmtInt(amount)); } function fiatSub(amount) { const f = fiatValue(amount, btcAsset()); return f ? val(f) + originHtml(fiatValueOrigin(btcAsset()), btcAsset()) : ''; } function amountHtml(amount, numClass = 't-num-md') { return `
${amt(amount)}
${fiatSub(amount)}
`; } function asideHtml(items) { const rows = items.map(i => ` `).join(''); return ``; } function stat(label, sleutel) { return `
${label}
…
`; } export async function render(el) { btc = await onchainAsset(); const accts = await accountsWithSources(); if (accts.length === 0) { el.innerHTML = `` + emptyStateHtml(t('tracking.noAccountsWithSources'), { href: ACCOUNTS_HASH, linkText: t('tracking.manageAccounts'), }); return; } const wallets = await Promise.all(accts.map(a => accountWallets(a.id))); const items = accts.map((a, i) => ({ id: a.id, label: a.name, wallets: wallets[i].length })); if (!selectedAccounts) selectedAccounts = resolveSelection({ saved: await getPref(FILTER_SCOPE, FILTER_KEY, null), items }); if (!collapsedSet) collapsedSet = await loadCollapsed(); newTxIds = new Set(); scanBaseline = null; el.innerHTML = `
${asideHtml(items)}` + `
` + `` + `
`; bindAside(el, items); renderBody(el); loadFiat().then(() => renderBody(el)).catch(() => {}); } function bindAside(el, items) { const aside = el.querySelector('.trk-aside'); const alle = aside.querySelector('[data-acct-all]'); const syncAct = () => { const allOn = items.every(i => selectedAccounts.has(i.id)); alle.textContent = allOn ? t('filter.none') : t('filter.all'); }; const zet = (sel) => { selectedAccounts = sel; for (const b of aside.querySelectorAll('[data-acct]')) { const on = selectedAccounts.has(Number(b.dataset.acct)); b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', String(on)); } syncAct(); saveFilter(); renderBody(el); }; syncAct(); alle.onclick = () => zet(items.every(i => selectedAccounts.has(i.id)) ? new Set() : new Set(items.map(i => i.id))); aside.onclick = (e) => { const b = e.target.closest('[data-acct]'); if (!b) return; const id = Number(b.dataset.acct); const next = new Set(selectedAccounts); if (next.has(id)) next.delete(id); else next.add(id); zet(next); }; el.querySelector('#scan-all').onclick = () => scanAll(el); } async function sourcesOf(accounts) { const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id))); return accounts.flatMap((a, i) => bronnen[i].map(s => ({ ...s, _account: a }))); } async function selectedSources() { return sourcesOf((await accountsWithSources()).filter(a => selectedAccounts.has(a.id))); } async function allSources() { return sourcesOf(await accountsWithSources()); } function groupWallets(sources) { const byKey = new Map(); for (const s of sources) { const named = (s.name || '').trim(); const key = named ? `a${s._account.id}:${named}` : `s${s.id}`; if (!byKey.has(key)) byKey.set(key, { account: s._account, name: named, sources: [] }); byKey.get(key).sources.push(s); } const groups = [...byKey.values()]; for (const g of groups) g.id = g.sources[0].id; return groups; } const allWallets = async () => groupWallets(await allSources()); const groupedWallets = async () => groupWallets(await selectedSources()); function sourceBadge(s) { const { label, title } = sourceBadgeText(s); return `${esc(label)}`; } async function accountWallets(accountId) { const byKey = new Map(); for (const s of await listWalletSources(accountId)) { const named = (s.name || '').trim(); const key = named || `s${s.id}`; if (!byKey.has(key)) byKey.set(key, { name: named, sources: [] }); byKey.get(key).sources.push(s); } return [...byKey.values()]; } async function renderBody(el) { const body = el.querySelector('#acc-body'); if (!body) return; eventMap = await walletTxEventMap(); const wallets = await allWallets(); const perWallet = await Promise.all(wallets.map(g => walletTxs(g.sources.map(s => s.id)))); const ids = perWallet.flat().map(tx => tx.id); if (scanBaseline) { for (const id of ids) if (!scanBaseline.has(id)) newTxIds.add(id); scanBaseline = null; } shownTxIds = new Set(ids); const shown =wallets.map((g, i) => [g, perWallet[i]]).filter(([g]) => selectedAccounts.has(g.account.id)); body.innerHTML = `
${ (await Promise.all(shown.map(([g, txs]) => walletBlock(g, txs)))).join('')}
`; await vulKolom(el, wallets, perWallet); if (stopMasonry) stopMasonry(); stopMasonry = volgMasonry(body.querySelector('.oca-grid'), ':scope > .oca-source'); for (const card of body.querySelectorAll('.oca-source[data-new]')) holdAttention(card); const btc = await btcAsset(); const openMatch = dot => openMatchPopover(dot, { kind: 'tx', id: Number(dot.dataset.matchId), assetId: btc ? btc.id : null, onChange: () => syncAddressLinks().catch(() => 0).then(() => renderBody(el)), }); body.onclick = e => { const collapseBtn = e.target.closest('[data-collapse]'); if (collapseBtn) { const id = Number(collapseBtn.dataset.collapse); const card = collapseBtn.closest('.oca-source'); const nowCollapsed = card.classList.toggle('is-collapsed'); if (nowCollapsed) collapsedSet.add(id); else collapsedSet.delete(id); collapseBtn.classList.toggle('is-open', !nowCollapsed); saveCollapsed(); return; } if (e.target.closest('summary') && e.target.closest('[data-edit-label],[data-match-trigger],[data-label-combo]')) { e.preventDefault(); } const dot = e.target.closest('[data-match-trigger]'); if (dot) { openMatch(dot); return; } const trigger = e.target.closest('[data-edit-label]'); if (trigger) openLabelEditor(el, trigger); }; body.onkeydown = e => { if (e.key !== 'Enter' && e.key !== ' ') return; const dot = e.target.closest('[data-match-trigger]'); if (dot) { e.preventDefault(); openMatch(dot); } }; } async function vulKolom(el, wallets, perWallet) { const zet = (sleutel, html) => { const e = el.querySelector(`[data-trk="${sleutel}"]`); if (e) e.innerHTML = html; }; const alle = await accountsWithSources(); const bronnen = await sourcesOf(alle); const bronnenVan = id => bronnen.filter(s => s._account.id === id); const saldi = new Map(); for (const a of alle) saldi.set(a.id, await accountOnchainBalance(a.id) + lightningAccountSats(bronnenVan(a.id))); for (const a of alle) { const cel = el.querySelector(`[data-acct-amt="${a.id}"]`); if (cel) cel.innerHTML = amt(saldi.get(a.id)); } const total = alle.reduce((sum, a) => sum + saldi.get(a.id), 0); zet('total', amt(total)); zet('fiat', fiatSub(total)); const { total: nTx, linked: nLinked } = countMovements(perWallet.flat(), tx => (eventMap.get(tx.id) || []).length > 0); zet('wallets', fmtInt(wallets.length)); zet('txs', fmtInt(nTx)); zet('linked', nTx ? `${fmtInt(nLinked)} / ${fmtInt(nTx)}` : '-'); const gekozen = alle.filter(a => selectedAccounts.has(a.id)); const deel = gekozen.reduce((sum, a) => sum + saldi.get(a.id), 0); zet('selection', gekozen.length === alle.length ? '' : `${t('tracking.selectedSum')} ${amt(deel)} · ${t('tracking.selectedOf', { n: gekozen.length, m: alle.length })}`); const logs = await Promise.all(gekozen.map(a => latestScanLog(a.id))); const tijden = gekozen.map((a, i) => [logs[i]?.scannedAt, ...bronnenVan(a.id).map(s => s.lastScannedAt)] .filter(Boolean).sort().at(-1)).filter(Boolean); zet('scanned', (!gekozen.length || tijden.length < gekozen.length) ? t('tracking.scanNever') : `${t('tracking.scanLast')} ${fmtDateLong(localDate(tijden.sort()[0]))}`); } async function walletBlock(g, txs) { const name = g.name || t('tracking.sourceFallback'); const saldi = await Promise.all(g.sources.filter(s => !isLightningSource(s)).map(s => sourceBalance(s.id))); const sats = saldi.reduce((sum, b) => sum + b.sats, 0) + lightningAccountSats(g.sources); const badges = g.sources.map(sourceBadge).join(''); const collapsed = collapsedSet.has(g.id); const isNew = txs.some(tx => newTxIds.has(tx.id)); return `
${esc(name)}
${esc(g.account ? g.account.name : '')}
${badges}
${amountHtml(sats)}
${walletMovements(txs)}
`; } async function walletTxs(sourceIds) { const seen = new Map(); for (const id of sourceIds) for (const tx of await listSourceTxs(id)) if (!seen.has(tx.id)) seen.set(tx.id, tx); return [...seen.values()]; } function walletMovements(txs) { if (txs.length === 0) { return `
${t('tracking.noTxs')}
`; } const groups = new Map(); for (const tx of txs) { const key = tx.blockTime ? localDate(tx.blockTime) : ''; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(tx); } const keys = [...groups.keys()].sort((a, b) => { if (a === '') return -1; if (b === '') return 1; return a < b ? 1 : (a > b ? -1 : 0); }); return keys.map(k => { const label = k === '' ? t('tracking.unconfirmed') : fmtDate(k); return `
${label}
${dayRows(groups.get(k))}
`; }).join(''); } function dayRows(txs) { const byEvent = new Map(); for (const tx of txs) { for (const ev of (eventMap.get(tx.id) || [])) { if (!byEvent.has(ev.eventId)) byEvent.set(ev.eventId, { ev, list: [] }); byEvent.get(ev.eventId).list.push(tx); } } const inGroup = new Set(); for (const { list } of byEvent.values()) if (list.length >= 2) for (const tx of list) inGroup.add(tx.id); const emitted = new Set(); return txs.map(tx => { const groups = (eventMap.get(tx.id) || []).filter(ev => byEvent.get(ev.eventId).list.length >= 2); if (groups.length === 0) return inGroup.has(tx.id) ? '' : txRow(tx); return groups.map(ev => { if (emitted.has(ev.eventId)) return ''; emitted.add(ev.eventId); const g = byEvent.get(ev.eventId); return eventGroupRow(g.ev, g.list); }).join(''); }).join(''); } function eventGroupRow(ev, list) { const out = ev.fromAccountId === list[0].accountId; const dirTitle = out ? t('tracking.dirOut') : t('tracking.dirIn'); const arrow = out ? ARROW_UP : ARROW_DOWN; const typeLabel = t('enums.event_type.' + ev.type); const other = out ? ev.to_name : ev.from_name; const desc = ev.type === 'transfer' && other ? `${typeLabel} ${out ? '→' : '←'} ${other}` : typeLabel; const allSame = list.every(x => x.labelId && x.labelId === list[0].labelId); const shown = allSame ? list[0].label_name : desc; const idsAttr = esc(JSON.stringify(list.map(x => x.id))); const feeBlock = ev.unitFee ? `
${t('tracking.fee')} ${amt(ev.unitFee)}
${fiatSub(ev.unitFee)}
` : ''; return `
${arrow}
› ${esc(shown)} ${matchDot({ matched: true, method: ev.method, kind: 'tx', id: list[0].id })}
${feeBlock}
${amt(ev.amount)}
${fiatSub(ev.amount)}
${list.map(txRow).join('')}
`; } function txRow(tx) { const dirTitle = tx.direction === 'in' ? t('tracking.dirIn') : t('tracking.dirOut'); const arrow = tx.direction === 'in' ? ARROW_DOWN : ARROW_UP; const links = eventMap.get(tx.id) || []; const memo = !tx.address ? memoLine(tx.memo, tx.direction) : null; const leeg = memo ? esc(memo.text) : t('tracking.addLabel'); const mid = tx.labelId ? `${esc(tx.label_name)}` : `${tx.address ? trunc(tx.address) : leeg}`; const copyBtn = tx.address ? copyButton(tx.address, t('tracking.copyAddr')) : ''; const feeBlock = (tx.feeSats && tx.feeSats > 0) ? `
${t('tracking.fee')} ${amt(tx.feeSats)}
${fiatSub(tx.feeSats)}
` : ''; return `
${arrow}
${mid}${copyBtn}${matchDot({ matched: links.length > 0, method: links.length ? links[0].method : undefined, kind: 'tx', id: tx.id })}
${feeBlock}
${amt(tx.valueSats)}
${fiatSub(tx.valueSats)}
`; } function openLabelEditor(el, trigger) { const ids = trigger.dataset.txIds ? JSON.parse(trigger.dataset.txIds) : [Number(trigger.dataset.txId)]; const cur = trigger.dataset.labelId ? Number(trigger.dataset.labelId) : ''; const cell = trigger.closest('[data-cell-tx]'); if (!cell) return; cell.innerHTML = `${labelComboMarkup('labelId', cur)}`; const host = cell.querySelector('[data-label-combo]'); labelComboMount(host, { listLabels, createLabel }, async id => { await setTxLabels(ids, id); renderBody(el); }); const input = host.querySelector('[data-label-input]'); if (input) input.focus(); } const logRow = (el, id) => el.querySelector(`[data-trk="scanlog"] [data-log="${id}"] .trk-scan-out`); function logGroup(lijst, account) { const key = String(account ? account.id : ''); const bestaand = [...lijst.children].find(e => e.dataset.logAcct === key); if (bestaand) return bestaand.querySelector('.trk-scan-wallets'); const groep = document.createElement('li'); groep.className = 'trk-scan-group'; groep.dataset.logAcct = key; const kop = document.createElement('div'); kop.className = 't-label t-label--ter trk-scan-acct'; kop.textContent = account ? account.name : ''; const wallets = document.createElement('ul'); wallets.className = 'trk-scan-wallets'; groep.append(kop, wallets); lijst.append(groep); return wallets; } function addLogRow(el, g) { const lijst = el.querySelector('[data-trk="scanlog"]'); if (!lijst) return; const li = document.createElement('li'); li.className = 'trk-scan-row'; li.dataset.log = g.id; const naam = document.createElement('span'); naam.className = 'trk-scan-name'; naam.textContent = g.name || t('tracking.sourceFallback'); const uit = document.createElement('span'); uit.className = 'trk-scan-out t-muted'; uit.textContent = t('tracking.scanning'); li.append(naam, uit); logGroup(lijst, g.account).append(li); } function setLogRow(el, id, cls, txt) { const e = logRow(el, id); if (e) { e.className = `trk-scan-out ${cls}`; e.textContent = txt; } } async function scanOne(el, source, logId = source.id) { return scanSource(source, txt => { const e = logRow(el, logId); if (e) e.textContent = txt; }); } async function scanAll(el) { const wallets = await groupedWallets(); if (!wallets.length) return; const knop = el.querySelector('#scan-all'); const zetStatus = txt => { const e = el.querySelector('[data-trk="scanstatus"]'); if (e) e.textContent = txt; }; const results = {}; const kanalen = []; const voorstellen = new Map(); const lnAccounts = new Set(); let nWallets = 0, nMislukt = 0; if (knop) knop.disabled = true; const lijst = el.querySelector('[data-trk="scanlog"]'); if (lijst) lijst.replaceChildren(); const voor = shownTxIds; newTxIds = new Set(); try { for (const [i, g] of wallets.entries()) { zetStatus(t('tracking.scanProgress', { i: i + 1, n: wallets.length })); addLogRow(el, g); let count = 0, failed = 0, error = false, geslagen = 0, ln = 0, lnBronnen = 0; for (const s of g.sources) { if (isLightningSource(s)) { lnBronnen++; lnAccounts.add(s.accountId); try { const r = await scanLightningSource(s); ln += r.count; if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]); } catch (err) { console.error('[lightning-scan]', err); error = err.message || true; } continue; } if (!isOnchainSource(s)) { geslagen++; continue; } try { const r = await scanOne(el, s, g.id); count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0); } catch (err) { console.error('[onchain-scan-all]', err); error = err.message || true; } } const skipped = geslagen && !lnBronnen && !count && !failed; if (error) nMislukt++; else if (!skipped) nWallets++; results[g.id] = error ? { error } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen }); } if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err)); for (const [accountId, txids] of kanalen) { await labelChannelTxs(accountId, txids).catch(err => console.error('[lightning-channel-label]', err)); } const btc = lnAccounts.size ? await btcAsset() : null; if (btc) for (const accountId of lnAccounts) { try { voorstellen.set(accountId, await syncTxProposals(accountId, btc.id)); } catch (err) { console.error('[lightning-proposals]', err); } } } finally { if (knop) knop.disabled = false; } const nVoorstellen = [...voorstellen.values()].reduce((s, n) => s + n, 0); zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt)); for (const id in results) { const r = results[id]; if (r.error) setLogRow(el, id, 't-neg', typeof r.error === 'string' ? t('tracking.scanErrorWhy', { reden: r.error }) : t('tracking.scanError')); else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain')); else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r)); } scanBaseline = voor; await renderBody(el); } function scanSummary(nWallets, nVoorstellen, nMislukt) { const deel = [`${fmtInt(nWallets)} ${nWallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')} ${t('tracking.scanUpdated')}`]; if (nVoorstellen) deel.push(`${fmtInt(nVoorstellen)} ${nVoorstellen === 1 ? t('tracking.proposalOne') : t('tracking.proposalMany')}`); if (nMislukt) deel.push(`${fmtInt(nMislukt)} ${t('tracking.scanFailedN')}`); return deel.join(' · '); } function scanDoneText(r) { const delen = []; if (r.count || r.failed || !r.lnBronnen) delen.push(`${r.count} ${t('tracking.addressesScanned')}`); if (r.failed) delen.push(`${r.failed} ${t('tracking.addressesFailed')}`); if (r.lnBronnen) delen.push(`${fmtInt(r.ln)} ${t('tracking.lightningMovements')}`); return delen.join(' · '); }