diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 326 |
1 files changed, 266 insertions, 60 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 030d9fa..6c6b48d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js'; // coinos's own relay and primal: both take DM gift wraps and pass call // messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it // refused or ignored the household IP's publishes, call answers among them. -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net']; +// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads; +// relay.coinos.io stays on while older inbox lists still name it +const MESSAGES_RELAY = 'wss://messages.coinos.io'; +const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net']; +// what this app put in a wallet key's kind 10050 before messages.coinos.io +const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -3314,12 +3319,19 @@ export function messagesFeature(ctx) { // — the user's other clients own that. And when a login identity IS active, // don't advertise the seed key at all: it's internal, not a second inbox // (advertising it is what let DMs land on a phantom identity). + // A list this app wrote with its old defaults (nothing else in it) is + // brought up to today's, so senders find messages.coinos.io first. async function ensureDmRelayList() { if (!wallet.nostr || !wallet.nostr.sk) return; if (hook('nostrLoginIdentity')) return; const pk = wallet.nostr.pk; const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500); - if (existing.length) return; + if (existing.length) { + const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, '')); + const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u)); + if (!ours || urls.includes(DM_RELAYS[0])) return; + } const evt = finalizeEvent({ kind: 10050, content: '', @@ -4123,7 +4135,8 @@ export function messagesFeature(ctx) { // ---- profile notes: latest public posts & replies ----------------------- - const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + // (not messages.coinos.io: it takes messages, not posts) + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])]; // ---- zap tallies --------------------------------------------------------- // How much a post or chat message has been zapped: the public receipts @@ -4528,10 +4541,12 @@ export function messagesFeature(ctx) { const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])] .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) .sort((a, b) => a.moment.seconds - b.moment.seconds) - .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })); + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) })); // how a moment shows on a bar: a reaction as its emoji (a :code: as its - // picture, from the reaction's own tag), a zap ⚡, a reply ↩ + // picture, from the reaction's own tag), a zap ⚡, a reply as its + // author's face function momentGlyph(x) { + if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false); if (x.ev.kind !== 7) return x.icon; const code = shortcodeOf(x.ev.content); const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]); @@ -4551,6 +4566,19 @@ export function messagesFeature(ctx) { } return null; } + // A comment made at a moment of a clip opens with its time as a link that + // plays the clip from there; the "[0:03] " other clients read as text is + // left out of the words beside it. + function momentLink(ev) { + const m = readMoment(ev); + if (!m) return null; + return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(m.seconds)); + } + const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text); const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; }; function attachMediaTimeline(player, url, explicitEvent) { if (player._timeline) return; @@ -4562,7 +4590,8 @@ export function messagesFeature(ctx) { }; const box = player.parentElement; box._skipMorph = true; - box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph })); + box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph, + onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null })); player.dispatchEvent(new Event('timeupdate')); } function noteEvent(ev) { @@ -7949,7 +7978,7 @@ export function messagesFeature(ctx) { // known, never taller than the screen; only a clip taller than that sits // in a black frame, rather than in a strip at the left. const videoBoxStyle = (d) => d && d.width && d.height - ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain` + ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain` : 'width:100%;aspect-ratio:16/9;object-fit:contain'; // Over a clip that waits for a tap: what it costs, and the tap that plays // it (with sound — the reader asked for this one). The box is left alone @@ -7967,7 +7996,7 @@ export function messagesFeature(ctx) { if (v) { if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url)); v.preload = 'auto'; - v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; }); + v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); }); } } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url))); box.append(cover); @@ -8095,10 +8124,38 @@ export function messagesFeature(ctx) { }); } // A tap on the picture itself (not the player's own play button or its - // control bar): inline, it goes full screen — the corner button is a + // control bar): inline, a mouse click pauses a playing clip; a paused one + // (or any touch) goes full screen — the corner button is a // reach on a phone. Full screen, it pauses a playing clip, and a tap on a // paused one comes back out. The player's controls are inside the video // element, so where they sit is judged from the tap's position. + // The big play/pause over a feed clip is for a mouse: <html> carries + // .has-mouse while the last pointer was one (a touch laptop switches with + // the hand), and CSS shows the button on :hover (a repaint can't drop it). + if (typeof document !== 'undefined') { + const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on); + document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true }); + document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true }); + } + // A clip whose post has timed reactions seeks on the moments bar under it + // (the reactions sit on it at their moments), so the player's own controls + // — a second seek bar — stay off; the middle button and the sound button + // are its controls then (Adam: "hide the main seek bar … and only show a + // moments seek bar"). Otherwise the player's controls, once it has woken. + const nativeControls = (v) => { v.controls = !v._ownBar; }; + function ownBar(player, on) { + const box = player.closest('.note-video-box'); + if (!box || player._ownBar === on) return; + player._ownBar = on; + box.classList.toggle('own-bar', on); + if (!box.classList.contains('idle')) nativeControls(player); + // with no player controls, the sound button stays to turn it back down + if (on && !box.querySelector('.vid-sound')) { + const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); }); + b.setSound(!player.muted); + box.append(b); + } + } const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null; let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened function videoTap(e, url) { @@ -8120,12 +8177,24 @@ export function messagesFeature(ctx) { else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); return true; } + // a mouse click on a playing clip just pauses it (Adam: "clicking on a + // video that's playing should pause the video, not full screen it"); it + // keeps its controls and the middle button resumes it, and a click on it + // paused opens the pager. A touch has no middle button: its tap still opens. + if (!v.paused && e.pointerType === 'mouse') { + v.pause(); + wokeClips.add(url); + const box = v.closest('.note-video-box'); + if (box) { box.classList.remove('idle'); box._skipMorph = true; } + nativeControls(v); + return true; + } if (openClip(v, url)) return true; // a clip still idle (autoplaying, no controls) becomes the reader's wokeClips.add(url); const box = v.closest('.note-video-box'); if (box) { box.classList.remove('idle'); box._skipMorph = true; } - v.controls = true; + nativeControls(v); const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen; if (!go) return true; // (the screen isn't turned for it: a wide clip plays with bars, and the @@ -8187,13 +8256,38 @@ export function messagesFeature(ctx) { if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now(); }); v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted - const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v); + // a tall clip is held to the cap's height and the box narrows to its + // shape, instead of a full-width box of black bars (Adam, on a 1920 screen) + const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined; + const known = feedMedia(url); + const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v); + v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; }); if (loud) box._skipMorph = true; + // With a mouse: a big play/pause button in the middle while the pointer is + // over the clip. It plays or pauses in place, never full screen (a click + // elsewhere on a paused clip opens the pager). Touch keeps its taps. + const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => { + e.stopPropagation(); e.preventDefault(); + const player = e.currentTarget.closest('.note-video-box')?.querySelector('video'); + if (!player) return; + if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause(); + } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' })); + const ppState = () => { + const live = v.closest('.note-video-box'); + const b = live && live.querySelector('.vid-pp'); + if (!b) return; + b.classList.toggle('playing', !v.paused); + b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause')); + }; + v.addEventListener('play', ppState); + v.addEventListener('pause', ppState); + box.append(pp); + // remembered by clip, so a repaint draws the box as it now stands const wake = () => { wokeClips.add(url); box.classList.remove('idle'); - v.controls = true; + nativeControls(v); }; v.addEventListener('playing', wake); const btn = loud ? null : soundBtn((button) => { @@ -8202,14 +8296,19 @@ export function messagesFeature(ctx) { const liveBox = button.closest('.note-video-box'); const player = liveBox?.querySelector('video'); if (!player) return; + if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; } player.muted = false; unmutedClips.add(url); liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted if (player.paused) player.play().catch(() => {}); - button.remove(); // the player's own controls carry mute from here + // the player's own controls carry mute from here — unless they're off + // for the moments bar: then this button turns it down again + if (player._ownBar) button.setSound(true); else button.remove(); }); v.onvolumechange = () => { - if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same + const b = v.closest('.note-video-box')?.querySelector('.vid-sound'); + if (v._ownBar && b && b.setSound) b.setSound(!v.muted); + if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same else unmutedClips.delete(url); // muted again from the control: the next box starts quiet }; if (btn) box.append(btn); @@ -9309,7 +9408,7 @@ export function messagesFeature(ctx) { }; return h('div', { class: 'col live-page', style: 'gap:12px' }, ctx.brandHeader(!ui.pubProf && wallet.loaded), - h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'card col live-main', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹') : backBtn(() => { ui.liveStream = null; }), @@ -9361,7 +9460,7 @@ export function messagesFeature(ctx) { watchersRow(w), liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null, ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null), - h('div', { class: 'card col', style: 'gap:8px' }, + h('div', { class: 'card col live-chat-card', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('liveChat')), h('div', { class: 'live-chat', 'data-key': 'live-chat:' + s.a, @@ -11077,6 +11176,10 @@ export function messagesFeature(ctx) { return null; } const hidden = (ev) => !!hiddenWhy(ev); + // A list row with nothing left to show: a post its author deleted, or a + // repost of one (a deletion that lands after the row did, too). Threads + // keep their deleted posts' places; lists just drop them. + const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev))); const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk)); const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk)); @@ -11990,13 +12093,9 @@ export function messagesFeature(ctx) { zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null) : 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' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), - readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { - e.stopPropagation(); - const m = readMoment(ev); - const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); - if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } - } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, + : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, + momentLink(ev), + ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))), foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -12447,7 +12546,9 @@ export function messagesFeature(ctx) { onKeydown: (e) => { mentionKey(e); if (e.defaultPrevented) return; - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; } + // a phone's Return is a new line (Reply is the button); a + // keyboard's Enter sends + if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; } if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); const el = e.target; @@ -12488,7 +12589,19 @@ export function messagesFeature(ctx) { previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('button', { class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, + // Cancel discards: the typed text, its attachments and the box. + // (It only folded the `replying` flag before, and the box stays + // up while a draft has text — so with anything typed it did + // nothing visible.) The field may still be focused, and the + // morph leaves a focused field's value alone: clear it by hand. + onClick: () => { + const text = s.draft || ''; + s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null; + ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url)); + const inp = document.querySelector('.thread-reply-input'); + if (inp) inp.value = ''; + render(); + }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -12587,12 +12700,32 @@ export function messagesFeature(ctx) { ...noteOverlays()); } - async function publishProfileFields(fields, opts = {}) { + // Every kind-0 publish merges over the newest copy on the relays, so two + // publishes in flight at once clobber each other: both read the same base, + // the later one lands without the earlier one's fields. That exact race + // put a migrated user's released placeholder name back into their lud16 + // (the rename repointed it, the legacy-avatar import republished the old + // base nine seconds later). One at a time, and the base is never older + // than what this session last wrote. + let kind0Chain = Promise.resolve(); + let kind0Last = null; // { at, content } — the newest kind 0 we published + const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; }; + async function newestKind0(pk) { + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000); + let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null; + if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last; + let base = {}; + try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + return base; + } + // Strictly later than the last one we sent: relays keep the LOWEST id of + // two replaceable events with the same second, so a second publish in + // the same second as the first could lose to it. + const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; }; + function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); } + async function publishProfileFieldsNow(fields, opts = {}) { const id = await requireIdentity(); - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be const merged = { ...base }; // fillOnly fields are offers, not orders: the onboarding wizard suggests @@ -12608,10 +12741,11 @@ export function messagesFeature(ctx) { } if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); return true; @@ -12625,11 +12759,20 @@ export function messagesFeature(ctx) { ui.profSaving = true; render(); try { + await withKind0Lock(() => saveProfileNow(id, e)); + ui.profEdit = null; ui.profEditFilled = false; + toast(t('profSaved')); + } catch (err) { + if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; + toast(err.message || String(err)); + } finally { + ui.profSaving = false; + render(); + } + } + async function saveProfileNow(id, e) { // merge over the newest published kind 0 so unknown fields round-trip - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); // A changed username claims first — if the name is taken (or invalid) // the save stops here and the editor stays open to fix it. const oldAddr = hook('namesAddress'); @@ -12649,26 +12792,62 @@ export function messagesFeature(ctx) { // when empty; move them when they pointed at our (old) address. A // deliberately foreign lud16/nip05 is the user's business. if (addr) { - for (const k of ['lud16', 'nip05']) - if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; + const domain = addr.split('@')[1]; + for (const k of ['lud16', 'nip05']) { + const cur = merged[k]; + if (!cur || cur === oldAddr) { merged[k] = addr; continue; } + // a name at our own domain that is not ours and no longer answers + // (the placeholder a migration released) is ours now — Save is the + // heal an interactive signer can't do in the background + if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr; + } } if (merged.name) merged.display_name = merged.name; - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); - ui.profEdit = null; ui.profEditFilled = false; - toast(t('profSaved')); - } catch (err) { - if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; - toast(err.message || String(err)); - } finally { - ui.profSaving = false; - render(); + } + // A payment-address rename released the old name: repoint the kind 0's + // lud16 and nip05 — but only where they pointed at the released address + // (or were empty). Deliberately different values are not ours to touch. + function repointAddress(oldAddr, newAddr) { + const follows = (k) => (base) => !base[k] || base[k] === oldAddr; + return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { + onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), + fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, + }); + } + // Our kind 0 still names an address at our own domain that is not the one + // we hold — a placeholder name released by a migration whose repoint lost + // the race above. Repointed once the domain confirms the old name is dead; + // a same-domain name that still answers (a second account, say) is left + // alone. Once per address per session. + const addrHealed = new Set(); + async function healStaleAddress(olds, myAddr) { + // Amber by intent needs a tap per signature: a background publish would + // sit in the kind-0 queue forever and block every later one. Save in the + // profile editor repoints the address for these users instead. + const id = await identity().catch(() => null); + if (!id || (id.signer && id.signer.interactive)) return; + for (const old of olds) { + if (addrHealed.has(old)) continue; + addrHealed.add(old); + try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {} } } + async function addressDead(addr) { + const [name, domain] = addr.split('@'); + try { + const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' }); + if (!r.ok) return true; + const j = await r.json().catch(() => null); + return !j || j.status === 'ERROR' || !j.callback; + } catch { return false; } // unreachable is not dead + } function profileScreen() { const pk = ui.profilePk; @@ -12808,9 +12987,17 @@ export function messagesFeature(ctx) { // authoritative locally, a deep link carries the address in the URL // (seeded as `addr`), and the light cache remembers it from last time. const lp = profileOf(pk) || {}; - const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null; + // Our own page never shows a name at our domain that the registrar says + // is no longer ours (see healStaleAddress): the claimed address is the + // truth here, and the kind 0 is brought in line in the background. + const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null; + if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); } + const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; - const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + // the lightning line too: a stale same-domain lud16 (jar@ after the + // rename to jarryd@) showed under the right NIP-05 as a second address + const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; // an about of "~" or a lone character is noise, not a bio. // Until the full kind 0 lands, the light profile cache answers — it was @@ -13054,7 +13241,8 @@ export function messagesFeature(ctx) { // Streams joins the tabs once they've streamed const streamed = pastOf(pk).length > 0; const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); - const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); + // a post its author deleted leaves the list (no "was deleted" row) + const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e)); // a tab that holds only a few of the pages fetched so far (someone // who mostly replies, or mostly posts) asks for older ones by itself: // too short to scroll means the scroll that loads more never comes @@ -13087,7 +13275,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'col', style: 'gap:8px' }, tabs, h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab }, - ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), + ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) @@ -13498,7 +13686,9 @@ export function messagesFeature(ctx) { const key = mediaIdxKey(feedId); let r = mediaIdx.get(key); if (r) return r; - r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; + const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 }); + r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false, + order: { images: order(), videos: order() } }; mediaIdx.set(key, r); loadMediaIndex(key).then(async (rec) => { if (rec) { @@ -14037,7 +14227,27 @@ export function messagesFeature(ctx) { out.push({ ev, url }); } } - return out; + return settledOrder(r, kind, out); + } + // The grid's order holds for the session (Adam, 2026-10-23: clips the + // crawl found landed in the middle of the grid, above an older tail the + // index had from before, and the tiles already there jumped): what it + // shows first is newest first, and whatever turns up after goes under what + // is already there, in its own date order. Only a post newer than the top + // tile goes above it. Nothing is settled until the kept index is in, so + // the first paint is one date order, not two batches. + function settledOrder(r, kind, out) { + if (!r.loaded) return out; + const o = r.order[kind]; + const key = (it) => it.ev.id + '|' + it.url; + const fresh = out.filter((it) => !o.seq.has(key(it))); + if (fresh.length) { + const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : []; + for (const it of above.reverse()) o.seq.set(key(it), --o.top); + for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom); + for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at; + } + return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b))); } // The feed's pictures (or clips) in reading order — every one of a post // before the next post's — for the pager opened from one in the feed: the @@ -15868,7 +16078,7 @@ export function messagesFeature(ctx) { // (a reply whose post every lookup came back without isn't shown: a // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20) const repliesHere = feedReplies(c); - const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev)); + const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev)); const visible = c.notes.filter(shownHere); // Every child keyed by its post, hairlines included, so the morph // reconciles the list by post: a new one at the top is inserted as its @@ -18233,13 +18443,9 @@ export function messagesFeature(ctx) { // A payment-address rename released the old name: repoint the kind 0's // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. - addressRenamed(oldAddr, newAddr) { - const follows = (k) => (base) => !base[k] || base[k] === oldAddr; - return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { - onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), - fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, - }).catch(() => {}); - }, + addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // the profile editor's Save, without the editor (tests) + async saveProfileDraft(e) { const id = await identity(); return withKind0Lock(() => saveProfileNow(id, { name: '', about: '', picture: '', banner: '', ...e })); }, // opens the profile, unless the caller has a step to take first (the // Accounts page switches to that identity before showing its profile) profileChip(pk, size, onOpen) { |
