aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js23
-rw-r--r--src/features/messages.js46
2 files changed, 54 insertions, 15 deletions
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 = [];