aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js176
1 files changed, 114 insertions, 62 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a75ae2a..d6eacc9 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1869,14 +1869,19 @@ export function messagesFeature(ctx) {
prefetchProfilePage(pk); // rows are tap-targets: have the page warm
const isReply = ev.tags.some((x) => x[0] === 'e');
const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap'));
+ // 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;
+ const openable = open && !pending;
return h('div', {
class: 'row',
style: 'gap:10px;align-items:flex-start;padding:10px 0'
- + (open ? ';cursor:pointer' : '')
+ + (openable ? ';cursor:pointer' : '')
+ + (pending ? ';opacity:.55' : '')
+ (focus ? ';background:var(--accent-soft,rgba(128,128,128,.08));border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px' : ''),
// closest('button') guard: on touch, a ⚡ tap must never double as a
// row tap even if propagation quirks let the click reach us
- onClick: open ? (e) => { if (e.target && e.target.closest && e.target.closest('button')) return; openNoteThread(ev); } : undefined,
+ onClick: openable ? (e) => { if (e.target && e.target.closest && e.target.closest('button')) return; openNoteThread(ev); } : undefined,
},
// the avatar is its own tap-target (profile), even inside an openable
// row — its handler stops propagation, so the row still opens the thread
@@ -1891,7 +1896,7 @@ export function messagesFeature(ctx) {
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
(isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000))),
- h('div', { class: 'row', style: 'gap:6px;flex-shrink:0' },
+ pending ? null : h('div', { class: 'row', style: 'gap:6px;flex-shrink:0' },
// Reply on every post: opens (or re-targets) its thread and puts
// the cursor in the reply box — no hunting for the row tap.
h('button', {
@@ -1901,6 +1906,7 @@ export function messagesFeature(ctx) {
ui.noteThread.focusId = ev.id;
render();
} else openNoteThread(ev);
+ if (ui.noteThread) ui.noteThread.refocus = true; // survives the box relocating when the thread loads
setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120);
},
}, '↩'),
@@ -1936,7 +1942,17 @@ export function messagesFeature(ctx) {
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => a.created_at - b.created_at);
c.status = 'ready';
- if (ui.noteThread && ui.noteThread.rootId === rootId) render();
+ if (ui.noteThread && ui.noteThread.rootId === rootId) {
+ render();
+ // The inline reply box may have MOVED on this render (it slots under
+ // the focused note once that note exists) — a focus taken before the
+ // load finished died with the old position. Re-take it if the reply
+ // button asked for it.
+ if (ui.noteThread.refocus) {
+ ui.noteThread.refocus = false;
+ setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 30);
+ }
+ }
})().catch(() => {
c.status = 'ready';
if (ui.noteThread && ui.noteThread.rootId === rootId) render();
@@ -1962,21 +1978,36 @@ export function messagesFeature(ctx) {
// Publish a kind-1 reply to the focused note (NIP-10 markers), addressed to
// the conversation's own relays plus ours, and shown optimistically.
// A new top-level kind-1 note, published to the identity's own relays —
- // the profile page's compose button. The fresh note is prepended to the
- // profile's notes cache so it appears immediately.
+ // the profile page's compose button. OPTIMISTIC: a provisional copy sits
+ // at the top of the feed before any signing or relay round-trip (a remote
+ // signer alone can take seconds), swapped for the signed event on success
+ // and withdrawn on failure.
async function publishPost(text) {
const id = await identity();
if (!id) throw new Error(t('msgNoIdentity'));
- const partial = { kind: 1, content: text, created_at: Math.floor(Date.now() / 1000), tags: [] };
- const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
- const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
- const ok = await publishOn(relays, evt);
- if (!ok) throw new Error(t('msgSendFailed'));
- for (const pk of new Set([id.pubkey, ui.profilePk].filter(Boolean))) {
- const c = notesCache.get(pk);
- if (c && !c.notes.some((e) => e.id === evt.id)) c.notes = [evt, ...c.notes].slice(0, 20);
+ const temp = {
+ id: 'pending:' + Math.random().toString(36).slice(2),
+ pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000),
+ tags: [], pending: true,
+ };
+ const caches = [...new Set([id.pubkey, ui.profilePk].filter(Boolean))]
+ .map((pk) => notesCache.get(pk)).filter(Boolean);
+ for (const c of caches) c.notes = [temp, ...c.notes];
+ render();
+ try {
+ const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
+ const ok = await publishOn(relays, evt);
+ if (!ok) throw new Error(t('msgSendFailed'));
+ for (const c of caches) c.notes = c.notes.map((e) => (e.id === temp.id ? evt : e));
+ render();
+ return evt;
+ } catch (e) {
+ for (const c of caches) c.notes = c.notes.filter((e) => e.id !== temp.id);
+ render();
+ throw e;
}
- return evt;
}
async function publishReply(c, s, text) {
@@ -2011,43 +2042,59 @@ export function messagesFeature(ctx) {
const s = ui.noteThread;
const c = threadFor(s.seed);
const row = (ev) => noteRow(ev.pubkey, ev, displayName(ev.pubkey), { open: false, focus: ev.id === s.focusId && ev.id !== c.rootId });
+ // The reply box sits INLINE, right under the note it answers — it used
+ // to live at the bottom of the thread, where nobody scrolled to find it.
+ const replyBox = () => h('div', { class: 'row', style: 'gap:8px;align-items:center;padding:2px 0 10px' },
+ h('input', {
+ type: 'text', class: 'grow thread-reply-input', placeholder: t('threadReplyHint'),
+ value: s.draft || '',
+ onInput: (e) => { s.draft = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter') e.target.parentElement.querySelector('.thread-reply-send')?.click(); },
+ }),
+ h('button', {
+ class: 'btn-primary thread-reply-send', disabled: !!s.sending,
+ onClick: async () => {
+ const text = (s.draft || '').trim();
+ if (!text) return;
+ s.sending = true; render();
+ try {
+ await publishReply(c, s, text);
+ s.draft = '';
+ // the input may still be focused, and the morph won't touch a
+ // focused field's value — clear it by hand
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = '';
+ toast(t('threadReplied'));
+ } catch (e) { toast(e.message); }
+ s.sending = false; render();
+ },
+ }, s.sending ? h('span', { class: 'spinner sm' }) : t('threadReplySend')));
+ // rows with the reply box slotted under the focused note (the root when
+ // nothing narrower is focused; appended at the end if the focused note
+ // hasn't loaded yet, so the box never disappears entirely)
+ const kids = [];
+ let boxPlaced = false;
+ const place = (ev) => {
+ if (boxPlaced) return;
+ if (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)) {
+ kids.push(replyBox());
+ boxPlaced = true;
+ }
+ };
+ if (c.root) { kids.push(row(c.root)); place(c.root); }
+ else {
+ kids.push(h('div', { class: 'small faint', style: 'padding:10px 0' },
+ c.status === 'loading' ? '…' : t('threadRootMissing')));
+ }
+ for (const ev of c.replies) { kids.push(noteSep(), row(ev)); place(ev); }
+ if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })));
+ else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
+ if (!boxPlaced) kids.push(replyBox());
return h('div', { class: 'col', style: 'gap:16px' },
- ctx.brandHeader(false),
- h('div', { class: 'card col', style: 'gap:0;padding:2px 14px' },
- c.root ? row(c.root)
- : h('div', { class: 'small faint', style: 'padding:10px 0' },
- c.status === 'loading' ? '…' : t('threadRootMissing')),
- ...c.replies.flatMap((ev) => [noteSep(), row(ev)]),
- c.status === 'loading'
- ? h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))
- : !c.replies.length
- ? h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))
- : null),
- h('div', { class: 'card row', style: 'gap:8px;align-items:center' },
- h('input', {
- type: 'text', class: 'grow thread-reply-input', placeholder: t('threadReplyHint'),
- value: s.draft || '',
- onInput: (e) => { s.draft = e.target.value; },
- onKeydown: (e) => { if (e.key === 'Enter') e.target.closest('.card').querySelector('.thread-reply-send')?.click(); },
- }),
- h('button', {
- class: 'btn-primary thread-reply-send', disabled: !!s.sending,
- onClick: async () => {
- const text = (s.draft || '').trim();
- if (!text) return;
- s.sending = true; render();
- try {
- await publishReply(c, s, text);
- s.draft = '';
- // the input may still be focused, and the morph won't touch a
- // focused field's value — clear it by hand
- const inp = document.querySelector('.thread-reply-input');
- if (inp) inp.value = '';
- toast(t('threadReplied'));
- } catch (e) { toast(e.message); }
- s.sending = false; render();
- },
- }, s.sending ? h('span', { class: 'spinner sm' }) : t('threadReplySend'))),
+ // full header: search/chat/settings stay reachable mid-thread (only
+ // the public no-wallet surface drops the action row)
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
+ h('div', { class: 'card col', style: 'gap:0;padding:2px 14px' }, ...kids),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back')));
}
@@ -2244,7 +2291,9 @@ export function messagesFeature(ctx) {
: (full && urlish(full.banner)) || null;
const draftPic = draft && urlish(draft.picture);
return h('div', { class: 'col', style: 'gap:16px' },
- ctx.brandHeader(false),
+ // 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),
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' },
@@ -2351,17 +2400,20 @@ export function messagesFeature(ctx) {
onInput: (ev) => { ui.profCompose = ev.target.value; },
}),
h('div', { class: 'row gap6' },
- h('button', { class: 'btn-primary grow', disabled: !!ui.profPosting, onClick: async () => {
+ h('button', { class: 'btn-primary grow', onClick: async () => {
const text = (ui.profCompose || '').trim();
if (!text) return;
- ui.profPosting = true; render();
- try {
- await publishPost(text);
- ui.profCompose = null;
- toast(t('profPosted'));
- } catch (e) { toast(e.message || String(e)); }
- ui.profPosting = false; render();
- } }, ui.profPosting ? h('span', { class: 'spinner sm' }) : t('profPostBtn')),
+ // the post is on screen instantly (publishPost is
+ // optimistic) — close the composer now; a failure
+ // reopens it with the text intact
+ ui.profCompose = null;
+ try { await publishPost(text); toast(t('profPosted')); }
+ catch (e) {
+ ui.profCompose = text;
+ toast(e.message || String(e));
+ render();
+ }
+ } }, t('profPostBtn')),
h('button', { class: 'btn-ghost', onClick: () => { ui.profCompose = null; render(); } }, t('cancel')))),
logoutBtn())
: ui.pubProf
@@ -2431,7 +2483,7 @@ export function messagesFeature(ctx) {
function userSearchScreen() {
const s = ui.userSearch;
return h('div', { class: 'col', style: 'gap:16px' },
- ctx.brandHeader(false),
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('searchUsers')),
h('input', {