diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 106 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/public-chat.js | 10 | ||||
| -rw-r--r-- | src/style.css | 40 |
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 { |
