aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-30 18:03:33 +0000
committerAdam Soltys <[email protected]>2026-09-30 18:03:33 +0000
commit0570f48910ddc4ea10d27fb8ab7992fbd013a0b7 (patch)
treedbc1e5ece94bc71fcd8bb7e89f61043933c1992c /src/features
parent95eaf11d116260b101c81346ba41810aacf49a7d (diff)
parentcfeacb1a7a10766565ed3eaab13154096478c837 (diff)
downloadcoinosv3-0570f48910ddc4ea10d27fb8ab7992fbd013a0b7.tar.gz
coinosv3-0570f48910ddc4ea10d27fb8ab7992fbd013a0b7.zip
Merge remote-tracking branch 'cs/prod'
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js128
-rw-r--r--src/features/messages.js946
2 files changed, 869 insertions, 205 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index d733e0b..566c402 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1667,7 +1667,8 @@ export function arkFeature(ctx) {
const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' }));
if (!live()) return;
if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; }
- if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(t('arkGiftWrongNet', { net: adv.net })); render(); return; }
+ // An advert for another network is no address here: keep looking.
+ if (adv.status === 'wrongnet') z.net = adv.net;
// 1b. BIP-353 with an ark instruction; remember on-chain as last resort
const uris = [];
try {
@@ -1699,12 +1700,14 @@ export function arkFeature(ctx) {
}
// 2. lightning via the zap flow (connect first — canLnPay needs it)
if (profile && (profile.lud16 || profile.lud06)) {
+ z.lnOk = true;
await connectArk().catch(() => {});
if (!live()) return;
if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
}
- // 3. on-chain fallback
- if (onchain) {
+ // 3. on-chain fallback (a one-tap zap can't take the send form: it
+ // goes as a locked gift below instead)
+ if (onchain && !(z.autoSat && z.eventId && zapGiftOk())) {
if (z.autoSat && z.eventId) { bail(); render(); return; }
ui.arkZap = null;
ui.send.recipients[0].address = onchain;
@@ -1712,7 +1715,23 @@ export function arkFeature(ctx) {
render();
return;
}
- if (live()) { z.status = 'noark'; bail(); render(); }
+ if (!live()) return;
+ z.status = z.net ? 'wrongnet' : 'noark';
+ // 4. last resort: an ark gift locked to their nostr key, DMed to them.
+ // To the payer it's just a zap.
+ if (z.autoSat && zapGiftOk(z.autoSat)) {
+ try {
+ await performArkZapGift(z, z.autoSat);
+ if (ui.arkZap === z) ui.arkZap = null;
+ ctx.hook('zapSettled', z.eventId, true, z.autoSat);
+ if (!z.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() }));
+ } catch (e) {
+ if (!bail(e.message)) ui.sendError = e.message;
+ }
+ render();
+ return;
+ }
+ bail(z.net ? t('arkGiftWrongNet', { net: z.net }) : undefined); render();
})().catch((e) => { if (live()) { z.status = 'noark'; if (!bail(e.message)) ui.sendError = e.message; render(); } });
}
@@ -1828,6 +1847,34 @@ export function arkFeature(ctx) {
// safe to put in the receipt because only the recipient's key — or the
// claim code we DM them — opens it. Unclaimed, it stays revocable from the
// gift card like any other ark gift.
+ // Can a zap to this person leave as a locked gift (this build carries
+ // gifts, the wallet can sign, and Spending covers it)?
+ function zapGiftOk(sats = 330) {
+ return !wallet.watchOnly && (arkBalance()?.spendableSat || 0) >= Math.max(330, sats) && !!ctx.hook('canLockGift');
+ }
+ async function performArkZapGift(z, sats) {
+ const g = await createArkGift(sats, z.pk);
+ const locked = ctx.hook('lockArkGift', g.code, sats, z.pk);
+ if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build
+ noteZap('to:' + g.address, z.pk);
+ const gift = { url: locked.url, claimCode: locked.claimCode, dm: 'sending' };
+ // best-effort receipt; carries the locked link so the recipient can
+ // discover the gift from the note even if the DM never lands
+ wallet.nostrPublish({
+ kind: ARK_ZAP_KIND,
+ content: (z.comment || '').slice(0, 280),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
+ }).catch(() => {});
+ const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
+ if (wallet.sendNostrDM) {
+ wallet.sendNostrDM(z.pk, dmText)
+ .then((ok) => { gift.dm = ok ? 'sent' : 'failed'; render(); })
+ .catch(() => { gift.dm = 'failed'; render(); });
+ } else gift.dm = 'failed';
+ return gift;
+ }
+
async function doArkZapGift() {
const z = ui.arkZap;
const sats = ctx.parseAmount(z.amount, ctx.getUnit());
@@ -1835,27 +1882,11 @@ export function arkFeature(ctx) {
if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; }
ui.busy = true; ui.sendError = ''; render();
try {
- const g = await createArkGift(sats, z.pk);
- const locked = ctx.hook('lockArkGift', g.code, sats, z.pk);
- if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build
- noteZap('to:' + g.address, z.pk);
- const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } });
+ const gift = await performArkZapGift(z, sats);
+ ctx.hook('zapSettled', z.eventId, true, sats);
+ ui.arkZapped = { amountSat: sats, npub: z.npub, gift };
ui.arkZap = null;
ui.send = blankSend();
- // best-effort receipt; carries the locked link so the recipient can
- // discover the gift from the note even if the DM never lands
- await wallet.nostrPublish({
- kind: ARK_ZAP_KIND,
- content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
- ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
- }).catch(() => {});
- const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
- if (wallet.sendNostrDM) {
- wallet.sendNostrDM(z.pk, dmText)
- .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); })
- .catch(() => { done.gift.dm = 'failed'; render(); });
- } else done.gift.dm = 'failed';
} catch (e) {
ui.sendError = e.message;
}
@@ -1870,7 +1901,9 @@ export function arkFeature(ctx) {
if (ui.arkZapped) {
const gift = ui.arkZapped.gift;
const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); };
- if (!gift) {
+ // A gift zap looks like any other zap. Only a DM that couldn't be
+ // delivered needs the payer: the link and code to pass along by hand.
+ if (!gift || gift.dm !== 'failed') {
return h('div', {
class: 'card col',
style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px',
@@ -1881,29 +1914,25 @@ export function arkFeature(ctx) {
h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()),
h('div', { class: 'small muted' }, t('tapToProceed')));
}
- // A locked-gift zap has aftercare: the DM's fate, and the link + claim
- // code to pass along by hand when the DM couldn't be delivered.
return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' },
- h('div', { class: 'check-badge' }, '🎁'),
- h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')),
+ h('div', { class: 'check-badge' }, '⚡'),
+ h('h2', { style: 'margin:0' }, t('arkZapSentTitle')),
h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()),
- gift.dm === 'sending'
- ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))
- : gift.dm === 'sent'
- ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent'))
- : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')),
+ h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url),
h('div', { class: 'row gap6 wrap', style: 'justify-content:center' },
copyBtn(gift.url, t('copyLink')),
- gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null),
+ copyBtn(gift.claimCode, t('giftCopyCode'))),
h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done')));
}
const z = ui.arkZap;
if (!z) return null;
const spendable = arkBalance()?.spendableSat || 0;
- // No ark address found — but a zap can still leave as an ark gift locked
- // to their nostr key, when this build carries the gifts feature.
- const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift');
+ // No ark address or Lightning found — the zap still leaves, as an ark
+ // gift locked to their nostr key. The payer sees an ordinary zap.
+ const noAddr = z.status === 'noark' || z.status === 'wrongnet';
+ const giftOk = noAddr && !wallet.watchOnly && !!ctx.hook('canLockGift');
+ const payable = z.status === 'ready' || giftOk;
const amountInputs = (hint) => h('div', { class: 'col gap6' },
h('div', { class: 'input-group' },
h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount,
@@ -1918,31 +1947,24 @@ export function arkFeature(ctx) {
ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub),
z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null,
z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null,
- giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null,
- giftOk ? amountInputs(t('arkZapGiftHint')) : null,
- giftOk
- ? (ui.busy
- ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
- : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn')))
- : null,
- z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null,
- // No Ark address, but they may still take a Lightning zap — hand off to
- // the zaps feature (present alongside swaps).
- (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap')
- ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
+ z.status === 'wrongnet' && !giftOk ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null,
+ // Only someone with a Lightning address gets the Lightning door (the
+ // lookup normally hands such a person straight to the zaps feature).
+ noAddr && z.lnOk && ctx.hook('canLnZap')
+ ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
: null,
- z.status === 'ready' && spendable < 330
+ payable && spendable < 330
? h('div', { class: 'notice info' }, t('zapNoBalance'))
- : z.status === 'ready'
+ : payable
? amountInputs(t('arkZapHint'))
: null,
ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')),
- z.status === 'ready' && spendable >= 330
+ payable && spendable >= 330
? (ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
- : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn')))
+ : h('button', { class: 'btn-primary grow', onClick: z.status === 'ready' ? doArkZap : doArkZapGift }, t('arkZapBtn')))
: null));
}
diff --git a/src/features/messages.js b/src/features/messages.js
index f00a52b..2f4446e 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -446,6 +446,8 @@ export function messagesFeature(ctx) {
if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk };
return null;
}
+ // what a feed or a profile lists: notes, and polls (NIP-88)
+ const FEED_KINDS = [1, 1068];
const myPubkeys = () => {
const pks = [];
const id = hook('nostrLoginIdentity');
@@ -553,6 +555,29 @@ export function messagesFeature(ctx) {
profilesWarmed = true;
const cached = profilesDisk();
for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p);
+ // someone known to have no face paints their punk from the first frame,
+ // instead of a blank circle that turns into one when the relays answer
+ for (const [pk, t] of Object.entries(facelessDisk())) {
+ if (!profiles.has(pk)) profiles.set(pk, { name: null, picture: null, t, miss: 1 });
+ }
+ }
+ // People with no face: no kind 0 on two asks running, or one naming
+ // nothing. Kept apart from the profile blob (whose 150 rows are for real
+ // faces) as a small pubkey -> time map; one found with a face leaves it.
+ const FACELESS_MAX = 1000;
+ let facelessBlob = null, facelessFlush = null;
+ const facelessDisk = () => (facelessBlob ||= wallet.loadFeatureState('profilesFaceless', {}) || {});
+ function markFaceless(pk, on) {
+ const s = facelessDisk();
+ if (on ? s[pk] : !s[pk]) return;
+ if (on) s[pk] = Date.now(); else delete s[pk];
+ if (facelessFlush) return;
+ facelessFlush = setTimeout(() => {
+ facelessFlush = null;
+ const keys = Object.keys(s);
+ if (keys.length > FACELESS_MAX) for (const k of keys.sort((a, b) => s[a] - s[b]).slice(0, keys.length - FACELESS_MAX)) delete s[k];
+ wallet.saveFeatureState('profilesFaceless', s);
+ }, 500);
}
// The persisted profile blob (names, faces, thumbnails) lives parsed in
// memory and is written back once per burst: a batch of thirty profiles
@@ -590,9 +615,10 @@ export function messagesFeature(ctx) {
wallet.saveFeatureState('profiles', s);
}
function persistProfile(pk, p) {
- // A faceless answer is not a fact worth writing down — persisting
- // {name:null, picture:null} rows only evicts real faces from the cap and
- // spreads a cold-relay miss across sessions.
+ // A faceless answer is not a fact worth writing down here — persisting
+ // {name:null, picture:null} rows only evicts real faces from the cap.
+ // (It goes on the faceless list instead.)
+ if (p && p.eventAt) markFaceless(pk, !p.name && !p.picture);
if (!p || (!p.name && !p.picture)) return;
const s = profilesDisk();
s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null,
@@ -925,7 +951,11 @@ export function messagesFeature(ctx) {
for (const pk of pks) {
if (found.has(pk)) continue;
const prev = profiles.get(pk);
- profiles.set(pk, { ...(prev || {}), t: Date.now(), miss: ((prev && prev.miss) || 0) + 1 });
+ const miss = ((prev && prev.miss) || 0) + 1;
+ profiles.set(pk, { ...(prev || {}), t: Date.now(), miss });
+ // remembered, so the next load paints their punk at once (never
+ // someone whose face we do know; a face found later replaces it)
+ if (!(prev && (prev.name || prev.picture))) markFaceless(pk, true);
}
scheduleRepaint();
} finally {
@@ -1334,6 +1364,16 @@ export function messagesFeature(ctx) {
save(s);
}, 800);
}
+ // Cancel means discard, including across an immediate reload. Typing can
+ // use the debounce above; an explicit destructive action is persisted now.
+ function discardDraft(key) {
+ sessionDrafts.set(key, '');
+ clearTimeout(draftPersist); draftPersist = 0;
+ const s = st();
+ s.drafts ||= {};
+ for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; }
+ save(s);
+ }
// The morph never rewrites a focused field's value (it would fight the user
// mid-keystroke), and the composer is focused at the moment you send — so
@@ -2878,6 +2918,18 @@ export function messagesFeature(ctx) {
return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`;
}
+ const FACE_WAIT_MS = 1000;
+ const faceAsked = new Map(); // pk -> when its face was first wanted this session
+ let faceTimer = null;
+ function faceWaiting(pk) {
+ let at = faceAsked.get(pk);
+ if (at == null) { at = Date.now(); faceAsked.set(pk, at); }
+ const left = at + FACE_WAIT_MS - Date.now();
+ if (left <= 0) return false;
+ // repaint when the wait runs out, so the punk shows without an event
+ if (!faceTimer) faceTimer = setTimeout(() => { faceTimer = null; scheduleRepaint(); }, left + 20);
+ return true;
+ }
const avatar = (pk, cls = 'chat-avatar', clickable = true) => {
const p = profileOf(pk);
// Someone we've never cached used to get an empty circle until a relay
@@ -2891,12 +2943,15 @@ export function messagesFeature(ctx) {
// `loading` is different and still gets the quiet circle: a name lookup
// is in flight for that specific person, so a picture is expected and
// punk art must not flash in front of it.
+ // A face still being looked up gets a quiet circle for a second at
+ // most, then its punk — a picture found later replaces it.
+ const waiting = (p === null || (p && p.loading && !p.picture)) && faceWaiting(pk);
const node = feedPaint && !p.picture
? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2))
- : p && p.loading && !p.picture
- ? h('div', { class: cls + ' fallback loading' })
- : p === null && ui.noteThread
+ : waiting && (p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room'))
? h('div', { class: cls + ' fallback loading' })
+ : p && p.loading && !p.picture
+ ? fallbackAvatar(h, pk, p.name, cls)
: p === null
? fallbackAvatar(h, pk, null, cls)
: p.picture
@@ -3021,7 +3076,31 @@ export function messagesFeature(ctx) {
}
}
+ // A profile opens at its top: keeping the feed's scroll offset landed the
+ // reader deep in that person's posts, which read as a thread. Back (a
+ // popstate) puts them where they were on the page they left.
+ // The same for a post opened into its thread: Back lands on the row the
+ // reader tapped, not the top of the feed.
+ const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
+ const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ : ui.profilePk ? 'p:' + ui.profilePk : '';
+ function rememberPlace(key) {
+ if (pageKey() === key) return;
+ let y = 0; try { y = window.scrollY || 0; } catch {}
+ pageReturn.push({ key, y });
+ if (pageReturn.length > 20) pageReturn.shift();
+ }
+ if (typeof window !== 'undefined') window.addEventListener('popstate', () => {
+ const top = pageReturn[pageReturn.length - 1];
+ if (!top || pageKey() === top.key) return;
+ pageReturn.pop();
+ const y = top.y;
+ // twice: the page under it may paint its rows a frame late
+ requestAnimationFrame(() => { try { window.scrollTo(0, y); } catch {}
+ requestAnimationFrame(() => { try { if (Math.abs(window.scrollY - y) > 2) window.scrollTo(0, y); } catch {} }); });
+ });
function openProfile(pk) {
+ rememberPlace('p:' + pk);
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -3030,6 +3109,7 @@ export function messagesFeature(ctx) {
ui.profOverThread = !!ui.noteThread;
ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null;
render();
+ try { window.scrollTo(0, 0); } catch {}
fetchFullProfile(pk);
notesFor(pk);
}
@@ -3447,7 +3527,7 @@ export function messagesFeature(ctx) {
: { status: 'loading', notes: seed };
notesCache.set(pk, c);
(async () => {
- const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500);
+ const evs = await queryOn(await notesRelays(pk), { kinds: FEED_KINDS, authors: [pk], limit: 30 }, 4500);
const seen = new Set();
const fresh = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
@@ -3493,7 +3573,7 @@ export function messagesFeature(ctx) {
render();
try {
const evs = await queryOn(await notesRelays(pk),
- { kinds: [1], authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500);
+ { kinds: FEED_KINDS, authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500);
const seen = new Set(c.notes.map((e) => e.id));
const older = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
@@ -3819,6 +3899,8 @@ export function messagesFeature(ctx) {
const FEED_LS = 'btc-wallet-feed'; // the feed last on screen, remembered per device
const feedStates = new Map(); // id -> state
const adhocFeeds = new Map(); // id -> definition, this session only
+ // the feed of your own you last had on (never a session one)
+ const homeFeedId = () => { let id = FOLLOWING; try { id = localStorage.getItem(FEED_LS) || FOLLOWING; } catch {} return feedDef(id) && !adhocFeeds.has(id) ? id : FOLLOWING; };
let curFeedId = (() => { try { return localStorage.getItem(FEED_LS) || FOLLOWING; } catch { return FOLLOWING; } })();
// Two feeds everyone has: the people you follow, and everything the coinos
// relay carries. Each remembers its own relay choice in the state.
@@ -4011,7 +4093,13 @@ export function messagesFeature(ctx) {
};
// posts on a topic come from the big public relays as well as ours: a
// hashtag has no author whose outbox we could read
- const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol'])];
+ // A topic has no authors whose relays to read, and the big relays prune:
+ // #gardenstr was 5 posts across damus/primal/nos.lol/ours, 774 once these
+ // archive-keeping relays were asked too (ditto 500+, nostr.mom 448,
+ // oxtr 247, nostrplebs 175, nostr21 102, offchain 74 — 2026-09-30).
+ const TOPIC_ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr.mom', 'wss://nostr.oxtr.dev',
+ 'wss://relay.nostrplebs.com', 'wss://nostr21.com', 'wss://offchain.pub'];
+ const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', ...TOPIC_ARCHIVE_RELAYS])];
// ---- lists (NIP-51 follow sets, kind 30000) ------------------------------
// A feed of hand-picked people IS a nostr list. Saved here it is published
@@ -4157,7 +4245,7 @@ export function messagesFeature(ctx) {
if (!ui.listPick) return null;
const pk = ui.listPick;
const close = () => { ui.listPick = null; render(); };
- const feeds = st().feeds;
+ const feeds = st().feeds.filter((f) => !f.of); // a timeline is someone, not a list
const toggle = (f) => {
const s = st();
const def = s.feeds.find((x) => x.id === f.id);
@@ -4229,8 +4317,37 @@ export function messagesFeature(ctx) {
for (const pk of def.authors || []) if (pk) set.add(pk);
for (const pk of def.priv || []) if (pk) set.add(pk); // a list's private members
for (const pk of packAuthors(def)) set.add(pk);
+ if (def.of) for (const pk of ofFollows.get(def.of)?.authors || []) set.add(pk);
return [...set].slice(0, FEED_AUTHORS_MAX);
};
+ // Someone's timeline is their follow list, read from the relays when the
+ // feed is opened (and again after ten minutes) — never copied into the
+ // feed itself, so a saved one stays small in sync and follows them.
+ const ofFollows = new Map(); // pk -> { authors, at, loading }
+ const ofLoaded = (def) => !!ofFollows.get(def.of)?.at;
+ function loadOfFollows(def) {
+ const pk = def.of;
+ let f = ofFollows.get(pk);
+ if (!f) { f = { authors: [], at: 0, loading: false }; ofFollows.set(pk, f); }
+ if (f.loading || (f.at && Date.now() - f.at < 600_000)) return;
+ f.loading = true;
+ liveProfileOf(pk); // their name and face for the title
+ queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000)
+ .catch(() => [])
+ .then((evs) => {
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ const had = f.at;
+ if (newest || !had) f.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
+ f.at = Date.now(); f.loading = false;
+ // whatever was built before the list was known
+ for (const id of [...feedStates.keys()]) {
+ if (feedDef(id)?.of !== pk || id === curFeedId) continue;
+ dropFeedState(id);
+ }
+ if (feedDef(curFeedId)?.of === pk) { dropFeedState(curFeedId); selectFeed(curFeedId); }
+ scheduleRepaint();
+ });
+ }
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = FOLLOWING; // a feed deleted on another device
@@ -4276,10 +4393,10 @@ export function messagesFeature(ctx) {
return;
}
try {
- const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: [1], limit: FEED_PAGE,
+ const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE,
...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000);
if (c.stopped) return;
- await mergeFeed(events.filter((e) => e.kind === 1 && !isReply(e) && !hidden(e))
+ await mergeFeed(events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e))
.sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE), {}, c);
} catch {} finally {
if (!c.stopped) {
@@ -4292,6 +4409,7 @@ export function messagesFeature(ctx) {
// history restores ui.feedId and the view catches up from here.
function selectFeed(id) {
if (!feedDef(id)) return;
+ if (feedDef(id).of) loadOfFollows(feedDef(id));
if (id !== curFeedId) {
curFeedId = id;
if (!adhocFeeds.has(id)) { try { localStorage.setItem(FEED_LS, id); } catch {} }
@@ -4299,7 +4417,7 @@ export function messagesFeature(ctx) {
ui.feedId = id;
stopFeedWatch();
const c = feedNow();
- c.unseen = 0; c.shown = FEED_PAGE;
+ c.unseen = 0; c.fresh = null; c.shown = FEED_PAGE;
admitFeed(c.deferred || [], c, true);
// An already warmed feed can paint synchronously on entry.
if (!c.booting) c.presentations.clear();
@@ -4328,19 +4446,9 @@ export function messagesFeature(ctx) {
const id = 'of:' + pk + (readFrom.length ? '@' + readFrom.join(',') : '');
let def = adhocFeeds.get(id);
if (!def) {
- def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, ofAt: 0, relays: readFrom };
+ def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, relays: readFrom };
adhocFeeds.set(id, def);
- liveProfileOf(pk); // their name and face for the title
- queryOn([...new Set([...relays, ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000)
- .catch(() => [])
- .then((evs) => {
- const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
- def.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
- def.ofAt = Date.now();
- dropFeedState(id); // whatever was built before the list was known
- if (curFeedId === id) selectFeed(id);
- scheduleRepaint();
- });
+ loadOfFollows({ ...def, relays: [...relays, ...readFrom] });
}
showFeed(id);
}
@@ -4683,10 +4791,45 @@ export function messagesFeature(ctx) {
}
const prev = feedPaint;
feedPaint = presentation;
- try { return keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); }
+ try {
+ const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id);
+ if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); }
+ return row;
+ }
finally { feedPaint = prev; }
}
+ // New posts stay tinted until they've been in view for FRESH_SEEN_MS: a
+ // row at least half on screen (or filling most of it) starts its clock,
+ // leaving view stops it. Seen, it loses the tint in place — no repaint.
+ const FRESH_SEEN_MS = 2000;
+ let freshObserver = null;
+ const freshTimers = new Map();
+ function watchFresh(c) {
+ if (typeof IntersectionObserver === 'undefined') return;
+ freshObserver ||= new IntersectionObserver((entries) => {
+ for (const en of entries) {
+ const el = en.target, id = el.getAttribute('data-key');
+ const inView = en.isIntersecting && (en.intersectionRatio >= 0.5 || en.intersectionRect.height >= window.innerHeight * 0.5);
+ if (inView && !freshTimers.has(id)) {
+ freshTimers.set(id, setTimeout(() => {
+ freshTimers.delete(id);
+ if (!el.isConnected) return;
+ feed?.fresh?.delete(id);
+ el.classList.remove('note-fresh');
+ freshObserver.unobserve(el);
+ }, FRESH_SEEN_MS));
+ } else if (!inView && freshTimers.has(id)) {
+ clearTimeout(freshTimers.get(id)); freshTimers.delete(id);
+ }
+ }
+ }, { threshold: [0, 0.5, 1] });
+ // after the paint, when the row nodes are the mounted ones
+ requestAnimationFrame(() => {
+ for (const el of document.querySelectorAll('.notes-feed > .row.note-fresh')) freshObserver.observe(el);
+ });
+ }
+
// Posts at the door: filtered in synchronously, so the same note from a
// second relay is dropped while the first copy is still warming.
const feedStaged = new WeakMap();
@@ -4695,7 +4838,7 @@ export function messagesFeature(ctx) {
if (!staged) feedStaged.set(c, staged = new Set());
const known = new Set([...c.notes, ...(c.catchup || []), ...(c.deferred || [])].map((e) => e.id));
for (const e of evs || []) noteForSpam(e);
- let add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)
+ let add = (evs || []).filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)
&& !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id));
if (!add.length) return false;
const def = feedDef(c.id);
@@ -4740,20 +4883,56 @@ export function messagesFeature(ctx) {
for (const id of c.presentations.keys()) if (!retained.has(id)) c.presentations.delete(id);
saveFeedCache(c);
if (rows.length) {
- c.unseen = (c.unseen || 0) + result.added.filter((e) => e.created_at > topBefore).length;
+ const fresh = result.added.filter((e) => e.created_at > topBefore);
+ c.unseen = (c.unseen || 0) + fresh.length;
+ c.fresh ||= new Set();
+ for (const e of fresh) c.fresh.add(e.id);
holdScroll(render);
}
}
- // The pill's tap: the posts are already in, so this just goes up to them.
- // Reaching the top by yourself clears it too (see the scroll listener).
+ // The pill's tap: the posts are already in, so this just goes up to them —
+ // to the OLDEST of them, the one right above where you were reading, so
+ // you read on upward through the rest. Reaching the top by yourself clears
+ // the pill too (see the scroll listener).
function jumpToNew() {
const c = feed;
if (!c) return;
admitFeed(c.deferred || [], c, true);
c.unseen = 0;
render();
- glideToTop();
+ const list = c.winList ? c.winList() : [];
+ let i = -1;
+ for (let j = list.length - 1; j >= 0; j--) if (c.fresh && c.fresh.has(list[j].id)) { i = j; break; }
+ if (i < 0) { glideToTop(); return; }
+ glideToPost(c, list, i);
+ }
+ // Scroll so post i's top sits just under the top of the window. A row
+ // outside the rendered window is placed from the height cache first, then
+ // corrected once it's mounted.
+ function glideToPost(c, list, i) {
+ const id = list[i].id, MARGIN = 8;
+ const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(id) + '"]');
+ try {
+ const H = window.innerHeight || 800;
+ if (!find() && c.heights) {
+ let sum = 0, n = 0;
+ for (const v of c.heights.values()) { sum += v; n++; }
+ const avg = n ? sum / n : 200;
+ let y = 0;
+ for (let j = 0; j < i; j++) y += (c.heights.get(list[j].id) || avg) + ROW_GAP;
+ window.scrollTo(0, Math.max(0, (c.listTop || 0) + y - MARGIN));
+ render();
+ }
+ const el = find();
+ if (!el) { glideToTop(); return; }
+ const target = Math.max(0, window.scrollY + el.getBoundingClientRect().top - MARGIN);
+ const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (reduce) { window.scrollTo({ top: target }); return; }
+ // a long way off: close most of the distance in one step, glide the rest
+ if (Math.abs(window.scrollY - target) > H * 2) window.scrollTo({ top: target + Math.sign(window.scrollY - target) * H * 2 });
+ requestAnimationFrame(() => { try { window.scrollTo({ top: target, behavior: 'smooth' }); } catch { window.scrollTo(0, target); } });
+ } catch { glideToTop(); }
}
// Up to the new posts as a glide, not a cut: an instant scrollTo read as a
// page refresh. A long way down, most of the distance is closed in one
@@ -4775,6 +4954,13 @@ export function messagesFeature(ctx) {
// as disruptive as a live arrival if you were reading halfway down, so it
// goes behind the pill too. A first load, or paging older posts onto the
// bottom, does not.
+ // The oldest event the relays handed back, admitted or not: a page of
+ // replies, muted or spam posts is not the end of the feed — the next ask
+ // goes on from there.
+ function sawBack(c, evs) {
+ for (const e of evs || []) if (e && e.created_at && !(c.seenBack <= e.created_at)) c.seenBack = e.created_at;
+ c.sawAny = (c.sawAny || 0) + ((evs || []).length);
+ }
async function feedPass(extra = {}, merge = {}, c = feedNow()) {
const def = feedDef(c.id);
if (!def) return false;
@@ -4787,7 +4973,8 @@ export function messagesFeature(ctx) {
// the firehose asks its relays for everything
if (!topics.length && !def.all) return false;
const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
- const evs = await queryOn(relays, { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ const evs = await queryOn(relays, { kinds: FEED_KINDS, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ sawBack(c, evs);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
return got;
}
@@ -4798,7 +4985,8 @@ export function messagesFeature(ctx) {
const chunks = [];
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
return chunks.map(async (chunk) => {
- const evs = await queryOn(relays, { kinds: [1], authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ const evs = await queryOn(relays, { kinds: FEED_KINDS, authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ sawBack(c, evs);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
});
}));
@@ -4969,11 +5157,11 @@ export function messagesFeature(ctx) {
const on = (ev) => { mergeFeed([ev], { live: true }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; }
- if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: [1], ...tag, since }, on)); return; }
+ if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; }
const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
for (const { relays, authors: a } of plan)
for (let i = 0; i < a.length; i += REQ_AUTHORS)
- feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
+ feedUnsubs.push(subscribeOn(relays, { kinds: FEED_KINDS, authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
@@ -5009,8 +5197,14 @@ export function messagesFeature(ctx) {
if (c.status !== 'ready' || c.end) return;
const oldest = c.notes.at(-1);
if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
- if (await feedPass({ until: oldest.created_at - 1 }, {}, c)) c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length);
- else c.end = true;
+ let until = oldest.created_at - 1;
+ for (let tries = 0; tries < 3; tries++) {
+ c.sawAny = 0;
+ if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; }
+ // nothing older at all (or no step back): that really is the end
+ if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; }
+ until = c.seenBack - 1;
+ }
} finally {
c.loadingMore = false;
if (c === feed) render();
@@ -5229,30 +5423,75 @@ export function messagesFeature(ctx) {
// wanted, half a minute on, with any relay hints the reference carried.
const QUOTE_RETRY_MS = 30_000;
const quoted = new Map(); // id -> { status, ev, at }
+ // Where a referenced note may be: the reference's hints, its author's own
+ // relays, ours — and when none of those has it, a wider net of big public
+ // relays (a quoted note in the wild sat only on nostr.mom, which neither
+ // the reference nor its author's relay list named).
+ // (the archive-keeping ones hold what the big relays have pruned — a poll
+ // two days old was only on ditto and nostrplebs by 2026-09-30)
+ const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com', 'wss://nostr21.com',
+ 'wss://nostr.wine', 'wss://relay.snort.social', 'wss://offchain.pub', 'wss://nostr.oxtr.dev',
+ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+ // All at once, first answer wins: asked in turn, a note on a far relay
+ // took longer than the feed waits for a row.
+ function findNote(ref) {
+ const pick = (evs) => (evs || []).find((e) => e.id === ref.id) || null;
+ const ask = (relays) => (relays.length ? queryOn(relays, { ids: [ref.id] }, 4500).then(pick).catch(() => null) : Promise.resolve(null));
+ const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
+ const asks = [
+ ask(near),
+ ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
+ ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
+ ];
+ return new Promise((resolve) => {
+ let left = asks.length;
+ for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); });
+ });
+ }
function quotedNote(ref) {
let c = quoted.get(ref.id);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
c.at = Date.now();
c.promise = (async () => {
- const relays = [...new Set([...(ref.relays || []), ...zapRelays()])];
- const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []);
- c.ev = (evs || [])[0] || c.ev || null;
+ c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
+ if (c.ev) cacheNotifNotes([c.ev]);
+ // the open thread quotes it: keep it with the thread for the next reload
+ const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId);
+ if (th && th.root) persistThread(th);
scheduleRepaint();
})();
return c;
}
+ // A feed row that froze before its quote arrived showed a link — and
+ // nothing asked for the note again. Keep asking, and once the note and its
+ // own faces/pictures are in, the row takes the card.
+ function quoteWarm(ref) {
+ const c = quotedNote(ref);
+ if (c.ev && !c.warm && !c.warming) {
+ c.warming = noteReady(c.ev, Date.now() + READY_MS, 1).catch(() => {})
+ .then(() => { c.warm = true; c.warming = null; scheduleRepaint(); });
+ }
+ return c;
+ }
+
// The card. Deliberately not a noteRow: a quote is context, not another
// post to act on — no reply, no boost, no zap of its own. Tapping it opens
// the note properly, which is where those live.
function quoteCard(ref, depth) {
if (feedPaint && !feedPaint.quotes.has(ref.id)) feedPaint.quotes.set(ref.id, { ...quoted.get(ref.id) });
- const c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
- if (feedPaint && !c?.ev) return h('a', {
- href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); },
- }, t('noteRefLink'));
+ let c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
+ if (feedPaint && !c?.ev) {
+ const live = quoteWarm(ref);
+ // not (yet) found: the card says so where the quote will be — never a
+ // bare "view note" link that only leads to the same answer
+ if (!(live.ev && live.warm)) return h('div', { class: 'quote-card' },
+ h('span', { class: 'small faint' }, t(live.status === 'missing' ? 'noteRefNotFound' : 'noteRefLoading')));
+ c = { ...live };
+ feedPaint.quotes.set(ref.id, c);
+ }
if (c.status === 'loading') {
return h('div', { class: 'quote-card quote-loading' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')));
@@ -5274,13 +5513,27 @@ export function messagesFeature(ctx) {
h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
// one level deep only: a quote of a quote of a quote is a rabbit
// hole, and the inner one stays a link you can follow
- ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))));
+ ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))),
+ ev.kind === POLL ? pollBox(ev, { interactive: false }) : null);
}
// One URL, rendered as whatever it points at. `isImage` is markdown saying
// so outright — plenty of perfectly good picture URLs carry no extension
// (a CDN path, a /media/ route), and ![…] is the author telling us what it
// is, which beats guessing from the filename.
+ // A picture is fitted inside its box (object-fit: contain), so a tall one
+ // leaves blank bands either side. A tap there is a tap on the post — it
+ // opens the thread — not on the picture.
+ function onPicture(e) {
+ const el = e.currentTarget;
+ const r = el.getBoundingClientRect();
+ const nw = el.naturalWidth, nh = el.naturalHeight;
+ if (!nw || !nh || !r.width || !r.height) return true;
+ const k = Math.min(r.width / nw, r.height / nh);
+ const w = nw * k, hh = nh * k;
+ const x = e.clientX - (r.left + (r.width - w) / 2), y = e.clientY - (r.top + (r.height - hh) / 2);
+ return x >= 0 && x <= w && y >= 0 && y <= hh;
+ }
function urlNode(url, { label = null, isImage = false } = {}) {
if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
return videoNode(url, { stable: !!feedPaint });
@@ -5294,7 +5547,7 @@ export function messagesFeature(ctx) {
src: url, class: 'note-img clickable', loading: feedPaint ? 'eager' : 'lazy', alt: label || '',
width: size?.width, height: size?.height,
style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height : undefined,
- onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
+ onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
// a picture we were TOLD was a picture and which won't load leaves
// nothing behind — the alt text is already in the sentence above it
onError: (e) => { if (!size) e.target.style.display = 'none'; },
@@ -5749,6 +6002,108 @@ export function messagesFeature(ctx) {
}
// React with whichever emoji was picked (NIP-25 takes any content).
+ // ---- polls (NIP-88) -------------------------------------------------------
+ // A kind 1068 poll is its question (content) plus ["option", id, label]
+ // tags; votes are kind 1018 events e-tagging it with ["response", id].
+ // Each person's LATEST vote counts (one option unless the poll says
+ // "multiplechoice"), and nothing cast after its endsAt.
+ const POLL = 1068, POLL_VOTE = 1018;
+ const pollOptions = (ev) => ev.tags.filter((x) => x[0] === 'option' && x[1] != null && x[2])
+ .map((x) => ({ id: String(x[1]), label: String(x[2]) }));
+ const pollMulti = (ev) => (ev.tags.find((x) => x[0] === 'polltype') || [])[1] === 'multiplechoice';
+ const pollEnds = (ev) => { const v = Number((ev.tags.find((x) => x[0] === 'endsAt') || [])[1]); return Number.isFinite(v) && v > 0 ? v : 0; };
+ const pollEnded = (ev) => !!pollEnds(ev) && Date.now() / 1000 > pollEnds(ev);
+ const pollRelays = async (ev) => [...new Set([
+ ...ev.tags.filter((x) => x[0] === 'relay' && /^wss?:\/\//i.test(x[1] || '')).map((x) => x[1]),
+ ...(await noteRelaysFor(ev).catch(() => zapRelays())),
+ ])].slice(0, 12);
+ const pollVotes = new Map(); // poll id -> { at, loaded, loading, by: Map(pk -> { t, ids }) }
+ function pollState(ev) {
+ let st = pollVotes.get(ev.id);
+ if (!st) { st = { at: 0, loaded: false, loading: false, by: new Map() }; pollVotes.set(ev.id, st); }
+ return st;
+ }
+ function pollCount(poll, v) {
+ const st = pollState(poll);
+ const ends = pollEnds(poll);
+ if (ends && v.created_at > ends) return;
+ const prev = st.by.get(v.pubkey);
+ if (prev && prev.t >= v.created_at) return;
+ const valid = new Set(pollOptions(poll).map((o) => o.id));
+ let ids = [...new Set(v.tags.filter((x) => x[0] === 'response').map((x) => String(x[1])))].filter((id) => valid.has(id));
+ if (!pollMulti(poll)) ids = ids.slice(0, 1);
+ st.by.set(v.pubkey, { t: v.created_at, ids });
+ }
+ // Votes are asked for when a poll is painted, and again a minute on.
+ function pollTally(ev) {
+ const st = pollState(ev);
+ if (!st.loading && Date.now() - st.at > 60_000) {
+ st.loading = true; st.at = Date.now();
+ (async () => {
+ const evs = await queryOn(await pollRelays(ev), { kinds: [POLL_VOTE], '#e': [ev.id], limit: 1000 }, 5000).catch(() => []);
+ for (const v of evs || []) if (v.kind === POLL_VOTE) pollCount(ev, v);
+ })().finally(() => { st.loading = false; st.loaded = true; scheduleRepaint(); });
+ }
+ return st;
+ }
+ async function votePoll(poll, optId) {
+ if (pollEnded(poll)) return;
+ const id = await requireIdentity();
+ const st = pollState(poll);
+ const prev = st.by.get(id.pubkey);
+ const mine = prev?.ids || [];
+ const ids = pollMulti(poll) ? (mine.includes(optId) ? mine.filter((x) => x !== optId) : [...mine, optId]) : [optId];
+ if (!pollMulti(poll) && mine[0] === optId) return;
+ const partial = {
+ kind: POLL_VOTE, content: '',
+ // strictly newer than our last vote, or relays and counters keep the old one
+ created_at: Math.max(Math.floor(Date.now() / 1000), (prev?.t || 0) + 1),
+ tags: [['e', poll.id], ['p', poll.pubkey], ...ids.map((x) => ['response', x]), CLIENT_TAG],
+ };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ pollCount(poll, evt); // counted on screen before the relays answer
+ render();
+ const ok = await publishOn(await pollRelays(poll), evt).catch(() => false);
+ if (!ok) toast(t('msgSendFailed'));
+ }
+ // The options as rows with their share of the vote. In a quote card
+ // (`interactive` off) they are a picture of the poll — the tap opens it.
+ function pollBox(ev, { interactive = true } = {}) {
+ const opts = pollOptions(ev);
+ if (!opts.length) return null;
+ const st = pollTally(ev);
+ const counts = new Map(opts.map((o) => [o.id, 0]));
+ let voters = 0;
+ for (const v of st.by.values()) {
+ if (!v.ids.length) continue;
+ voters++;
+ for (const x of v.ids) counts.set(x, (counts.get(x) || 0) + 1);
+ }
+ const mine = new Set(myPubkeys().flatMap((pk) => st.by.get(pk)?.ids || []));
+ const ended = pollEnded(ev);
+ const canVote = interactive && !ended;
+ const shown = st.loaded || voters > 0;
+ const foot = [
+ shown ? t(voters === 1 ? 'pollVoter' : 'pollVoters', { n: voters }) : null,
+ pollMulti(ev) ? t('pollMulti') : null,
+ ended ? t('pollEnded') : pollEnds(ev) ? t('pollEndsAt', { when: new Date(pollEnds(ev) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }) : null,
+ ].filter(Boolean).join(' \u00b7 ');
+ return h('div', { class: 'poll' },
+ ...opts.map((o) => {
+ const n = counts.get(o.id) || 0;
+ const pct = voters ? Math.round((n / voters) * 100) : 0;
+ return h(canVote ? 'button' : 'div', {
+ class: 'poll-opt' + (mine.has(o.id) ? ' mine' : '') + (canVote ? '' : ' static'),
+ type: canVote ? 'button' : undefined,
+ onClick: canVote ? (e) => { e.stopPropagation(); votePoll(ev, o.id).catch(() => {}); } : undefined,
+ },
+ h('span', { class: 'poll-bar', style: 'width:' + (shown ? pct : 0) + '%' }),
+ h('span', { class: 'poll-label' }, (mine.has(o.id) ? '\u2713 ' : '') + o.label),
+ shown ? h('span', { class: 'poll-pct' }, pct + '%') : null);
+ }),
+ foot ? h('div', { class: 'small faint poll-foot' }, foot) : null);
+ }
+
async function reactTo(ev, emoji) {
const id = await requireIdentity();
const relays = await noteRelaysFor(ev);
@@ -5795,7 +6150,7 @@ export function messagesFeature(ctx) {
ui.msgView = 'feed';
feedNow();
render();
- setTimeout(() => { const el = document.querySelector('.chat-page textarea'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
+ setTimeout(() => { const el = document.querySelector('.chat-page coinos-text'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
}
// ---- mute list (NIP-51 kind 10000) ---------------------------------------
@@ -6391,9 +6746,9 @@ export function messagesFeature(ctx) {
// An event of a kind this app has no renderer for (a chess game, kind
// 64, reached through a reaction to it) is shown as what its author's
// client said it is (NIP-31 alt), never as raw content dressed as a post.
- const foreign = ev.kind !== 1 && ev.kind !== 30023;
+ const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL && ev.kind !== COMMENT;
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
- const isReply = !foreign && ev.tags.some((x) => x[0] === 'e');
+ const isReply = ev.kind === COMMENT || (!foreign && ev.tags.some((x) => x[0] === 'e'));
const canZap = canZapPk(pk);
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
@@ -6404,10 +6759,10 @@ export function messagesFeature(ctx) {
const openable = open && !pending;
if (!pending) watchZaps([ev.id]);
return h('div', {
- class: 'row',
+ class: 'row note-post',
'data-zap-post': ev.id,
'data-focus-note': focus ? '1' : undefined,
- style: 'gap:10px;align-items:flex-start;padding:10px 0'
+ style: 'padding:10px 0'
+ (openable ? ';cursor:pointer' : '')
+ (pending ? ';opacity:.55' : '')
// joined: the reply box continues this highlight below, so the
@@ -6420,8 +6775,8 @@ export function messagesFeature(ctx) {
// the avatar is its own tap-target (profile), even inside an openable
// row — its handler stops propagation, so the row still opens the thread
avatar(pk, 'chat-avatar note-avatar'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:3px' },
- h('div', { class: 'row between', style: 'align-items:center;gap:8px' },
+ h('div', { class: 'note-content' },
+ h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' },
h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
h('span', {
// the name, like the avatar, is its own tap-target (profile)
@@ -6441,6 +6796,7 @@ export function messagesFeature(ctx) {
foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
: h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content, 0, emojiTagMap(ev.tags))),
+ ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
!pending && whoOpen(ev.id) ? whoPanel(ev) : null));
}
@@ -6455,7 +6811,21 @@ export function messagesFeature(ctx) {
const p = profiles.get(ev.pubkey);
if (p && (p.name || p.picture)) faces[ev.pubkey] = p;
}
- threadStore.save(c, c.focusId, faces, c.counts);
+ // quoted posts inside the thread ride along, so a reload paints them
+ // instead of a "Fetching note…" spinner
+ const quotes = [];
+ for (const ev of [c.root, ...c.replies]) {
+ for (const m of (ev.content || '').matchAll(/nostr:((?:note|nevent)1[a-z0-9]+)/gi)) {
+ const ref = parseNostrRef(m[1].toLowerCase());
+ const q = ref && ref.type === 'event' && quoted.get(ref.id);
+ if (q && q.ev) {
+ quotes.push(q.ev);
+ const p = profiles.get(q.ev.pubkey);
+ if (p && (p.name || p.picture)) faces[q.ev.pubkey] = p;
+ }
+ }
+ }
+ threadStore.save(c, c.focusId, faces, c.counts, quotes);
};
function persistThreadProfile(pk) {
const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
@@ -6491,7 +6861,16 @@ export function messagesFeature(ctx) {
settle();
requestAnimationFrame(settle);
}
+ // ---- NIP-22 comments (kind 1111) ----
+ // The reply format for everything that isn't a kind 1 note — a poll, an
+ // article, a picture. Uppercase tags name the ROOT (E/A + K + P),
+ // lowercase the PARENT it answers (e/a + k + p). A kind 1 note is never
+ // answered with one (NIP-22), and never answers anything but a kind 1.
+ const COMMENT = 1111;
+ const tagVal = (ev, name) => (ev.tags.find((x) => x[0] === name && x[1]) || [])[1] || null;
+ const addrOf = (ev) => ev.kind >= 30000 && ev.kind < 40000 ? ev.kind + ':' + ev.pubkey + ':' + (tagVal(ev, 'd') || '') : null;
function rootIdOf(ev) {
+ if (ev.kind === COMMENT) return tagVal(ev, 'E') || tagVal(ev, 'e') || ev.id;
const es = ev.tags.filter((x) => x[0] === 'e');
const marked = es.find((x) => x[3] === 'root');
return (marked || es[0] || [])[1] || ev.id;
@@ -6527,6 +6906,7 @@ export function messagesFeature(ctx) {
}
}
}
+ for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() });
const rootId = stored?.rootId || requestedRootId;
c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id,
root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} };
@@ -6548,8 +6928,12 @@ export function messagesFeature(ctx) {
};
let relays = await relaysFor(c.root || seed);
const [roots, replies] = await Promise.all([
- c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000),
- queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500),
+ c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll
+ // kind 1 replies, and NIP-22 comments naming it as their root
+ Promise.all([
+ queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []),
+ queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []),
+ ]).then(([a, b]) => [...(a || []), ...(b || [])]),
]);
if (!c.root) c.root = (roots || [])[0] || null;
let all = [...c.replies, ...(replies || [])];
@@ -6562,8 +6946,8 @@ export function messagesFeature(ctx) {
if (up === top.id) break;
relays = [...new Set([...relays, ...(await relaysFor(top))])].slice(0, 14);
const [ups, more] = await Promise.all([
- queryOn(relays, { kinds: [1], ids: [up] }, 4000),
- queryOn(relays, { kinds: [1], '#e': [up], limit: 80 }, 4500),
+ queryOn(relays, { ids: [up] }, 4000),
+ queryOn(relays, { kinds: [1, COMMENT], '#e': [up], limit: 80 }, 4500),
]);
const upNote = (ups || [])[0];
if (!upNote) break;
@@ -6575,6 +6959,9 @@ export function messagesFeature(ctx) {
threadCache.set(topId, c);
if (ui.noteThread && ui.noteThread.rootId === rootId) ui.noteThread.rootId = topId;
}
+ // an article's comments may name it only by address
+ const addr = c.root && addrOf(c.root);
+ if (addr) all = [...all, ...(await queryOn(relays, { kinds: [COMMENT], '#A': [addr], limit: 120 }, 4500).catch(() => []) || [])];
for (const e of all) noteForSpam(e);
const seen = new Set([c.rootId]);
c.replies = all
@@ -6601,6 +6988,7 @@ export function messagesFeature(ctx) {
return c;
}
function openNoteThread(ev) {
+ rememberPlace('thread');
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
@@ -6613,9 +7001,7 @@ export function messagesFeature(ctx) {
const cached = saved && (saved.root.id === ref.id ? saved.root : saved.replies.find((e) => e.id === ref.id));
if (cached) { openNoteThread(cached); return; }
toast(t('noteRefLoading'));
- const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])];
- const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []);
- const ev = (evs || [])[0];
+ const ev = quoted.get(ref.id)?.ev || await findNote(ref).catch(() => null);
if (ev) openNoteThread(ev);
else toast(t('noteRefNotFound'));
}
@@ -6677,7 +7063,28 @@ export function messagesFeature(ctx) {
])].filter((pk) => pk !== id.pubkey).slice(0, 8);
const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url))
.map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]);
- const partial = {
+ const root = c.root || (target.id === rootId ? target : null);
+ // the root's scope: from the root itself, or — not loaded yet — copied
+ // off the comment being answered, which carries it
+ const scope = root
+ ? [...(addrOf(root) ? [['A', addrOf(root), '']] : []), ['E', root.id, '', root.pubkey], ['K', String(root.kind)], ['P', root.pubkey]]
+ : target.kind === COMMENT ? target.tags.filter((x) => ['A', 'E', 'I', 'K', 'P'].includes(x[0])) : [];
+ const partial = target.kind === 1 || !scope.length ? null : {
+ // NIP-22: a comment on a poll, an article, or on another comment
+ kind: COMMENT,
+ content: text,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ...scope,
+ ...(root && target.id === root.id
+ ? [...(addrOf(root) ? [['a', addrOf(root), '']] : []), ['e', root.id, '', root.pubkey], ['k', String(root.kind)], ['p', root.pubkey]]
+ : [['e', target.id, '', target.pubkey], ['k', String(target.kind)], ['p', target.pubkey]]),
+ ...pTags.filter((pk) => pk !== target.pubkey && (!root || pk !== root.pubkey)).map((pk) => ['p', pk]),
+ ...imeta,
+ CLIENT_TAG,
+ ],
+ };
+ const note = partial || {
kind: 1,
content: text,
created_at: Math.floor(Date.now() / 1000),
@@ -6689,7 +7096,7 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
- const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
if (!ok) throw new Error(t('msgSendFailed'));
@@ -6721,14 +7128,21 @@ export function messagesFeature(ctx) {
style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'),
},
s.preview ? draftPreview(s.draft) : null,
+ mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
// sends, Shift+Enter (or Ctrl+J) breaks the line — a reply used to be
// a one-line field with no way to write a paragraph.
- h('textarea', {
+ h('coinos-text', {
class: 'thread-reply-input', placeholder: t('threadReplyHint'),
- rows: String(Math.min(6, Math.max(2, (s.draft || '').split('\n').length))),
- style: 'font-family:var(--sans);resize:none;max-height:160px;overflow-y:auto;line-height:1.4;width:100%;box-sizing:border-box',
+ style: 'font-family:var(--sans);min-height:64px;max-height:160px',
value: s.draft || '',
+ // a keyboard GIF or a pasted picture: attached like the paperclip's
+ onMedia: (f) => attachTo(f, (url) => {
+ s.draft = ((s.draft || '').replace(/\s+$/, '') + ' ' + url).trim();
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = s.draft;
+ s.preview = true;
+ }),
// a render per keystroke only while the preview is open; the morph
// leaves a focused field alone, so this can't fight the typing
onInput: (e) => { s.draft = e.target.value; growComposer(e.target); if (s.preview) render(); },
@@ -6749,7 +7163,7 @@ export function messagesFeature(ctx) {
// A reply can carry a picture too — same upload, same imeta tag, same
// paperclip. The URL lands in the draft, which is what every client
// reads as the media.
- ctx.uploadImage ? h('button', {
+ (ctx.uploadMedia || ctx.uploadImage) ? h('button', {
class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('reply-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
@@ -6814,6 +7228,9 @@ export function messagesFeature(ctx) {
// first among a note's answers, so what you just wrote shows right
// under the post instead of at the bottom of a long thread.
const parentOf = (ev) => {
+ // a comment's parent is its lowercase e; one answering the root by
+ // address (an article) has only an a — the root
+ if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
const es = ev.tags.filter((x) => x[0] === 'e');
const marked = es.find((x) => x[3] === 'reply');
return (marked || es[es.length - 1] || [])[1] || c.rootId;
@@ -7340,26 +7757,66 @@ export function messagesFeature(ctx) {
// client reads media — with a NIP-92 imeta tag published beside it for the
// ones that would rather have the metadata than sniff the URL.
const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || '');
+ let postAttachGeneration = 0;
// Upload, then hand the URL to whichever draft asked for it.
async function attachTo(file, place) {
- if (!file || !ctx.uploadImage) return;
+ const upload = ctx.uploadMedia || ctx.uploadImage;
+ if (!file || !upload) return;
ui.postUploading = true; render();
try {
- const url = await ctx.uploadImage(file);
- (ui.postMedia ||= []).push({ url, m: file.type || '' });
- place(url);
+ const url = await upload(file);
+ // A post canceled while its upload was in flight must stay canceled.
+ // `false` lets that composer reject the late result without affecting
+ // the reply composer, which shares this helper.
+ if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' });
} catch (e) { toast(e.message || String(e)); }
ui.postUploading = false; render();
}
- const attachMedia = (file) => attachTo(file, (url) => {
- const cur = composeText().replace(/\s+$/, '');
- const next = (cur ? cur + '\n' : '') + url;
- ui.profCompose = next;
- setDraft(POST_DRAFT, next);
- // you just attached a picture; showing it IS the answer to the question
- // attaching one raises
- ui.postPreview = true;
- });
+ const attachMedia = (file) => {
+ const generation = postAttachGeneration;
+ return attachTo(file, (url) => {
+ if (generation !== postAttachGeneration) return false;
+ const cur = composeText().replace(/\s+$/, '');
+ const next = (cur ? cur + '\n' : '') + url;
+ ui.profCompose = next;
+ setDraft(POST_DRAFT, next);
+ // you just attached a picture; showing it IS the answer to the question
+ // attaching one raises
+ ui.postPreview = true;
+ return true;
+ });
+ };
+ function cancelPost() {
+ postAttachGeneration++;
+ ui.postCancelConfirm = false;
+ ui.profCompose = null;
+ ui.postMedia = [];
+ ui.postPreview = false;
+ discardDraft(POST_DRAFT);
+ render();
+ }
+ function requestCancelPost() {
+ if (composeText().length > 20 || ui.postUploading || (ui.postMedia || []).length) {
+ ui.postCancelConfirm = true;
+ render();
+ return;
+ }
+ cancelPost();
+ }
+ function postCancelWarning() {
+ if (!ui.postCancelConfirm) return null;
+ const keep = () => { ui.postCancelConfirm = false; render(); };
+ return h('div', {
+ class: 'confirm-pop-backdrop',
+ onClick: (event) => { if (event.target === event.currentTarget) keep(); },
+ },
+ h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
+ h('h3', { style: 'margin:0' }, t('postDiscardTitle')),
+ h('div', { class: 'small muted' }, t('postDiscardBody')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost grow', onClick: keep }, t('postKeepEditing')),
+ h('button', { class: 'btn-danger grow', onClick: cancelPost }, t('postDiscard')))));
+ }
const CLIP = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
const EYE = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
@@ -7378,6 +7835,16 @@ export function messagesFeature(ctx) {
? h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(body))
: h('div', { class: 'small faint' }, t('composePreviewEmpty')));
}
+ // Shrinking, then uploading, as a labelled bar with its percentage — the
+ // paperclip's spinner said only "busy", however long a video took.
+ function mediaBar() {
+ const m = ui.mediaProgress;
+ if (!m) return null;
+ const label = t(m.stage === 'shrink' ? 'mediaShrinking' : 'mediaUploading');
+ return h('div', { class: 'media-progress', role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(m.pct), 'aria-label': label },
+ h('div', { class: 'row between small' }, h('span', { class: 'muted' }, label + '…'), h('span', { class: 'media-progress-pct' }, m.pct + '%')),
+ h('div', { class: 'media-progress-track' }, h('div', { class: 'media-progress-fill', style: 'width:' + m.pct + '%' })));
+ }
const previewBtn = (on, toggle) => h('button', {
class: 'attach-btn' + (on ? ' on' : ''),
title: on ? t('composePreviewHide') : t('composePreview'),
@@ -7388,10 +7855,12 @@ export function messagesFeature(ctx) {
if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null;
const text = composeText();
return h('div', { class: 'col', style: 'gap:8px' },
- h('textarea', {
- rows: '3', placeholder: t('profComposePh'),
- style: 'font-family:var(--sans);min-height:64px',
+ h('coinos-text', {
+ class: 'post-input', placeholder: t('profComposePh'),
+ style: 'font-family:var(--sans);min-height:84px;max-height:320px',
value: text,
+ // the field is focused, so the morph leaves it: put the URL in by hand
+ onMedia: (f) => attachMedia(f).then(() => { const el = document.querySelector('.post-input'); if (el) el.value = composeText(); }),
onInput: (ev) => {
ui.profCompose = ev.target.value;
setDraft(POST_DRAFT, ev.target.value);
@@ -7402,6 +7871,7 @@ export function messagesFeature(ctx) {
},
}),
ui.postPreview ? draftPreview(text) : null,
+ mediaBar(),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
const body = composeText().trim();
@@ -7421,7 +7891,7 @@ export function messagesFeature(ctx) {
render();
}
} }, t('profPostBtn')),
- ctx.uploadImage ? h('button', {
+ (ctx.uploadMedia || ctx.uploadImage) ? h('button', {
class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
@@ -7430,7 +7900,8 @@ export function messagesFeature(ctx) {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
- h('button', { class: 'btn-ghost', onClick: () => { ui.profCompose = null; render(); } }, t('cancel'))));
+ h('button', { class: 'btn-ghost', onClick: requestCancelPost }, t('cancel'))),
+ postCancelWarning());
}
// ---- the feed view --------------------------------------------------------
@@ -7455,7 +7926,9 @@ export function messagesFeature(ctx) {
...items.map((f) => h('button', {
class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id,
onClick: () => switchFeed(f.id),
- }, f.name)),
+ // someone's timeline carries the person, not a name — an empty
+ // label left a blank black pill
+ }, f.name || (f.of ? t('feedOfTitle', { name: displayName(f.of) }) : feedSummary(f)))),
h('button', { class: 'feed-chip add', type: 'button', title: t('feedNew'), 'aria-label': t('feedNew'), onClick: () => openFeedEditor(null) }, '+'));
}
// The sheets a post (or a person) can open: the ⋯ menu, the reaction
@@ -7476,7 +7949,9 @@ export function messagesFeature(ctx) {
item('\u{1F4E1}', t('feedRelays') + (feedRelays(def) ? ' \u00b7 ' + feedRelays(def).length : ''), () => { ui.feedRelayEdit = { id: def.id, input: '' }; }),
def.builtin || visitor ? null
: adhocFeeds.has(def.id)
- ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => openFeedEditor(def))
+ ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => (def.of ? saveTimelineFeed(def) : openFeedEditor(def)))
+ // a saved timeline is the person, nothing to edit — only to drop
+ : def.of ? item('\u{1F5D1}', t('feedDelete'), () => deleteFeed(def.id))
: item('\u270e', t('feedEdit'), () => openFeedEditor(def)),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
@@ -7525,6 +8000,64 @@ export function messagesFeature(ctx) {
} catch {}
}, 0);
}
+ function appBottomNav() {
+ const visitor = isVisitor();
+ const active = ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : 'wallet';
+ const leavePage = () => {
+ stopFeedWatch(); stopNotifWatch();
+ // Top-level destinations must leave any feature page covering the shell.
+ for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch',
+ 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet',
+ 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen',
+ 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null;
+ if (!visitor) { ui.screen = 'wallet'; ui.pubProf = null; }
+ };
+ // The chat list used to carry the notifications row and its count; the
+ // nav's bell carries the dot now.
+ const notifsWaiting = () => {
+ if (!myPubkeys().length) return 0;
+ refreshNotifs(); // throttled inside; keeps the count honest
+ return notifUnread();
+ };
+ const icon = (paths) => '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>';
+ const button = (id, label, paths, onClick) => h('button', {
+ type: 'button', class: 'app-nav-button' + (!visitor && (id === 'messages' ? hook('unreadMessages') : id === 'notifs' && notifsWaiting()) ? ' unread' : ''),
+ 'aria-label': label, 'aria-current': active === id ? 'page' : undefined, onClick,
+ }, h('span', { html: icon(paths) }), h('span', {}, label));
+ return h('nav', {
+ class: 'app-bottom-nav', 'data-key': 'app-bottom-nav',
+ },
+ button('feed', t('feedTitle'), '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2m0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8M15 18h-5M10 6h8v4h-8z"/>', () => {
+ // A destination tab is a destination: tapping Feed while already
+ // there always returns to its top. Only the explicit "x new posts"
+ // pill jumps to the first unread arrival.
+ // (someone's timeline or a #tag opened from a post isn't the Feed
+ // tab — the tab is your own feeds, the one you last had on)
+ const own = adhocFeeds.has(curFeedId) ? homeFeedId() : curFeedId;
+ if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit && own === curFeedId) { glideToTop(); return; }
+ leavePage();
+ if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(own);
+ window.scrollTo({ top: 0 });
+ }),
+ button('messages', t('msgDmsTitle'), '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/>', () => {
+ if (visitor) { toFrontDoor(); return; }
+ leavePage(); ui.chatOpen = true; ui.msgView = 'home'; render();
+ window.scrollTo({ top: 0 });
+ }),
+ button('notifs', t('alertsTitle'), '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>', () => {
+ if (visitor) { toFrontDoor(); return; }
+ leavePage(); openNotifs();
+ window.scrollTo({ top: 0 });
+ }),
+ button('wallet', t('settingsWallet'), '<path d="M20 8V5H5a2 2 0 0 0 0 4h16v11H5a2 2 0 0 1-2-2V7"/><path d="M21 12h-5v5h5"/><path d="M17 14.5h.01"/>', () => {
+ if (isVisitor()) { toFrontDoor(); return; }
+ leavePage();
+ ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null;
+ ui.chatOpen = false; ui.msgView = null; ui.tab = 'receive';
+ render(); window.scrollTo({ top: 0 });
+ }));
+ }
function feedView() {
syncFollowSets().catch(() => {}); // throttled inside
syncReports().catch(() => {}); // likewise
@@ -7564,31 +8097,42 @@ export function messagesFeature(ctx) {
waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord'))
: null;
// the chat shell draws the brand header; this is just the page under it
- return h('div', { class: 'card col chat-page', style: 'gap:10px' },
+ return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' },
h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill),
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => {
stopFeedWatch();
+ // a feed opened from somewhere (a profile's timeline, a #tag, a
+ // pack link) goes back there — or, reached directly, to yours
+ if (!visitor && adhocFeeds.has(def.id)) { ctx.goBack(() => { ui.chatOpen = true; ui.msgView = 'feed'; selectFeed(homeFeedId()); }); return; }
if (visitor) { ui.chatOpen = false; ui.msgView = null; ui.pubProf = null; } else ui.msgView = 'home';
render();
}),
def.of ? avatar(def.of, 'chat-avatar mini', true) : null,
h('h3', { style: 'margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
- def.builtin && !def.all ? t('feedTitle') : def.of ? t('feedOfTitle', { name: displayName(def.of) }) : def.name),
+ def.builtin && !def.all ? t('feedTitle') : def.of && !def.name ? t('feedOfTitle', { name: displayName(def.of) }) : def.name),
// one ⋯ for the feed itself (share, relays, save or edit) and one
// pencil for a new post: the title keeps the room a phone has
h('button', {
class: 'btn-sm' + (feedRelays(def) ? ' on' : ''), title: t('feedMenu'), 'aria-label': t('feedMenu'), style: 'margin-left:auto;flex-shrink:0',
onClick: () => { ui.feedMenu = true; render(); },
}, '\u22ef'),
- visitor ? null : h('button', {
- class: 'btn-sm', style: 'flex-shrink:0', title: t('profNewPost'), 'aria-label': t('profNewPost'),
- onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); },
- html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' })),
+ ),
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
+ // 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', {
+ class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'),
+ onClick: () => {
+ ui.profCompose = draftFor(POST_DRAFT) || '';
+ render();
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
+ setTimeout(() => document.querySelector('.post-input')?.focus(), 60);
+ },
+ }, h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' }), h('span', {}, t('feedPostFab'))),
visitor ? null : postComposer(),
- def.of && !def.ofAt
+ def.of && !ofLoaded(def)
? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedOfLoading')))
: def.of && !hasQuery
@@ -7674,6 +8218,22 @@ export function messagesFeature(ctx) {
deleteFollowSet(prev).catch(() => {});
}
}
+ // "Save feed" on someone's timeline: into the chips at once. The editor
+ // would have copied their whole follow list in, one row per person.
+ function saveTimelineFeed(adhoc) {
+ const s = st();
+ const have = s.feeds.find((f) => f.of === adhoc.of && JSON.stringify(f.relays || []) === JSON.stringify(adhoc.relays || []));
+ const id = have ? have.id : 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ if (!have) {
+ s.feeds.push({ id, name: '', follows: false, authors: [], packs: [], topics: [], of: adhoc.of,
+ ...(adhoc.relays && adhoc.relays.length ? { relays: adhoc.relays } : {}), at: Date.now() });
+ save(s);
+ }
+ adhocFeeds.delete(adhoc.id);
+ feedStates.delete(adhoc.id);
+ switchFeed(id);
+ toast(t('feedSaved'));
+ }
function deleteFeed(id) {
const s = st();
const def = s.feeds.find((f) => f.id === id);
@@ -8052,9 +8612,10 @@ export function messagesFeature(ctx) {
type: 'file', id: 'msg-file', accept: 'image/*,video/*', style: 'display:none',
onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) onAttach(f); },
}) : null,
- h('textarea', {
- class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)),
- value: draftFor(draftKey), maxlength: '2000',
+ h('coinos-text', {
+ class: 'grow', id: 'msg-draft', placeholder,
+ value: draftFor(draftKey),
+ onMedia: onAttach ? (f) => onAttach(f) : null,
onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); updateEmojiAc(e.target, draftKey); if (onType && e.target.value) onType(); },
onClick: (e) => updateEmojiAc(e.target, draftKey), // the caret moved
onBlur: () => { if (ui.emojiAc) { ui.emojiAc = null; render(); } },
@@ -8158,45 +8719,6 @@ export function messagesFeature(ctx) {
if (st().noPushService && !st().push)
kids.push(h('div', { class: 'notice info small' }, pushAdvice()));
- // ---- the feed, above the conversations: it's the thing you read, they're
- // the things you answer
- kids.push(h('div', { class: 'list' },
- h('div', {
- class: 'item chat-thread-row',
- onClick: () => { ui.msgView = 'feed'; switchFeed(FOLLOWING); },
- },
- h('div', { class: 'chat-avatar fallback' }, '\u2605'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('span', { class: 'chat-name' }, t('feedTitle')),
- h('div', { class: 'muted small' },
- followsNow().set.size === 1 ? t('feedFollowing1')
- : followsNow().set.size ? t('feedFollowingN', { n: followsNow().set.size })
- : t('feedNoFollowsShort')))),
- // ...the feeds you made, each its own row under it
- ...st().feeds.map((f) => h('div', {
- class: 'item chat-thread-row',
- onClick: () => { ui.msgView = 'feed'; switchFeed(f.id); },
- },
- h('div', { class: 'chat-avatar fallback' }, feedTopics(f).length && !f.follows && !(f.authors || []).length && !(f.packs || []).length ? '#' : '\u2605'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('span', { class: 'chat-name' }, f.name),
- h('div', { class: 'muted small chat-preview' }, feedSummary(f))))),
- // ...and what happened to what you posted
- (() => {
- const n = myPubkeys().length ? notifUnread() : 0;
- if (myPubkeys().length) refreshNotifs(); // throttled inside; keeps the count honest
- return h('div', {
- class: 'item chat-thread-row' + (n ? ' unread' : ''),
- onClick: openNotifs,
- },
- h('div', { class: 'chat-avatar fallback' }, '\ud83d\udd14'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('span', { class: 'chat-name' }, t('alertsTitle')),
- h('div', { class: 'muted small' }, n ? t('alertsNew', { n }) : t('alertsSub'))),
- // like a group row: the dot sits mid-height, padded off the edge
- n ? h('i', { class: 'thread-dot' }) : null);
- })()));
-
// ---- DMs
kids.push(h('div', { class: 'row between', style: 'align-items:baseline' },
h('h3', { style: 'margin:0' }, t('msgDmsTitle')),
@@ -8232,11 +8754,10 @@ export function messagesFeature(ctx) {
? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk), (pk, node) => hook('wrapAvatar', pk, node)))
: h('div', { class: 'small muted' }, t('msgNoMatches'))));
}
- // A long DM history must not bury the communities below it: past a
- // handful, the rest waits behind "show all". (No cap for barely-over —
- // a "show 2 more" button costs more than the rows it hides.)
- const DM_PREVIEW = 5;
- const shownDms = (ui.msgAllDms || dmRows.length <= DM_PREVIEW + 2) ? dmRows : dmRows.slice(0, DM_PREVIEW);
+ // A long DM history must not bury the communities below it: past four,
+ // the rest waits behind "show all". Communities get the same cap.
+ const LIST_PREVIEW = 4;
+ const shownDms = ui.msgAllDms ? dmRows : dmRows.slice(0, LIST_PREVIEW);
kids.push(
dmRows.length
? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) =>
@@ -8256,7 +8777,7 @@ export function messagesFeature(ctx) {
if (shownDms.length < dmRows.length)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } },
t('msgShowAllDms', { n: dmRows.length })));
- else if (ui.msgAllDms && dmRows.length > DM_PREVIEW + 2)
+ else if (ui.msgAllDms && dmRows.length > LIST_PREVIEW)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = false; render(); } },
t('msgShowFewerDms')));
@@ -8293,7 +8814,12 @@ export function messagesFeature(ctx) {
value: ui.msgNewName || '', onInput: (e) => { ui.msgNewName = e.target.value; },
}),
h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate'))));
- kids.push(h('div', { class: 'list' }, communities().map((jm) => {
+ const allRooms = communities();
+ const shownRooms = ui.msgAllRooms ? allRooms : allRooms.slice(0, LIST_PREVIEW);
+ // the rooms on the list get their faces while it sits there, so even a
+ // tap with no hover before it finds them ready
+ setTimeout(() => { for (const jm of shownRooms) if (rooms.has(jm.community_id)) warmRoom(jm); }, 300);
+ kids.push(h('div', { class: 'list' }, shownRooms.map((jm) => {
const room = rooms.get(jm.community_id);
const name = room?.folded?.metadata?.name || jm.name;
// Last session's settled count anchors the number: while the guestbook
@@ -8307,7 +8833,9 @@ export function messagesFeature(ctx) {
const unread = room ? roomUnread(room) : false;
return h('div', {
class: 'item chat-thread-row' + (unread ? ' unread' : ''),
- onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); },
+ onMouseenter: () => warmRoom(jm),
+ onPointerdown: () => warmRoom(jm),
+ onClick: () => openRoomWarm(jm, () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; }),
},
h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
@@ -8317,6 +8845,12 @@ export function messagesFeature(ctx) {
// the row's own centring puts the dot mid-height, off the edge
unread ? h('i', { class: 'thread-dot' }) : null);
})));
+ if (shownRooms.length < allRooms.length)
+ kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllRooms = true; render(); } },
+ t('msgShowAllCommunities', { n: allRooms.length })));
+ else if (ui.msgAllRooms && allRooms.length > LIST_PREVIEW)
+ kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllRooms = false; render(); } },
+ t('msgShowFewerDms')));
return h('div', { class: 'card col chat-page', style: 'gap:10px' }, ...kids);
}
@@ -8352,6 +8886,45 @@ export function messagesFeature(ctx) {
// ---- room ---------------------------------------------------------------
+ // ---- a room's faces, before it opens -------------------------------------
+ // Opening a community painted everyone as a punk for a beat: their
+ // profiles weren't in memory yet. As SvelteKit preloads on hover, a
+ // pointer over (or pressing) a room's row starts the faces of its latest
+ // messages — profile, then the picture decoded — and the tap waits for
+ // them, a moment at most, before the room replaces the list.
+ const ROOM_READY_MS = 1500;
+ const roomWarm = new Map(); // cid -> { at, promise }
+ let roomOpenSeq = 0;
+ function roomFaces(jm) {
+ const room = ensureRoom(jm);
+ const ch = roomChannels(room)[0];
+ if (!ch) return [];
+ // everyone the channel paints, newest speakers first
+ const msgs = [...(room.byChannel.get(ch.id)?.values() || [])]
+ .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor));
+ return [...new Set(msgs.map((m) => m.author))].slice(0, 150);
+ }
+ // Each face is warmed once per half minute; a later call (the history
+ // still streaming in when the list first warmed) adds whoever is new.
+ function warmRoom(jm) {
+ let w = roomWarm.get(jm.community_id);
+ if (!w || Date.now() - w.at > 30_000) { w = { at: Date.now(), faces: new Map() }; roomWarm.set(jm.community_id, w); }
+ const deadline = Date.now() + 8000;
+ try {
+ for (const pk of roomFaces(jm)) if (!w.faces.has(pk)) w.faces.set(pk, warmAvatar(pk, deadline).catch(() => {}));
+ } catch {}
+ return Promise.all(w.faces.values());
+ }
+ async function openRoomWarm(jm, go) {
+ const seq = ++roomOpenSeq;
+ const from = ui.msgView;
+ await Promise.race([warmRoom(jm), new Promise((r) => setTimeout(r, ROOM_READY_MS))]);
+ // another tap, or the reader went elsewhere, while the faces loaded
+ if (seq !== roomOpenSeq || ui.msgView !== from || !ui.chatOpen) return;
+ go();
+ render();
+ }
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -8694,10 +9267,11 @@ export function messagesFeature(ctx) {
// kept as a short list of what-happened rows rather than the raw events —
// a zap receipt drags its whole request along, and the list rides in the
// feature state. Painted from the last visit while the relays are asked.
- const NOTIF_KINDS = [1, 6, 7, ...ZAP_KINDS];
+ const NOTIF_KINDS = [1, COMMENT, 6, 7, ...ZAP_KINDS];
const NOTIF_KEEP = 150;
let notif = null; // { status, items }
let notifAt = 0, notifUnsub = null;
+ const notifNoteAsking = new Set();
const notifSeen = () => st().notifSeen || 0;
// Notifications are about the IDENTITY, not the wallet: the stored list
// remembers whose it is, and another identity on the same wallet starts
@@ -8708,7 +9282,17 @@ export function messagesFeature(ctx) {
const s = st();
const stored = (!s.notifsPk || s.notifsPk === identityPk()) ? (s.notifs || []) : [];
notif = { status: stored.length ? 'ready' : 'loading', items: stored };
+ // Reply events and the notes reactions/zaps point at ride beside their
+ // notification rows. Seed the in-memory note caches synchronously, so
+ // opening Notifications after a reload never paints "Fetching note…".
+ const noteCache = s.notifNotesPk === identityPk() ? (s.notifNoteCache || {}) : {};
+ for (const note of Object.values(noteCache)) {
+ if (!note || !note.id) continue;
+ notifNotes.set(note.id, note);
+ quoted.set(note.id, { status: 'ready', ev: note, at: Date.now() });
+ }
warmNotifFaces(stored);
+ warmNotifNotes(stored);
refreshNotifs();
}
return notif;
@@ -8726,6 +9310,58 @@ export function messagesFeature(ctx) {
const deadline = Date.now() + READY_MS;
for (const pk of pks) warmAvatar(pk, deadline).catch(() => {});
}
+ // Keep only what rendering and opening a thread need. Signatures and relay
+ // metadata make this cache unnecessarily large; the relays remain the
+ // source of truth whenever the live notification refresh runs.
+ const savedNotifNote = (ev) => ev && ev.id ? ({
+ id: ev.id, pubkey: ev.pubkey, kind: ev.kind, created_at: ev.created_at,
+ content: ev.content || '', tags: ev.tags || [],
+ }) : null;
+ function persistNotifs() {
+ if (!notif) return;
+ const wanted = new Set();
+ for (const x of notif.items) {
+ const id = x.what === 'reply' || x.what === 'mention' ? x.id : x.target;
+ if (id) wanted.add(id);
+ }
+ const notes = {};
+ for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id));
+ const s2 = st();
+ s2.notifs = notif.items; s2.notifsPk = identityPk();
+ s2.notifNoteCache = notes; s2.notifNotesPk = identityPk();
+ save(s2);
+ }
+ function cacheNotifNotes(evs) {
+ if (!notif || !(evs || []).length) return false;
+ const byId = new Map((evs || []).filter((ev) => ev && ev.id).map((ev) => [ev.id, savedNotifNote(ev)]));
+ let changed = false;
+ const wanted = new Set();
+ for (const x of notif.items) wanted.add(x.what === 'reply' || x.what === 'mention' ? x.id : x.target);
+ for (const [id, note] of byId) {
+ if (!wanted.has(id) || notifNotes.has(id)) continue;
+ notifNotes.set(id, note);
+ quoted.set(id, { status: 'ready', ev: note, at: Date.now() });
+ changed = true;
+ }
+ if (changed) persistNotifs();
+ return changed;
+ }
+ // Old notification rows did not carry their note. Upgrade them in one
+ // bounded batch while the bell/chat home is visible, before the user opens
+ // the page. New rows take the same path as soon as they arrive.
+ function warmNotifNotes(items) {
+ const ids = [...new Set((items || [])
+ .filter((x) => x && x.target && x.what !== 'reply' && x.what !== 'mention' && !notifNotes.has(x.target))
+ .map((x) => x.target).filter((id) => !notifNoteAsking.has(id)))].slice(0, NOTIF_KEEP);
+ if (!ids.length) return;
+ for (const id of ids) notifNoteAsking.add(id);
+ (async () => {
+ const got = [];
+ for (let i = 0; i < ids.length; i += 60)
+ got.push(...await queryOn(zapRelays(), { ids: ids.slice(i, i + 60) }, 4500).catch(() => []));
+ if (cacheNotifNotes(got)) scheduleRepaint();
+ })().finally(() => { for (const id of ids) notifNoteAsking.delete(id); });
+ }
// What an event says happened, or null if it isn't about you after all.
function notifItem(ev) {
const my = myPubkeys();
@@ -8746,6 +9382,10 @@ export function messagesFeature(ctx) {
return { id: ev.id, what: 'react', actor: ev.pubkey, target: lastE, hint, emoji, ts: ev.created_at };
}
if (ev.kind === 6) return lastE ? { id: ev.id, what: 'boost', actor: ev.pubkey, target: lastE, hint, ts: ev.created_at } : null;
+ if (ev.kind === COMMENT) {
+ const parent = tagVal(ev, 'e') || tagVal(ev, 'E');
+ return { id: ev.id, what: parent ? 'reply' : 'mention', actor: ev.pubkey, target: parent, text: String(ev.content || '').slice(0, 300), ts: ev.created_at, pubkey: ev.pubkey };
+ }
if (ev.kind === 1) {
const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]);
const replyTo = (es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es.at(-1) || [])[1] || null;
@@ -8757,7 +9397,7 @@ export function messagesFeature(ctx) {
const c = notifNow();
const known = new Set(c.items.map((x) => x.id));
for (const ev of evs || []) noteForSpam(ev);
- const add = (evs || []).map((ev) => (ev.kind === 1 && hidden(ev) ? null : notifItem(ev)))
+ const add = (evs || []).map((ev) => ((ev.kind === 1 || ev.kind === COMMENT) && hidden(ev) ? null : notifItem(ev)))
.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id));
if (!add.length) return false;
warmNotifFaces(add);
@@ -8772,11 +9412,12 @@ export function messagesFeature(ctx) {
kept.push(x);
}
c.items = kept.slice(0, NOTIF_KEEP);
- // the rows already say who; a reply row also wants its reader-facing text,
- // which it carries — the events themselves are not kept
- const s2 = st(); s2.notifs = c.items; s2.notifsPk = identityPk(); save(s2);
- // a reply is a note we can open straight away; keep it in hand
- for (const ev of evs) if (ev.kind === 1) notifNotes.set(ev.id, ev);
+ // Keep the small notification rows and one deduplicated copy of each note
+ // they show. A reply is also a note we can open straight away.
+ for (const ev of evs) if (ev.kind === 1 || ev.kind === COMMENT) notifNotes.set(ev.id, ev);
+ cacheNotifNotes(evs);
+ warmNotifNotes(c.items);
+ persistNotifs();
return true;
}
const notifNotes = new Map(); // reply id -> event, for opening the thread
@@ -8998,6 +9639,7 @@ export function messagesFeature(ctx) {
if (!Object.keys(s.read || {}).length && !anyMsgs) return 1;
return unreadCount();
},
+ bottomNav: appBottomNav,
notifySettingsCards() { return [notifyCard()]; },
screenView() {
// A profile deep link mid-resolution holds the frame over EVERY screen