aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-26 07:50:39 -0700
committerAdam Soltys <[email protected]>2026-09-26 07:50:39 -0700
commita4f13b845b9dfb364bdac0932916d99c381db20e (patch)
tree16a06b0f3b19153056f329ef156d813e02eb2abf
parentacac2431b592600be2c37878851cf8e16b6ceba3 (diff)
downloadcoinosv3-a4f13b845b9dfb364bdac0932916d99c381db20e.tar.gz
coinosv3-a4f13b845b9dfb364bdac0932916d99c381db20e.zip
Thread: the reply box appears on Reply, full width, with its tools on a row beneath.
A thread opened to read came with an empty field under its first post. The box now appears under the note whose Reply was tapped (or while a draft is pending), folds away once the reply is sent or Cancel is tapped, and takes Amethyst's shape: the field across the full width, attach and preview on a row beneath it with Cancel and Reply at the right. Tests: thread-reply's helper follows the new shape and expects the box to fold after sending; compose-preview and feed-list tap Reply before expecting a box (the thread card carries .thread-card as the marker). Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
-rw-r--r--src/features/messages.js25
-rw-r--r--src/style.css3
-rw-r--r--tools/compose-preview-test.js5
-rw-r--r--tools/feed-list-test.js6
-rw-r--r--tools/thread-reply-test.js6
5 files changed, 31 insertions, 14 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a44a9b7..357de3f 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5202,6 +5202,7 @@ export function messagesFeature(ctx) {
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
+ s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
render();
@@ -6115,16 +6116,19 @@ export function messagesFeature(ctx) {
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: 'col', style: 'gap:8px;padding:2px 0 10px' },
+ // The box appears once Reply is tapped (or while a draft is pending) —
+ // a thread opened to read used to come with an empty field under its
+ // first post. Amethyst's shape: the field across the full width, the
+ // tools on a row beneath it.
+ const replyBox = () => h('div', { class: 'col thread-reply', style: 'gap:8px;padding:2px 0 10px' },
s.preview ? draftPreview(s.draft) : null,
- h('div', { class: 'row', style: 'gap:8px;align-items:flex-end' },
// 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', {
- class: 'grow thread-reply-input', placeholder: t('threadReplyHint'),
- rows: String(Math.min(5, (s.draft || '').split('\n').length)),
- style: 'font-family:var(--sans);resize:none;max-height:120px;overflow-y:auto;line-height:1.4',
+ 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',
value: s.draft || '',
// a render per keystroke only while the preview is open; the morph
// leaves a focused field alone, so this can't fight the typing
@@ -6142,6 +6146,7 @@ export function messagesFeature(ctx) {
}
},
}),
+ h('div', { class: 'row reply-tools', style: 'gap:8px;align-items:center' },
// 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.
@@ -6163,6 +6168,11 @@ export function messagesFeature(ctx) {
},
}),
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
+ h('span', { class: 'grow' }),
+ h('button', {
+ class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
+ onClick: () => { s.replying = false; s.preview = false; render(); },
+ }, t('cancel')),
h('button', {
class: 'btn-primary thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
onClick: async () => {
@@ -6177,6 +6187,7 @@ export function messagesFeature(ctx) {
// focused field's value — clear it by hand
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
+ s.replying = false; s.preview = false; // sent: the box folds away
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
@@ -6186,7 +6197,7 @@ export function messagesFeature(ctx) {
// 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;
+ let boxPlaced = !(s.replying || (s.draft || '').trim()); // nothing to place until Reply is tapped
const place = (ev) => {
if (boxPlaced) return;
if (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)) {
@@ -6249,7 +6260,7 @@ export function messagesFeature(ctx) {
// 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('div', { class: 'card col thread-card', style: 'gap:0;padding:2px 14px' }, ...kids),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back')),
...noteOverlays());
}
diff --git a/src/style.css b/src/style.css
index b136432..83e67a0 100644
--- a/src/style.css
+++ b/src/style.css
@@ -684,7 +684,8 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
display: flex; align-items: center; justify-content: center; padding: 0;
}
/* beside a growing reply box the icons keep their size, sat on the baseline */
-.thread-reply-input ~ .attach-btn { align-self: flex-end; height: 44px; }
+.thread-reply .reply-tools .attach-btn { height: 40px; }
+.thread-reply .thread-reply-send { padding: 8px 18px; }
/* A picture, full screen: the image centred on a dark ground, everything
else out of the way. Fixed to the viewport (svh, so a phone's address bar
can't push it), and above every other layer including the toast. */
diff --git a/tools/compose-preview-test.js b/tools/compose-preview-test.js
index 08a94e3..d33dcdc 100644
--- a/tools/compose-preview-test.js
+++ b/tools/compose-preview-test.js
@@ -117,8 +117,11 @@ try {
console.log(' — no post in the feed to reply to, skipping the reply half');
} else {
await sleep(2500);
+ check('a thread opened to read has no reply box yet', !(await page.$('.thread-reply-input')));
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Reply'); if (b) b.click(); });
+ await sleep(400);
const box = await page.$('.thread-reply-input');
- check('a thread has a reply box', !!box);
+ check('Reply opens the box', !!box);
if (box) {
await type('.thread-reply-input', 'my answer https://coinos.io/punks/3.webp');
await sleep(300);
diff --git a/tools/feed-list-test.js b/tools/feed-list-test.js
index ac6bbc1..bd88846 100644
--- a/tools/feed-list-test.js
+++ b/tools/feed-list-test.js
@@ -130,7 +130,7 @@ try {
let np = null; try { np = decode(p2.slice(1)); } catch {}
check('the address bar names the person (npub)', p2.startsWith('/npub1') && np && np.data === AUTHOR, p2.slice(0, 30));
await page.goBack(); await sleep(600);
- check('Back returns to the thread and its address', (await path()) === p1 && await page.evaluate(() => !!document.querySelector('[data-focus-note], .thread-reply-input')));
+ check('Back returns to the thread and its address', (await path()) === p1 && await page.evaluate(() => !!document.querySelector('.thread-card')));
await page.goForward(); await sleep(600);
check('the profile has an Add to a list button', await page.evaluate(() => !![...document.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'Add to a list')));
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'Add to a list').click()); await sleep(400);
@@ -161,7 +161,7 @@ try {
await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Following').click()); await sleep(800);
await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => /a post to open/.test(n.textContent)); r.click(); }); await sleep(800);
await page.reload({ waitUntil: 'domcontentloaded' });
- check('reloading a thread lands on the thread, at its address', await waitFor(() => !!document.querySelector('.thread-reply-input'), 20000) && (await path()) === p1, await path());
+ check('reloading a thread lands on the thread, at its address', await waitFor(() => !!document.querySelector('.thread-card'), 20000) && (await path()) === p1, await path());
check('...with its post', await waitText('a post to open', 5000));
await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => /a post to open/.test(n.textContent)); [...r.querySelectorAll('span')].find((s) => s.style.cursor === 'pointer' && s.style.fontWeight === '600').click(); }); await sleep(800);
await page.reload({ waitUntil: 'domcontentloaded' });
@@ -169,7 +169,7 @@ try {
const hist = () => page.evaluate(() => JSON.stringify({ len: history.length, i: history.state && history.state.i, prof: !!(history.state && history.state.nav && history.state.nav.profilePk), th: !!(history.state && history.state.nav && history.state.nav.noteThread), over: history.state && history.state.nav && history.state.nav.profOverThread, path: location.pathname.slice(0, 12) }));
const before = await hist();
await page.goBack(); await sleep(800);
- check('Back from it is the thread again', (await path()) === p1 && await page.evaluate(() => !!document.querySelector('.thread-reply-input')), before + ' -> ' + await hist());
+ check('Back from it is the thread again', (await path()) === p1 && await page.evaluate(() => !!document.querySelector('.thread-card')), before + ' -> ' + await hist());
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ lists: a feed of people is a nostr list; posts and people are links' : '\n❌ failed');
diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js
index 37c22b4..4362468 100644
--- a/tools/thread-reply-test.js
+++ b/tools/thread-reply-test.js
@@ -65,7 +65,7 @@ page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r
const check = (name, value) => { assert(value, name); console.log('✓ ' + name); };
const inline = () => page.evaluate(() => {
const input = document.querySelector('.thread-reply-input');
- const box = input?.parentElement.parentElement;
+ const box = input?.closest('.thread-reply'); // the field sits directly in the box now, tools on a row beneath
return !!input && box.previousElementSibling?.dataset.zapPost === test.ui.noteThread.focusId;
});
try {
@@ -81,7 +81,9 @@ try {
await page.waitForFunction(() => test.waiting.length === 2);
await page.evaluate(() => test.release());
await page.waitForFunction(() => document.body.innerText.includes('Root post'));
- check('a relay result omitting the tapped reply keeps it and its composer', await inline());
+ // the composer folds away once the reply is sent; the tapped reply itself stays
+ check('a relay result omitting the tapped reply keeps it (composer folded after sending)', await page.evaluate(() =>
+ !!document.querySelector('[data-zap-post="' + test.ui.noteThread.focusId + '"]') && !document.querySelector('.thread-reply-input')));
await page.evaluate(() => test.overlay());
check('fixture starts with a profile covering the existing thread', await page.evaluate(() => test.ui.profOverThread && !document.querySelector('.thread-reply-input')));
await page.click('#outside .note-act');