diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 15 | ||||
| -rw-r--r-- | src/features/messages.js | 234 | ||||
| -rw-r--r-- | src/features/zaps.js | 9 |
3 files changed, 156 insertions, 102 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 22ba5fd..dbc97f6 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1514,7 +1514,16 @@ export function arkFeature(ctx) { // arkoor send and publishes a receipt referencing the delivered vtxo. // Kinds are provisional until a NIP lands. const ARK_INFO_KIND = 10037; // replaceable: ["ark", <address>], ["network", <net>] - const ARK_ZAP_KIND = 9737; // receipt: ["p", pk], ["e", note?], ["amount", sats], ["vtxo", id], ["network", net] + const ARK_ZAP_KIND = 9737; // receipt: ["p", pk], ["e", note?], ["amount", sats], ["vtxo", id], ["network", net], ["P", sender?] + // The receipt is signed by the wallet key, which has no profile. When the + // wallet is linked to a nostr login, name that identity as the sender (the + // same "P" tag NIP-57 receipts use) so the zap reads as the person, not + // as an npub nobody recognizes. + const zapSenderTag = () => { + const id = ctx.hook('nostrLoginIdentity'); + const me = wallet.nostrPubkey && wallet.nostrPubkey(); + return id && id.pubkey && id.pubkey !== me ? [['P', id.pubkey]] : []; + }; // npub decode without importing the nostr stack (keeps ark-only builds lean) function npubToHex(s) { @@ -1671,7 +1680,7 @@ export function arkFeature(ctx) { kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), - ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], + ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()], }).catch(() => {}); } @@ -1715,7 +1724,7 @@ export function arkFeature(ctx) { 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]], + ['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) { diff --git a/src/features/messages.js b/src/features/messages.js index acde0cb..958cd8d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -29,13 +29,14 @@ import { packAddr, packNaddr, parsePackRef, parsePackAddr, parseEmojiSet, } from '../emoji.js'; import { saveInbox } from '../dm-inbox.js'; -import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js'; +import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl } from '../recipient-search.js'; import { getNetwork } from '../api.js'; import { decodeBolt11 } from '../ark/lightning.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { sha256 } from '@noble/hashes/sha256'; import { base64urlnopad } from '@scure/base'; import { t } from '../i18n.js'; +import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; // The coinos community's join material lives in ../community.js — shared @@ -544,7 +545,7 @@ export function messagesFeature(ctx) { if (!p || (!p.name && !p.picture)) return; const s = wallet.loadFeatureState('profiles', {}); s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, - about: p.about || null, banner: p.banner || null, t: Date.now(), + about: p.about || null, banner: p.banner || null, eventAt: p.eventAt || 0, t: Date.now(), ...(p.thumbFor === p.picture && p.thumb ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, @@ -582,7 +583,7 @@ export function messagesFeature(ctx) { const PUNK_PIC_RE = /^(?:https?:\/\/(?:[a-z0-9-]+\.)*coinos\.io\/)?punks\/(\d{1,2})\.webp$/i; const localPunk = (url, big) => { const m = PUNK_PIC_RE.exec(url || ''); - return m ? (big ? `punks/${m[1]}.webp` : `punks-sm/${m[1]}.webp`) : null; + return m ? punkImageUrl(m[1], !big) : null; }; // A refreshed profile whose picture hasn't changed keeps the thumbnail we @@ -755,7 +756,10 @@ export function messagesFeature(ctx) { function applyProfile(pk, ev) { let m = null; try { m = JSON.parse(ev.content); } catch { m = null; } - const p = m ? { + if (!m || typeof m !== 'object' || Array.isArray(m)) return false; + const prev = profiles.get(pk); + if ((prev?.eventAt || 0) > ev.created_at) return true; + const p = { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null, @@ -767,14 +771,20 @@ export function messagesFeature(ctx) { // renders anyway. about: typeof m.about === 'string' ? m.about.slice(0, 1000) : null, banner: typeof m.banner === 'string' ? m.banner.slice(0, 400) : null, - } : null; - // An empty answer must never clobber a remembered face with a punk: keep - // what we had and just refresh the clock. - const prev = profiles.get(pk); - const entry = keepThumb(pk, p ? { ...p, t: Date.now(), miss: 0 } : { ...(prev || {}), t: Date.now() }); + }; + const entry = keepThumb(pk, { ...p, eventAt: ev.created_at, t: Date.now(), miss: 0 }); profiles.set(pk, entry); + // A page and the batched avatar lookup must share their answer. A slow + // page miss must never erase a name/photo the batch already found. + if (fullInFlight.has(pk) || fullProfiles.has(pk)) { + fullProfiles.set(pk, m); + fullFetched.add(pk); + fullMisses.delete(pk); + persistPage('full', pk, m); + } persistProfile(pk, entry); preloadPicture(entry); + return true; } async function fetchProfiles(pks) { @@ -789,7 +799,7 @@ export function messagesFeature(ctx) { const c = newest.get(ev.pubkey); if (!c || ev.created_at > c.created_at) newest.set(ev.pubkey, ev); } - for (const [pk, ev] of newest) { applyProfile(pk, ev); found.add(pk); } + for (const [pk, ev] of newest) { if (applyProfile(pk, ev)) found.add(pk); } // paint what this batch found before going after the stragglers — // the outbox fallback below can take seconds, and there's no reason // for a face we already have to wait behind one we don't @@ -808,7 +818,7 @@ export function messagesFeature(ctx) { const c = idxNewest.get(ev.pubkey); if (!c || ev.created_at > c.created_at) idxNewest.set(ev.pubkey, ev); } - for (const [pk, ev] of idxNewest) { applyProfile(pk, ev); found.add(pk); } + for (const [pk, ev] of idxNewest) { if (applyProfile(pk, ev)) found.add(pk); } if (idxNewest.size) scheduleRepaint(); } const stillLeft = pks.filter((pk) => !found.has(pk)); @@ -822,7 +832,7 @@ export function messagesFeature(ctx) { const c = newest.get(ev.pubkey); if (!c || ev.created_at > c.created_at) newest.set(ev.pubkey, ev); } - for (const [pk, ev] of newest) { applyProfile(pk, ev); found.add(pk); } + for (const [pk, ev] of newest) { if (applyProfile(pk, ev)) found.add(pk); } })); } // nobody has one: mark the clock so it's retried in minutes, not asked @@ -1471,7 +1481,7 @@ export function messagesFeature(ctx) { close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), - !ui.emojiPick && !mine && canZapPk(m.author) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null, + !ui.emojiPick && canZapPk(m.author) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null, ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {} close(); @@ -2773,6 +2783,8 @@ export function messagesFeature(ctx) { const fullProfiles = new Map(); // pk -> { raw kind0 content object, fetched_at } const fullFetched = new Set(); // fetched from relays this session + const fullInFlight = new Map(); + const fullMisses = new Set(); // The profile-page cache SURVIVES refreshes: bios and recent posts serve // instantly from storage while a background fetch freshens them. Bounded: @@ -2807,32 +2819,20 @@ export function messagesFeature(ctx) { const cached = pageCache().full[pk]; if (cached) fullProfiles.set(pk, cached.v); } - if (fullFetched.has(pk)) return; - fullFetched.add(pk); - queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3500).then((evs) => { - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let m = {}; - try { m = newest ? JSON.parse(newest.content) : {}; } catch {} - fullProfiles.set(pk, m); - persistPage('full', pk, m); - // A full fetch is the freshest word on this profile — stamp it into the - // light cache (and persist) so a picture changed elsewhere replaces the - // stale one everywhere within a session, not after the 24h TTL. The - // repaint covers the header/chat avatars, not just an open profile page. - const entry = { - name: m.display_name || m.name || null, picture: m.picture || null, - nip05: m.nip05 || null, lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null, - about: typeof m.about === 'string' ? m.about.slice(0, 1000) : null, - banner: typeof m.banner === 'string' ? m.banner.slice(0, 400) : null, - t: Date.now(), - }; - keepThumb(pk, entry); - profiles.set(pk, entry); - persistProfile(pk, entry); - preloadPicture(entry); + if (fullFetched.has(pk)) return Promise.resolve(); + if (fullInFlight.has(pk)) return fullInFlight.get(pk); + fullMisses.delete(pk); + // Use the same index-relay + author's-relay fallbacks as feed avatars. + // Only a real kind-0 response marks a page fetched; relay silence is + // retryable and leaves cached profile details intact. + const pending = fetchProfiles([pk]).catch(() => {}).finally(() => { + fullInFlight.delete(pk); + if (!fullFetched.has(pk)) fullMisses.add(pk); if (ui.profilePk === pk) render(); else scheduleRepaint(); - }).catch(() => { if (!fullProfiles.has(pk)) fullProfiles.set(pk, {}); }); + }); + fullInFlight.set(pk, pending); + return pending; } // Warm a profile PAGE (full kind-0 + latest notes + relay list) before @@ -2888,7 +2888,10 @@ export function messagesFeature(ctx) { // ...and, in the same REQ, what else happened to a note: likes (kind 7) // and boosts (kind 6). One round trip for all three. const NOTE_KINDS = [9735, 9737, 7, 6]; - const reacts = new Map(); // note id -> Map(emoji -> Set<pubkey>) + const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> }) + // Resolve from this reaction's own tag: packs can reuse the same shortcode + // for different pictures, and an unrelated event must not change its face. + const postReactNode = ({ emoji, url }) => url ? emojiImg(shortcodeOf(emoji), url) : emoji; const boosts = new Map(); // note id -> Set<pubkey> const seenNoteEv = new Set(); // event ids already counted const myReactEv = new Map(); // note id -> the id of OUR reaction, so it can be withdrawn @@ -2919,7 +2922,13 @@ export function messagesFeature(ctx) { try { return String((JSON.parse(tagOf(ev, 'description') || 'null') || {}).content || '').slice(0, 200); } catch { return ''; } } function zapperOf(ev) { - if (ev.kind === 9737) return ev.pubkey; + if (ev.kind === 9737) { + // Signed by the wallet key; the sender tag names the person behind it. + // Older receipts of ours have no such tag — read them as us all the same. + const sender = tagOf(ev, 'P') || ev.pubkey; + const id = hook('nostrLoginIdentity'); + return id && wallet.nostr && sender === wallet.nostr.pk ? id.pubkey : sender; + } if (tagOf(ev, 'P')) return tagOf(ev, 'P'); try { return (JSON.parse(tagOf(ev, 'description') || 'null') || {}).pubkey || null; } catch { return null; } } @@ -2938,11 +2947,15 @@ export function messagesFeature(ctx) { boosts.get(id).add(ev.pubkey); } else { // '+' and an empty content both mean a plain like - const emoji = !ev.content || ev.content === '+' ? '\u2764\ufe0f' : ev.content.slice(0, 12); + const code = shortcodeOf(ev.content); + const emoji = !ev.content || ev.content === '+' ? '\u2764\ufe0f' : code ? ev.content : ev.content.slice(0, 12); + const url = code ? emojiTagMap(ev.tags).get(code) || null : null; + const key = JSON.stringify([emoji, url]); + noteEmoji(ev); if (!reacts.has(id)) reacts.set(id, new Map()); const m = reacts.get(id); - if (!m.has(emoji)) m.set(emoji, new Set()); - m.get(emoji).add(ev.pubkey); + if (!m.has(key)) m.set(key, { emoji, url, who: new Set() }); + m.get(key).who.add(ev.pubkey); // ours, wherever it was sent from — so it can be taken back here if (myPubkeys().includes(ev.pubkey)) myReactEv.set(id, ev.id); } @@ -3084,10 +3097,11 @@ export function messagesFeature(ctx) { if (Date.now() - p.at > pendTtl(p)) { zapPending.delete(id); return null; } return p; } - function markZapPending(id, sats) { + function markZapPending(id, sats, origin) { if (!id || !sats) return; zapPending.set(id, { sats, at: Date.now(), state: 'flying' }); - scheduleRepaint(); + animateZap(sats, origin, id); + render(); // repaint when the in-flight chip would expire, so a zap nobody ever // reported on doesn't pulse forever setTimeout(scheduleRepaint, ZAP_FLIGHT_MS + 200); @@ -3118,6 +3132,7 @@ export function messagesFeature(ctx) { // The chip: a little bolt + the sats total. Absent until the first receipt // — or until you zap it yourself, which is its own kind of receipt. function zapChip(id, { onClick, cls = '' } = {}) { + if (onClick) warmZapSound(); const z = zapTotals.get(id) || zapSeeds().get(id); const p = pendingOf(id); const optimistic = p && p.state !== 'void' ? p.sats : 0; @@ -4195,20 +4210,18 @@ export function messagesFeature(ctx) { // asks once and remembers. // Whether a ⚡ makes sense for this author from this wallet: not // ourselves, and an instant path (Ark) or a Lightning fallback exists. - const canZapPk = (pk) => !!pk && !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); - function zapNote(pk, ev) { + // Your own posts and messages too: a zap to yourself is a harmless + // round-trip, and one bar that behaves the same everywhere beats a special case. + const canZapPk = (pk) => !!pk && !!(hook('arkReady') || hook('canLnZap')); + function zapNote(pk, ev, origin) { + if (pendingOf(ev.id)?.state === 'flying') return; const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; - if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21' }; render(); return; } - markZapPending(ev.id, def); // the chip answers the tap; the flow reports back + if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin }; render(); return; } + markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { - // no instant path in this build — the classic form flow settleZap(ev.id, false); - ui.profilePk = null; - ui.chatOpen = false; - ctx.showSend(); - render(); - if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id); + toast('⚡ ' + t('lnZapFailed'), 4000); } } @@ -4230,8 +4243,11 @@ export function messagesFeature(ctx) { const { pk, npub, eventId } = s; ui.zapSetup = null; render(); - markZapPending(eventId, n); - if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) settleZap(eventId, false); + markZapPending(eventId, n, s.origin); + if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) { + settleZap(eventId, false); + toast('⚡ ' + t('lnZapFailed'), 4000); + } recheckZap(eventId); } }, t('zapSetupSave'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.zapSetup = null; render(); } }, t('back'))); @@ -4248,7 +4264,7 @@ export function messagesFeature(ctx) { const m = reacts.get(id); if (!m) return null; const mine = myPubkeys(); - for (const [emoji, who] of m) for (const pk of mine) if (who.has(pk)) return emoji; + for (const reaction of m.values()) for (const pk of mine) if (reaction.who.has(pk)) return reaction; return null; }; const iBoosted = (id) => { @@ -4264,10 +4280,22 @@ export function messagesFeature(ctx) { function replyToNote(ev) { if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; - render(); - } else openNoteThread(ev); - if (ui.noteThread) ui.noteThread.refocus = true; - setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120); + ui.noteThread.seed = ev; + } else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; + const s = ui.noteThread; + ui.profOverThread = false; + s.scrollPending = false; // focus the composer, without a competing note scroll + s.refocus = true; + render(); + // Focus in the tap handler so mobile browsers can open the keyboard. + focusThreadReply(s); + } + function focusThreadReply(s) { + if (ui.noteThread !== s || ui.profOverThread) return; + const input = document.querySelector('.thread-reply-input'); + if (!input) return; + input.focus({ preventScroll: true }); + input.scrollIntoView({ block: 'nearest' }); } async function unreact(ev) { @@ -4279,7 +4307,7 @@ export function messagesFeature(ctx) { // that don't go on showing it to other people — worth knowing, not // worth refusing to try. const m = reacts.get(ev.id); - if (m) for (const [emoji, who] of [...m]) { for (const pk of myPubkeys()) who.delete(pk); if (!who.size) m.delete(emoji); } + if (m) for (const [key, { who }] of [...m]) { for (const pk of myPubkeys()) who.delete(pk); if (!who.size) m.delete(key); } render(); const mineEv = myReactEv.get(ev.id); if (!mineEv) return; @@ -4297,7 +4325,7 @@ export function messagesFeature(ctx) { const relays = await noteRelaysFor(ev); const partial = { kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000), - tags: [['e', ev.id], ['p', ev.pubkey], CLIENT_TAG], + tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); myReactEv.set(ev.id, evt.id); @@ -4451,14 +4479,15 @@ export function messagesFeature(ctx) { const I_ZAP = ICON('<path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z"/>', true); function noteActions(pk, ev, { canZap }) { + if (canZap) warmZapSound(); // the clip is decoded before the first tap const mineReact = myReactOn(ev.id); const rm = reacts.get(ev.id); - const likeN = rm ? [...rm.values()].reduce((n, who) => n + who.size, 0) : 0; + const likeN = rm ? [...rm.values()].reduce((n, { who }) => n + who.size, 0) : 0; const boostN = (boosts.get(ev.id) || new Set()).size; const btn = (icon, label, count, on, onClick, cls = '') => h('button', { class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label, 'aria-disabled': onClick ? undefined : 'true', - onClick: (e) => { e.stopPropagation(); if (onClick) onClick(); }, + onClick: (e) => { e.stopPropagation(); if (onClick) onClick(e); }, }, typeof icon === 'string' && icon.startsWith('<svg') ? h('span', { style: 'display:flex', html: icon }) @@ -4478,23 +4507,24 @@ export function messagesFeature(ctx) { btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})), btn(I_QUOTE, t('postQuote'), 0, false, () => quoteNote(ev)), // tap to choose how you feel about it; tap again to take it back - btn(mineReact || I_HEART(false), t('postLike'), likeN, !!mineReact, + btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact, () => { if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }), - // shown whenever there is a tally to read, even where zapping is off - // (own post, no wallet) — then it is just a number, not a button - canZap || zapSats - ? btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, - canZap ? () => { zapNote(pk, ev); recheckZap(ev.id); } : null, - 'note-zap' + (zapSats ? ' zapped' : '') + (zapFlying ? ' flying' : '')) - : null, - // who did all that: a small chevron, only once there is anyone to show - whoCount(ev.id) - ? h('button', { - class: 'note-act note-who-toggle' + (whoOpen(ev.id) ? ' on' : ''), - title: t('postWho'), 'aria-label': t('postWho'), 'aria-expanded': whoOpen(ev.id) ? 'true' : 'false', - onClick: (e) => { e.stopPropagation(); toggleWho(ev.id); }, - }, h('span', { class: 'note-who-chev' + (whoOpen(ev.id) ? ' open' : ''), html: I_CHEV })) - : null); + // always in the bar — the same row of buttons on every post, so nothing + // shifts when a wallet comes online; without one it is just the number + btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, + canZap ? (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); } : null, + 'note-zap' + (zapSats ? ' zapped' : '') + (zapFlying ? ' flying' : '')), + // who did all that: a small chevron, always in place — dimmed until + // there is someone to show, so the first reaction doesn't reflow the bar + (() => { + const n = whoCount(ev.id), open = n && whoOpen(ev.id); + return h('button', { + class: 'note-act note-who-toggle' + (open ? ' on' : ''), + title: t('postWho'), 'aria-label': t('postWho'), 'aria-expanded': open ? 'true' : 'false', + 'aria-disabled': n ? undefined : 'true', + onClick: (e) => { e.stopPropagation(); if (n) toggleWho(ev.id); }, + }, h('span', { class: 'note-who-chev' + (open ? ' open' : ''), html: I_CHEV })); + })()); } // ---- who reacted, boosted and zapped ------------------------------------ @@ -4507,7 +4537,7 @@ export function messagesFeature(ctx) { const toggleWho = (id) => { if (whoOpenIds.has(id)) whoOpenIds.delete(id); else whoOpenIds.add(id); render(); }; function whoCount(id) { const rm = reacts.get(id); - let n = rm ? [...rm.values()].reduce((k, who) => k + who.size, 0) : 0; + let n = rm ? [...rm.values()].reduce((k, { who }) => k + who.size, 0) : 0; n += (boosts.get(id) || new Set()).size; n += (zapWho.get(id) || new Map()).size; return n; @@ -4536,8 +4566,8 @@ export function messagesFeature(ctx) { person(z.pk, h('span', { class: 'note-who-sats' }, fmtSats(z.sats) + ' sats' + (z.text ? ' · ' + z.text : ''))))))); } - for (const [emoji, set] of [...rm.entries()].sort((a, b) => b[1].size - a[1].size)) { - lines.push(line(h('span', { class: 'note-act-emoji' }, emoji), people([...set]))); + for (const reaction of [...rm.values()].sort((a, b) => b.who.size - a.who.size)) { + lines.push(line(h('span', { class: 'note-act-emoji' }, postReactNode(reaction)), people([...reaction.who]))); } if (bs.size) lines.push(line(h('span', { style: 'display:flex', html: I_BOOST }), people([...bs]))); if (!lines.length) return null; @@ -4567,7 +4597,7 @@ export function messagesFeature(ctx) { // profile is actually opened. profileOf(pk); const isReply = ev.tags.some((x) => x[0] === 'e'); - const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); + 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 const pending = !!ev.pending; @@ -4575,6 +4605,7 @@ export function messagesFeature(ctx) { if (!pending) watchZaps([ev.id]); return h('div', { class: 'row', + 'data-zap-post': ev.id, 'data-focus-note': focus ? '1' : undefined, style: 'gap:10px;align-items:flex-start;padding:10px 0' + (openable ? ';cursor:pointer' : '') @@ -4621,8 +4652,15 @@ export function messagesFeature(ctx) { function threadFor(seed) { const rootId = rootIdOf(seed); let c = threadCache.get(rootId); - if (c) return c; - c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: [] }; + if (c) { + if (seed.id === rootId) c.root ||= seed; + else if (!c.replies.some((ev) => ev.id === seed.id)) + c.replies = [...c.replies, seed].sort((a, b) => a.created_at - b.created_at); + return c; + } + // We already have the tapped post. Keep it visible and replyable even + // if the root is unavailable or the relay's limited result omits it. + c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: seed.id === rootId ? [] : [seed] }; threadCache.set(rootId, c); (async () => { // the conversation's home relays are the root author's (NIP-10 outbox); @@ -4634,7 +4672,7 @@ export function messagesFeature(ctx) { ]); if (!c.root) c.root = (roots || [])[0] || null; const seen = new Set([rootId]); - c.replies = (replies || []) + c.replies = [...c.replies, ...(replies || [])] .filter((e) => !seen.has(e.id) && seen.add(e.id)) .sort((a, b) => a.created_at - b.created_at); c.status = 'ready'; @@ -4646,7 +4684,8 @@ export function messagesFeature(ctx) { // button asked for it. if (ui.noteThread.refocus) { ui.noteThread.refocus = false; - setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 30); + const s = ui.noteThread; + setTimeout(() => focusThreadReply(s), 30); } } })().catch(() => { @@ -5119,6 +5158,9 @@ export function messagesFeature(ctx) { // full header on profiles too — losing the search button here made // finding the NEXT person a trek back home ctx.brandHeader(!ui.pubProf && wallet.loaded), + fullMisses.has(pk) ? h('div', { class: 'row between gap6 small muted' }, + t(full || lp.eventAt || lp.name || lp.picture ? 'profFetchFailed' : 'profNotFound'), + h('button', { class: 'btn-sm', onClick: () => { fetchFullProfile(pk); render(); } }, t('retry'))) : null, h('div', { class: 'card col', style: 'gap:12px' }, bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(bannerUrl)})` }) : null, h('div', { class: 'row gap6', style: 'align-items:center' }, @@ -5954,11 +5996,11 @@ export function messagesFeature(ctx) { }, h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, - h('div', { class: 'row between' }, - h('span', { class: 'chat-name' }, name), - unread ? h('i', { class: 'thread-dot' }) : null), + h('span', { class: 'chat-name' }, name), h('div', { class: 'muted small' }, - memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted')))); + memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'))), + // the row's own centring puts the dot mid-height, off the edge + unread ? h('i', { class: 'thread-dot' }) : null); }))); return h('div', { class: 'card col chat-page', style: 'gap:10px' }, ...kids); @@ -6068,7 +6110,7 @@ export function messagesFeature(ctx) { title: t('msgReact'), onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); }, }, reactNode(emoji), n > 1 ? ' ' + n : ''))) - : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: !mine && canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null })))) + : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null })))) ); }); } @@ -6244,7 +6286,7 @@ export function messagesFeature(ctx) { close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), - !ui.emojiPick && !m.mine && canZapPk(m.rumor.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null, + !ui.emojiPick && canZapPk(m.rumor.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null, ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(m.rumor.content); toast(t('copied')); } catch {} close(); @@ -6273,7 +6315,7 @@ export function messagesFeature(ctx) { watchZaps(msgs.slice(-150).map((m) => m.rumor.id)); const dmChips = (m) => { const reacts = dmReacts.get(m.rumor.id); - const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: !m.mine && canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null }); + const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null }); if ((!reacts || !reacts.size) && !zc) return null; const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); const counts = new Map(); diff --git a/src/features/zaps.js b/src/features/zaps.js index 77f8475..54c8f58 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -214,7 +214,7 @@ export function zapsFeature(ctx) { } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 }); await hook('arkPayInvoice', invoice, { maxAmountSat: sats + 50 }); - toast('⚡ ' + t('zapSentShort', { n: fmtAmount(sats) + ' ' + unitLabel() })); + if (!target.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(sats) + ' ' + unitLabel() })); render(); } @@ -454,10 +454,13 @@ export function zapsFeature(ctx) { // this went, either way autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat) .then(() => hook('zapSettled', eventId, true, autoSat)) - .catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + e.message); }); + .catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); }); return true; } - if (autoSat) hook('zapSettled', eventId, false); // one tap couldn't serve it — the form takes over + if (autoSat) { + hook('zapSettled', eventId, false); + if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; } + } begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, |
