aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-04 09:26:00 -0700
committerAdam Soltys <[email protected]>2026-09-04 09:26:00 -0700
commitd92dfd42e7b01fd2a4806175136d10872474ca5c (patch)
tree113b754ed455dbe0b0f5a7e3a0b0aa9b6ec17018 /src
parent760efa2b404d559c6b2f369ddeb1b543b931b93d (diff)
downloadcoinosv3-d92dfd42e7b01fd2a4806175136d10872474ca5c.tar.gz
coinosv3-d92dfd42e7b01fd2a4806175136d10872474ca5c.zip
Telegram-style message action sheet + replies in community chat. Tapping any bubble opens a centered sheet: a quick row of the six reaction emojis (your current one highlighted; picking fires immediately) above Reply / Copy / Delete (delete on own messages only) β€” replacing the hover-only πŸ™‚ picker, which was invisible on the phones that need it most. Links, images and the hover Γ— keep their own clicks, and a desktop text-selection drag never pops the sheet over the copy. REPLIES are new: Reply arms a composer bar showing who/what you're answering (Γ— to cancel, cleared on channel/room switch), the sent kind-9 e-tags the quoted message, and rows carrying an e-tag render the quoted context above their text β€” author + one-line snippet, silent when the original is deleted or not held. The public /chat page renders the same quotes. Verified against the built stylesheet: 300px sheet, six even emoji buttons, quote ellipsizes, circular cancel button
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js106
-rw-r--r--src/i18n.js1
-rw-r--r--src/public-chat.js10
-rw-r--r--src/style.css40
4 files changed, 129 insertions, 28 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index b969d63..c381ebc 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -582,9 +582,13 @@ export function messagesFeature(ctx) {
// it only keeps unconfirmed messages out of the cache and marks what to
// withdraw if signing fails.
const { created_at, ms } = msTags(Date.now());
+ // an armed reply context e-tags the quoted message (rendered as a quote
+ // by replyQuote on every device that has the original)
+ const replyTo = ui.msgReplyTo && room.byChannel.get(chId)?.has(ui.msgReplyTo) ? ui.msgReplyTo : null;
+ ui.msgReplyTo = null;
const rumor = rumorWithId({
kind: 9, pubkey: id.pubkey, content: text,
- tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at,
+ tags: [['channel', chId], ['epoch', String(EPOCH)], ...(replyTo ? [['e', replyTo]] : []), ms], created_at,
});
const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
const entry = { rumor, author: id.pubkey, pending: true };
@@ -618,7 +622,7 @@ export function messagesFeature(ctx) {
async function sendReaction(room, chId, m, emoji) {
const id = await identity();
if (!id) { noIdToast(); return; }
- ui.msgReactFor = null;
+ ui.msgSheet = null;
const { created_at, ms } = msTags(Date.now());
const rumor = rumorWithId({
kind: 7, pubkey: id.pubkey, content: emoji,
@@ -641,6 +645,72 @@ export function messagesFeature(ctx) {
}
}
+ // The displayed text of a message after its edit fold, trimmed to one line.
+ function msgSnippet(room, m, max = 90) {
+ const edit = room.edits.get(m.rumor.id);
+ const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content;
+ const one = String(text || '').replace(/\s+/g, ' ').trim();
+ return one.length > max ? one.slice(0, max - 1) + '…' : one;
+ }
+
+ // A kind-9 that e-tags another message is a REPLY β€” render the quoted
+ // context above its text, Telegram-style. Quotes of deleted (or not-yet-
+ // loaded) messages stay silent rather than resurrect them.
+ function replyQuote(room, chId, m) {
+ const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1];
+ if (!replyId || room.deletes.has(replyId)) return null;
+ const src = room.byChannel.get(chId)?.get(replyId);
+ if (!src) return null;
+ return h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(src.author)),
+ h('span', { class: 'chat-quote-text' }, msgSnippet(room, src)));
+ }
+
+ // Telegram-style message action sheet: quick reactions on top, then the
+ // actions this message supports. Opened by a tap on the bubble.
+ function messageSheet(room, chId) {
+ const m = room.byChannel.get(chId)?.get(ui.msgSheet);
+ if (!m) { ui.msgSheet = null; return null; }
+ const my = myPubkeys();
+ const mine = my.includes(m.author);
+ const reacts = room.reactions.get(m.rumor.id);
+ const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
+ const close = () => { ui.msgSheet = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
+ h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', {
+ class: 'confirm-pop-backdrop',
+ onClick: (e) => { if (e.target === e.currentTarget) close(); },
+ },
+ h('div', { class: 'card col msg-sheet' },
+ h('div', { class: 'msg-sheet-emojis' },
+ REACT_EMOJIS.map((e2) => h('button', {
+ class: e2 === myReact ? 'on' : '',
+ onClick: () => { close(); sendReaction(room, chId, m, e2); },
+ }, e2))),
+ item('↩', t('msgReply'), () => {
+ ui.msgReplyTo = m.rumor.id;
+ close();
+ setTimeout(() => document.getElementById('msg-draft')?.focus(), 50);
+ }),
+ item('⧉', t('copy'), async () => {
+ try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {}
+ close();
+ }),
+ mine ? item('βœ•', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null));
+ }
+
+ // The composer's "replying to" context bar, with its way out.
+ function replyBar(room, chId) {
+ const m = ui.msgReplyTo && room.byChannel.get(chId)?.get(ui.msgReplyTo);
+ if (!m) { ui.msgReplyTo = null; return null; }
+ return h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'gap:1px;min-width:0' },
+ h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(m.author)),
+ h('span', { class: 'small muted chat-quote-text' }, msgSnippet(room, m))),
+ h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, 'Γ—'));
+ }
+
async function deleteMessage(room, chId, m) {
const id = await identity();
if (!id || id.pubkey !== m.author) return;
@@ -2642,20 +2712,26 @@ export function messagesFeature(ctx) {
displayName(m.author),
m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
h('span', { class: 'chat-time' }, timeLabel(tms))),
- h('div', { class: 'chat-bubble' },
+ h('div', {
+ class: 'chat-bubble clickable',
+ // Telegram-style: a tap on the message opens its action sheet
+ // (quick reactions, reply, copy, delete). Links, images and the
+ // hover Γ— keep their own clicks; a desktop text-selection drag
+ // ends in a click too, and must not pop the sheet over the copy.
+ onClick: (e) => {
+ if (e.target.closest && e.target.closest('a, button, img')) return;
+ const sel = window.getSelection && window.getSelection();
+ if (sel && String(sel).length) return;
+ ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
+ render();
+ },
+ },
+ replyQuote(room, chId, m),
...noteBody(text),
edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null,
- h('button', {
- class: 'chat-react-btn', title: t('msgReact'),
- onClick: () => { ui.msgReactFor = ui.msgReactFor === m.rumor.id ? null : m.rumor.id; render(); },
- }, 'πŸ™‚'),
mine
? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, 'Γ—')
: null),
- ui.msgReactFor === m.rumor.id
- ? h('div', { class: 'chat-react-picker' },
- REACT_EMOJIS.map((e2) => h('button', { onClick: () => sendReaction(room, chId, m, e2) }, e2)))
- : null,
counts.size
? h('div', { class: 'chat-reacts' },
[...counts.entries()].map(([emoji, n]) =>
@@ -2685,7 +2761,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'card col chat-card' },
h('div', { class: 'row between chat-head' },
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
- backBtn(() => { ui.msgView = 'home'; render(); }),
+ backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }),
h('div', {
class: 'col clickable', style: 'gap:2px;min-width:0',
onClick: () => { ui.msgMembers = !ui.msgMembers; render(); },
@@ -2700,7 +2776,7 @@ export function messagesFeature(ctx) {
chans.length > 1
? h('select', {
class: 'chan-pick',
- onChange: (e) => { ui.msgChannel = e.target.value; ui.msgStick = true; subChannel(room, e.target.value); render(); },
+ onChange: (e) => { ui.msgChannel = e.target.value; ui.msgStick = true; ui.msgReplyTo = null; ui.msgSheet = null; subChannel(room, e.target.value); render(); },
}, chans.map((c) => h('option', { value: c.id, selected: c.id === ch?.id }, '#' + c.name)))
: null,
h('button', {
@@ -2725,11 +2801,13 @@ export function messagesFeature(ctx) {
},
}, ...(ch ? messageRows(room, ch.id) : [])),
ch ? typingLine(room, ch.id) : null,
+ ch ? replyBar(room, ch.id) : null,
composer(
chans.length > 1 ? t('msgPlaceholder', { channel: ch ? ch.name : '' }) : t('msgPlaceholderPlain'),
() => ch && sendMessage(room, ch.id),
() => ch && ping(room, ch.id, TYPING),
- ch ? 'ch:' + ch.id : 'ch:'));
+ ch ? 'ch:' + ch.id : 'ch:'),
+ ch && ui.msgSheet ? messageSheet(room, ch.id) : null);
}
// "Alice is typing…" β€” named up to two, counted beyond that.
diff --git a/src/i18n.js b/src/i18n.js
index 0910d1d..fd4d318 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -180,6 +180,7 @@ const STR = {
msgAdmin: 'admin',
msgEdited: '(edited)',
msgReact: 'React',
+ msgReply: 'Reply',
publicChatTitle: '{name} community',
publicChatDesc: 'Live chat β€” read along, sign in to join the conversation',
publicChatSignIn: 'Sign in to chat',
diff --git a/src/public-chat.js b/src/public-chat.js
index 5732f4a..d3dd042 100644
--- a/src/public-chat.js
+++ b/src/public-chat.js
@@ -237,6 +237,14 @@ export function mountPublicChat() {
const edit = edits.get(m.rumor.id);
const content = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content;
const reacts = reactions.get(m.rumor.id);
+ // a reply e-tags its quoted message β€” render the context when we hold it
+ const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1];
+ const replySrc = replyId && !(deletes.get(replyId) || new Set()).size
+ ? (byChannel.get(activeChannel) || new Map()).get(replyId) : null;
+ const quote = replySrc ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, nameOf(replySrc.author)),
+ h('span', { class: 'chat-quote-text' },
+ String(replySrc.rumor.content || '').replace(/\s+/g, ' ').slice(0, 90))) : null;
log.append(h('div', { class: 'chat-row' + (grouped ? ' grouped' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author),
h('div', { class: 'chat-body' },
@@ -244,7 +252,7 @@ export function mountPublicChat() {
h('span', { class: 'chat-name' + (m.author === COMMUNITY.owner ? ' owner' : '') }, nameOf(m.author)),
m.author === COMMUNITY.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null,
h('span', { class: 'chat-time' }, timeAgo(ms / 1000))),
- h('div', { class: 'chat-bubble' }, ...linkedBody(content),
+ h('div', { class: 'chat-bubble' }, quote, ...linkedBody(content),
edit && edit.author === m.author ? h('span', { class: 'chat-edited' }, ' ' + t('msgEdited')) : null),
reacts && reacts.size ? h('div', { class: 'chat-reacts' },
...[...[...reacts.values()].reduce((m2, e) => m2.set(e, (m2.get(e) || 0) + 1), new Map()).entries()]
diff --git a/src/style.css b/src/style.css
index 483d999..cf0746e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -423,20 +423,34 @@ hr.divider { margin: 16px 0; }
padding: 0;
}
.chat-row:hover .chat-del { display: flex; }
-/* the react opener rides the bubble edge like the delete Γ—; on a mine-row
- both show, so it steps left to clear the Γ— */
-.chat-react-btn {
- position: absolute; top: -7px; right: -7px; width: 18px; height: 18px;
- display: none; align-items: center; justify-content: center;
- border: 1px solid var(--line-strong); border-radius: 50%;
- background: var(--paper); font-size: 10px; line-height: 1; cursor: pointer; padding: 0;
+/* Telegram-style message action sheet: quick reactions + actions, opened by
+ a tap on any bubble (rides the confirm-pop backdrop). */
+.msg-sheet { width: 100%; max-width: 300px; gap: 2px; }
+.msg-sheet-emojis { display: flex; gap: 4px; margin-bottom: 8px; }
+.msg-sheet-emojis button {
+ flex: 1; font-size: 19px; line-height: 1; padding: 8px 0;
+ border: 1px solid var(--line); border-radius: 10px; background: var(--paper);
+}
+.msg-sheet-emojis button.on { border-color: var(--accent); }
+.msg-sheet-item {
+ display: flex; gap: 12px; align-items: center; text-align: left;
+ border: none; background: none; padding: 11px 8px; font-weight: 550; border-radius: 10px;
+}
+.msg-sheet-item:hover { background: var(--surface2); }
+.msg-sheet-ico { width: 20px; text-align: center; color: var(--muted); flex-shrink: 0; }
+/* the quoted message a reply carries, and the composer's replying-to bar */
+.chat-quote {
+ display: flex; flex-direction: column; gap: 0; margin-bottom: 4px;
+ border-left: 3px solid var(--accent); background: var(--surface2);
+ border-radius: 6px; padding: 3px 8px; font-size: 12px; max-width: 100%;
+}
+.chat-quote-name { font-weight: 650; }
+.chat-quote-text {
+ color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
-.chat-row:hover .chat-react-btn { display: flex; }
-.chat-row.mine .chat-react-btn { right: 16px; } /* 18px Γ— at -7 ends at 11; 5px of air */
-.chat-react-picker { display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap; }
-.chat-react-picker button {
- font-size: 15px; line-height: 1; padding: 4px 6px;
- border: 1px solid var(--line); border-radius: 9px; background: var(--paper); cursor: pointer;
+.reply-bar {
+ display: flex; align-items: center; gap: 8px; padding: 7px 12px;
+ border-top: 1px solid var(--line); background: var(--surface2); flex: 0 0 auto;
}
.chat-reacts { display: flex; gap: 5px; margin-top: 3px; flex-wrap: wrap; }
.chat-react {