openvibers/openvibe.live Commits
Chat: a message's actions in one cluster at its corner, editing in the chat box, "edited" after the words (#848)
Changes
@@@ -91,7 +91,7 @@".chat-msg",".chat-msg .chat-user",".chat-msg-crossfeed",-- ".chat-msg-hoverable .chat-reply-btn",++ ".chat-msg-actions",".chat-msg.system",".chat-nickels-float .rewards-coin-balance",".chat-relay-badge",@@@ -111,15 +111,7 @@html[data-ov-motion=off] .chat-translation,html[data-ov-motion=off] .ch-caption,html[data-ov-motion=off] .star-card-border{opacity: 1;transform: none}--/* ── Chat: "translate to my language" hover button (sits left of the reply button) ── */--.chat-msg-hoverable .chat-translate-btn {-- display: none; position: absolute; right: 30px; top: 2px;-- padding: 2px 6px; font-size: 0.65rem; line-height: 1; z-index: 5; cursor: pointer;-- background: var(--bg-card, #1e1e2e); border: 1px solid var(--border, #333); border-radius: var(--radius-sm, 4px);-- color: var(--text-muted, #888); transition: color var(--ov-dur-2, 0.15s), border-color var(--ov-dur-2, 0.15s);--}--.chat-msg-hoverable:hover .chat-translate-btn { display: inline-flex; }--.chat-msg-hoverable .chat-translate-btn:hover { color: var(--accent-light); border-color: var(--accent); }++/* "Translate to my language" is one of a message's actions: style.css .chat-msg-actions. */.chat-translation[hidden] { display: none; }/* Lite mode (phones / weak GPUs): no blur, no filters, no shadow animations */@@@ -2631,27 +2631,43 @@ html[data-ov-motion=off] .ov-rail-track{scroll-behavior: auto}position: relative;}--/* ── Reply button (hover action) ─────────────────────────── */--.chat-msg-hoverable .chat-reply-btn {-- display: none;-- position: absolute;-- right: 4px; top: 2px;-- padding: 2px 6px;-- font-size: 0.65rem;++/* ── A message's actions: one cluster at its corner (openvibe.design rule 138) ──────────────++ Reply, edit and translate float just above the message's right end, on hover or keyboard focus (a computer) or a++ tap on the message (a phone). They never sit in the words, and nothing about a message is shown until asked for. */++.chat-msg-actions {++ position: absolute; bottom: calc(100% - 6px); right: 6px; z-index: 6;++ display: none; align-items: center; gap: 2px; padding: 2px;background: var(--bg-card, #1e1e2e);border: 1px solid var(--border, #333);-- border-radius: var(--radius-sm, 4px);-- color: var(--text-muted, #888);-- cursor: pointer;-- z-index: 5;-- line-height: 1;-- transition: color var(--ov-dur-2, 0.15s), border-color var(--ov-dur-2, 0.15s);--}--.chat-msg-hoverable:hover .chat-reply-btn { display: inline-flex; }--.chat-msg-hoverable .chat-reply-btn:hover {-- color: var(--accent, var(--accent));-- border-color: var(--accent, var(--accent));--}++ border-radius: 10px;++ box-shadow: 0 6px 18px rgba(0, 0, 0, .35);++}++/* Above the message, over the line before it, so its own words stay readable; the first message has none above. */++.chat-msg:first-child > .chat-msg-actions { bottom: auto; top: calc(100% - 6px); }++.chat-msg-hoverable:hover > .chat-msg-actions,++.chat-msg-hoverable:focus-within > .chat-msg-actions,++.chat-msg-hoverable.is-acting > .chat-msg-actions { display: inline-flex; }++.chat-msg-actions > button {++ display: grid; place-items: center; width: 28px; height: 26px; padding: 0;++ border: 0; border-radius: 7px; background: transparent; color: var(--text-muted, #888);++ font-size: 0.78rem; line-height: 1; cursor: pointer;++ transition: color var(--ov-dur-2, 0.15s), background-color var(--ov-dur-2, 0.15s);++}++.chat-msg-actions > button:hover,++.chat-msg-actions > button:focus-visible { color: var(--text-primary, #fff); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }++.chat-msg-actions > button:focus-visible { outline: 2px solid var(--accent-light, var(--accent)); outline-offset: -2px; }++/* A phone has no hover: the tap decides, and each button is big enough for a thumb. */++@media (hover: none) {++ .chat-msg-hoverable:hover:not(.is-acting):not(:focus-within) > .chat-msg-actions { display: none; }++ .chat-msg-actions { right: 4px; border-radius: 12px; }++ .chat-msg-actions > button { width: 44px; height: 40px; font-size: 0.95rem; border-radius: 9px; }++}++@media (hover: hover) { .chat-msg-hoverable:hover { background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); border-radius: 6px; } }++.chat-msg-hoverable.is-acting { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); border-radius: 6px; }++.chat-msg.is-editing { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); border-radius: 6px; }++/* "edited": one quiet word after the words, on the same line (the time is its title). */++.chat-edited { margin-left: 5px; font-size: 0.72em; color: var(--text-muted, #888); opacity: .85; white-space: nowrap; }++.chat-edit-bar .chat-reply-bar-content i { color: var(--accent-light, var(--accent)); }/* ── Reply header (on replied messages) ──────────────────── */.chat-reply-header {@@@ -56,6 +56,7 @@ let chatSlowModeCooldownEnd = 0;// ── Reply-to state ──────────────────────────────────────────let _chatReplyTo = null; // { id, username, user_id, message }++let _chatEditing = null; // { id, muted, draft }: your own message, being changed in the chat box// Relay username colors, keyed by source_platform. Used for BOTH live and history// so external (RS/Twitch/Kick/YouTube) names keep a consistent color — history rows@@@ -1906,6 +1907,31 @@ function _applyChatVipBadge(evt) {if (!evt || evt.id == null || !evt.vip_badge) return;document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => _placeVipBadge(el, evt.vip_badge));}++/** "edited": one quiet word right after the message's words, on its line; the time it changed on hover. */++function _chatEditedMark(el, at) {++ const text = el.querySelector('.chat-text');++ if (!text) return;++ let mark = el.querySelector('.chat-edited');++ if (!mark) { mark = document.createElement('small'); mark.className = 'chat-edited'; }++ if (mark.previousSibling !== text) text.after(mark);++ const when = at ? new Date(at) : null;++ mark.textContent = 'edited';++ mark.title = when && !isNaN(when) ? `Edited at ${when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'Edited';++}++++/** One cluster of a message's actions (reply, edit, translate), at its corner, shown on hover, focus or a tap. */++function _chatActions(el) {++ let acts = el.querySelector(':scope > .chat-msg-actions');++ if (!acts) { acts = document.createElement('span'); acts.className = 'chat-msg-actions'; el.appendChild(acts); }++ return acts;++}++function _chatActionBtn(cls, icon, label) {++ const b = document.createElement('button');++ b.type = 'button'; b.className = cls; b.title = label; b.setAttribute('aria-label', label);++ b.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`;++ return b;++}++function _applyChatEdited(evt) {const m = evt && evt.message;if (!m || !Number.isSafeInteger(Number(evt.id))) return;@@@ -1917,9 +1943,7 @@ function _applyChatEdited(evt) {el.classList.toggle('chat-msg-tts-off', muted);const shown = muted ? raw.replace(/^\s*\.\s?/, '') : raw;text.innerHTML = typeof parseEmotes === 'function' ? parseEmotes(shown) : esc(shown);-- let mark = el.querySelector('.chat-edited');-- if (!mark) { mark = document.createElement('small'); mark.className = 'chat-edited'; el.appendChild(mark); }-- mark.textContent = ` edited · ${new Date(m.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;++ _chatEditedMark(el, m.edited_at);el._ovRaw = raw;if (el._ovMsg) el._ovMsg.message = raw.slice(0, 100);});@@@ -2552,7 +2576,7 @@ function buildChatMessageEl(msg, opts = {}) {// tts-off messages drop the ":" so it reads "name 🔇 message" after the mute marker.const separator = (ttsOff || msg.message_type === 'soundboard' || msg.message_type === 'channel-sound') ? ' ' : ': ';el.innerHTML = `${replyHtml}${timestamp}${streamBadge}${voiceBadge}${gameBadge}${widget ? '' : `<span class="chat-avatar-wrap">${avatarHtml}</span>`}${badge}${_relayBadge}${_aiBadge}${hatHtml}${particleWrapOpen}<span class="chat-user${nameFXClass}" style="color:${esc(nameColor)}" data-username="${displayName}" data-core-username="${coreUsername}" data-user-id="${userId}" data-anon="${isAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>${particleWrapClose}${separator}<span class="chat-text">${text}</span>`;-- if (msg.edited_at) { const mark = document.createElement('small'); mark.className = 'chat-edited'; mark.textContent = ` edited · ${new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; el.appendChild(mark); }++ if (msg.edited_at) _chatEditedMark(el, msg.edited_at);// Auto-translation under the message (live 'chat_translation' event, or persisted// metadata.translation on history). See server/i18n/translate.js for the direction rules.@@@ -2565,13 +2589,10 @@ function buildChatMessageEl(msg, opts = {}) {if (msg.id && !widget) {el._ovMsg = { id: msg.id, username: msg.username || msg.displayName || 'anon', user_id: msg.user_id, message: (msg.message || msg.text || '').slice(0, 100) };el._ovRaw = rawText;-- const replyBtn = document.createElement('button');-- replyBtn.className = 'chat-reply-btn';-- replyBtn.title = 'Reply';-- replyBtn.innerHTML = '<i class="fa-solid fa-reply"></i>';-- el.appendChild(replyBtn);++ const acts = _chatActions(el);++ acts.appendChild(_chatActionBtn('chat-reply-btn', 'fa-reply', 'Reply'));if ((!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {-- const editBtn = document.createElement('button'); editBtn.className = 'ovk-btn ovk-btn--quiet chat-edit-btn'; editBtn.type = 'button'; editBtn.textContent = 'Edit'; el.appendChild(editBtn);++ acts.appendChild(_chatActionBtn('chat-edit-btn', 'fa-pen', 'Edit your message'));}// "Translate to my language", only on lines confidently in another language than the viewer's (decided// locally, public/js/lang-detect.js) and not already translated into it by the channel's auto-translation.@@@ -2579,11 +2600,8 @@ function buildChatMessageEl(msg, opts = {}) {const _foreign = window.OVLang ? OVLang.foreignFor(displayRaw || '', _me) : null;if (_foreign && !(_tr && _tr.to === _me)) {el.dataset.lang = _foreign;-- const trBtn = document.createElement('button');-- trBtn.className = 'chat-translate-btn';-- trBtn.title = `Translate from ${_LANG_LABEL[_foreign] || _foreign} to ${_LANG_LABEL[_me] || 'my language'}`;-- trBtn.innerHTML = '<i class="fa-solid fa-language"></i>';-- el.appendChild(trBtn);++ const trBtn = _chatActionBtn('chat-translate-btn', 'fa-language', `Translate from ${_LANG_LABEL[_foreign] || _foreign} to ${_LANG_LABEL[_me] || 'my language'}`);++ acts.prepend(trBtn);_autoTr.add(el, displayRaw);}el.classList.add('chat-msg-hoverable');@@@ -3396,6 +3414,7 @@ function sendChat(overrideInput = null) {const input = overrideInput || getChatEl().input;if (!input) return;let text = input.value.trim();++ if (_chatEditing) { _saveChatEdit(input, text); return; }if (!text) return;// TTS-off toggle: when the TTS button for this chat surface is OFF, prepend a@@@ -3471,10 +3490,11 @@ function sendChat(overrideInput = null) {* Set the reply target — shows the reply bar above the chat input.*/function setChatReply(replyData) {++ if (_chatEditing) clearChatEdit();_chatReplyTo = replyData;// Show/update reply bar on all chat input areasdocument.querySelectorAll('.chat-input-area').forEach(area => {-- let bar = area.querySelector('.chat-reply-bar');++ let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');if (!bar) {bar = document.createElement('div');bar.className = 'chat-reply-bar';@@@ -3499,11 +3519,77 @@ function setChatReply(replyData) {*/function clearChatReply() {_chatReplyTo = null;-- document.querySelectorAll('.chat-reply-bar').forEach(bar => {++ document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {bar.style.display = 'none';});}++/* ── Editing your own message, in the chat box ───────────────────── */++++/** The box takes the message's words and a bar says so: Enter saves, Escape (or ×) puts back what was in the box. */++function setChatEdit(row) {++ if (!row || !row._ovMsg) return;++ if (_chatReplyTo) clearChatReply();++ const input = getChatEl().input;++ if (!input) return;++ const raw = String(row._ovRaw || '');++ const muted = raw.trimStart().startsWith('.');++ if (!_chatEditing) _chatEditing = { draft: input.value };++ Object.assign(_chatEditing, { id: row._ovMsg.id, muted });++ document.querySelectorAll('.chat-msg.is-editing').forEach((r) => r.classList.remove('is-editing'));++ row.classList.add('is-editing');++ document.querySelectorAll('.chat-input-area').forEach((area) => {++ let bar = area.querySelector('.chat-edit-bar');++ if (!bar) {++ bar = document.createElement('div');++ bar.className = 'chat-reply-bar chat-edit-bar';++ const inputRow = area.querySelector('.chat-input-row');++ if (inputRow) area.insertBefore(bar, inputRow); else area.prepend(bar);++ const content = document.createElement('div'); content.className = 'chat-reply-bar-content';++ content.innerHTML = '<i class="fa-solid fa-pen" aria-hidden="true"></i> <strong>Editing your message</strong><span class="chat-reply-bar-snippet">Enter saves · Esc cancels</span>';++ const close = document.createElement('button');++ close.type = 'button'; close.className = 'chat-reply-bar-close'; close.title = 'Stop editing'; close.setAttribute('aria-label', 'Stop editing');++ close.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';++ close.addEventListener('click', () => clearChatEdit());++ bar.append(content, close);++ }++ bar.style.display = '';++ area.classList.add('is-editing');++ });++ input.value = muted ? raw.replace(/^\s*\.\s?/, '') : raw;++ _autoResizeTextarea(input);++ input.focus();++ requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });++}++++function clearChatEdit({ keepText = false } = {}) {++ const was = _chatEditing;++ _chatEditing = null;++ document.querySelectorAll('.chat-edit-bar').forEach((bar) => { bar.style.display = 'none'; });++ document.querySelectorAll('.chat-input-area.is-editing').forEach((a) => a.classList.remove('is-editing'));++ document.querySelectorAll('.chat-msg.is-editing').forEach((r) => r.classList.remove('is-editing'));++ const input = getChatEl().input;++ if (input && was && !keepText) { input.value = was.draft || ''; _autoResizeTextarea(input); }++}++++function _saveChatEdit(input, text) {++ const ed = _chatEditing;++ if (!ed) return;++ if (!text) { addSystemMessage('A message can\'t be empty. To take it back, delete it from its menu.'); return; }++ if (messageHitsSlurPolicy(text)) { showSlurNudgeModal(); return; }++ const body = ed.muted && !text.startsWith('.') ? '.' + text : text;++ input.disabled = true;++ fetch(`/api/chat/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })++ .then(async (r) => {++ const data = await r.json().catch(() => ({}));++ if (!r.ok) throw Error(data.error || 'Could not change that message');++ _applyChatEdited({ id: ed.id, message: data.message });++ clearChatEdit();++ })++ .catch((err) => addSystemMessage(err.message))++ .finally(() => { input.disabled = false; input.focus(); });++}++/*** Scroll to and highlight the message being replied to.*/@@@ -3552,7 +3638,12 @@ function _chatTextareaKeydown(e) {return;}-- // Escape: clear reply++ // Escape: stop editing, or clear the reply++ if (e.key === 'Escape' && _chatEditing) {++ clearChatEdit();++ e.preventDefault();++ return;++ }if (e.key === 'Escape' && _chatReplyTo) {clearChatReply();e.preventDefault();@@@ -5230,11 +5321,12 @@ function _buildCrossfeedEl(msg) {const cfUserId = msg.user_id || '';const cfIsAnon = !cfUserId && !isRelayMsg;const cfCore = msg.core_username || (isRelayMsg ? '' : (msg.username || ''));-- el.innerHTML = `${tsHtml}${sourceBadge}${badge}${_relayBadge}${_aiBadge}<span class="chat-name" style="color:${nameColor}" data-username="${displayName}" data-core-username="${esc(cfCore)}" data-user-id="${esc(String(cfUserId))}" data-anon="${cfIsAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>: <span class="chat-text">${text}</span>${msg.edited_at ? `<small class="chat-edited"> edited · ${esc(new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}</small>` : ''}`;++ el.innerHTML = `${tsHtml}${sourceBadge}${badge}${_relayBadge}${_aiBadge}<span class="chat-name" style="color:${nameColor}" data-username="${displayName}" data-core-username="${esc(cfCore)}" data-user-id="${esc(String(cfUserId))}" data-anon="${cfIsAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>: <span class="chat-text">${text}</span>${msg.edited_at ? `<small class="chat-edited" title="Edited at ${esc(new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}">edited</small>` : ''}`;if (msg.id && (!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {el._ovMsg = { id: msg.id, username: msg.username, user_id: msg.user_id, message: rawText.slice(0, 100) };el._ovRaw = rawText;-- const editBtn = document.createElement('button'); editBtn.className = 'ovk-btn ovk-btn--quiet chat-edit-btn'; editBtn.type = 'button'; editBtn.textContent = 'Edit'; el.appendChild(editBtn);++ _chatActions(el).appendChild(_chatActionBtn('chat-edit-btn', 'fa-pen', 'Edit your message'));++ el.classList.add('chat-msg-hoverable');}return el;}@@@ -6703,15 +6795,14 @@ function closeChatUsersPanel(btn) {const edit = t.closest('.chat-edit-btn');if (edit) {const row = edit.closest('.chat-msg');-- if (!row || !row._ovMsg) return;-- const body = window.prompt('Edit message', row._ovRaw || '');-- if (body == null) return;-- edit.disabled = true;-- fetch(`/api/chat/messages/${encodeURIComponent(row._ovMsg.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })-- .then(async (r) => { const data = await r.json(); if (!r.ok) throw Error(data.error || 'Could not edit message'); _applyChatEdited({ id: row._ovMsg.id, message: data.message }); })-- .catch((err) => addSystemMessage(err.message)).finally(() => { edit.disabled = false; });++ if (row && row._ovMsg) { e.stopPropagation(); setChatEdit(row); }return;}++ const row = t.closest('.chat-msg-hoverable');++ if (!t.closest('.chat-msg-actions')) {++ document.querySelectorAll('.chat-msg.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); });++ if (row && window.matchMedia && matchMedia('(hover: none)').matches && !t.closest('a, button, .chat-name, .chat-user, .chat-avatar-wrap, input, textarea, [contenteditable]')) row.classList.toggle('is-acting');++ }const tr = t.closest('.chat-translate-btn');if (tr) {const row = tr.closest('.chat-msg');nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++/**++ * A chat message's actions (openvibe.design rule 138). The owner, 11-12, with phone screenshots of a stream's chat:++ * "The edit button needs improvement … so it never happens again", "it also looks stupid as shit after you edit it".++ * Until then every one of your own messages carried a full-size "Edit" pill inline in its words, editing opened the++ * browser's prompt box, and "edited · 06:49 PM" fell onto a line of its own under the message.++ *++ * Now: reply, edit and translate are one cluster at the message's corner (hover or focus on a computer, a tap on a++ * phone); editing happens in the chat box with an "Editing your message" bar; "edited" is one quiet word after the words.++ * node test/chat-msg-actions.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.join(__dirname, '..');++const chat = fs.readFileSync(path.join(root, 'public/js/chat.js'), 'utf8');++const css = fs.readFileSync(path.join(root, 'public/css/style.css'), 'utf8');++const i18n = fs.readFileSync(path.join(root, 'public/css/i18n-star.css'), 'utf8');++++// No message action is a kit button with a word in it, and none is appended into the message's line.++assert.ok(!/chat-edit-btn'; editBtn\.type = 'button'; editBtn\.textContent = 'Edit'/.test(chat), 'no "Edit" pill');++assert.ok(!/ovk-btn[^'"]*chat-(edit|reply|translate)-btn/.test(chat), 'message actions are not kit buttons');++for (const cls of ['chat-reply-btn', 'chat-edit-btn', 'chat-translate-btn']) {++ assert.ok(new RegExp(`_chatActionBtn\\('${cls}'`).test(chat), `${cls} is made by _chatActionBtn (an icon with a label)`);++}++assert.ok(!/el\.appendChild\((replyBtn|editBtn|trBtn)\)/.test(chat), 'no action button is appended straight into the message');++assert.ok(/function _chatActions\(el\)[\s\S]{0,300}chat-msg-actions/.test(chat), 'the actions go in one .chat-msg-actions cluster');++++// Editing is in the chat box, never the browser's prompt or alert.++assert.ok(!/window\.prompt\('Edit message'/.test(chat), 'no prompt box for an edit');++assert.ok(/function setChatEdit\(row\)/.test(chat) && /function clearChatEdit\(/.test(chat) && /function _saveChatEdit\(/.test(chat));++assert.ok(/if \(_chatEditing\) \{ _saveChatEdit\(input, text\); return; \}/.test(chat), 'Enter in the box saves the edit');++assert.ok(/e\.key === 'Escape' && _chatEditing/.test(chat), 'Escape stops editing');++assert.ok(/Editing your message/.test(chat), 'the bar says what is happening');++assert.ok(/'\.chat-reply-bar:not\(\.chat-edit-bar\)'/.test(chat), 'replying and editing never reuse each other\'s bar');++assert.ok(/ed\.muted && !text\.startsWith\('\.'\) \? '\.' \+ text : text/.test(chat), 'a message sent without TTS stays without TTS when edited');++++// "edited" is one word after the words, with the time as its title.++assert.ok(/function _chatEditedMark\(el, at\)[\s\S]{0,400}text\.after\(mark\)[\s\S]{0,300}mark\.textContent = 'edited'/.test(chat), '"edited" sits right after the words');++assert.ok(!/edited · \$\{/.test(chat), 'no "edited · time" line');++++// The cluster: absolute at the corner, shown only on hover, focus or a tap; thumb-sized where there is no hover.++const block = (sel) => { const i = css.indexOf(`${sel} {`); assert.ok(i >= 0, `${sel} is styled`); return css.slice(i, css.indexOf('}', i)); };++assert.match(block('.chat-msg-actions'), /position: absolute/);++assert.match(block('.chat-msg-actions'), /display: none/);++assert.match(css, /\.chat-msg-hoverable:hover > \.chat-msg-actions,\s*\.chat-msg-hoverable:focus-within > \.chat-msg-actions,\s*\.chat-msg-hoverable\.is-acting > \.chat-msg-actions \{ display: inline-flex; \}/);++assert.match(css, /@media \(hover: none\) \{[\s\S]{0,400}width: 44px; height: 40px/, 'a phone gets thumb-sized actions');++assert.match(block('.chat-edited'), /white-space: nowrap/);++assert.ok(!/\.chat-msg-hoverable \.chat-(reply|translate)-btn \{/.test(css + i18n), 'no button keeps a position of its own');++++console.log('chat-msg-actions: one cluster at the corner, editing in the box, "edited" after the words');
@@ -91,7 +91,7 @@
@@ -91,7 +91,7 @@
".chat-msg",
".chat-msg",
".chat-msg .chat-user",
".chat-msg .chat-user",
".chat-msg-crossfeed",
".chat-msg-crossfeed",
- ".chat-msg-hoverable .chat-reply-btn",
+ ".chat-msg-actions",
".chat-msg.system",
".chat-msg.system",
".chat-nickels-float .rewards-coin-balance",
".chat-nickels-float .rewards-coin-balance",
".chat-relay-badge",
".chat-relay-badge",
@@ -111,15 +111,7 @@
@@ -111,15 +111,7 @@
html[data-ov-motion=off] .chat-translation,html[data-ov-motion=off] .ch-caption,html[data-ov-motion=off] .star-card-border{opacity: 1;transform: none}
html[data-ov-motion=off] .chat-translation,html[data-ov-motion=off] .ch-caption,html[data-ov-motion=off] .star-card-border{opacity: 1;transform: none}
-/* ── Chat: "translate to my language" hover button (sits left of the reply button) ── */
-.chat-msg-hoverable .chat-translate-btn {
- display: none; position: absolute; right: 30px; top: 2px;
- padding: 2px 6px; font-size: 0.65rem; line-height: 1; z-index: 5; cursor: pointer;
- background: var(--bg-card, #1e1e2e); border: 1px solid var(--border, #333); border-radius: var(--radius-sm, 4px);
- color: var(--text-muted, #888); transition: color var(--ov-dur-2, 0.15s), border-color var(--ov-dur-2, 0.15s);
-}
-.chat-msg-hoverable:hover .chat-translate-btn { display: inline-flex; }
-.chat-msg-hoverable .chat-translate-btn:hover { color: var(--accent-light); border-color: var(--accent); }
+/* "Translate to my language" is one of a message's actions: style.css .chat-msg-actions. */
.chat-translation[hidden] { display: none; }
.chat-translation[hidden] { display: none; }
/* Lite mode (phones / weak GPUs): no blur, no filters, no shadow animations */
/* Lite mode (phones / weak GPUs): no blur, no filters, no shadow animations */
@@ -2631,27 +2631,43 @@ html[data-ov-motion=off] .ov-rail-track{scroll-behavior: auto}
@@ -2631,27 +2631,43 @@ html[data-ov-motion=off] .ov-rail-track{scroll-behavior: auto}
position: relative;
position: relative;
}
}
-/* ── Reply button (hover action) ─────────────────────────── */
-.chat-msg-hoverable .chat-reply-btn {
- display: none;
- position: absolute;
- right: 4px; top: 2px;
- padding: 2px 6px;
- font-size: 0.65rem;
+/* ── A message's actions: one cluster at its corner (openvibe.design rule 138) ──────────────
+ Reply, edit and translate float just above the message's right end, on hover or keyboard focus (a computer) or a
+ tap on the message (a phone). They never sit in the words, and nothing about a message is shown until asked for. */
+.chat-msg-actions {
+ position: absolute; bottom: calc(100% - 6px); right: 6px; z-index: 6;
+ display: none; align-items: center; gap: 2px; padding: 2px;
background: var(--bg-card, #1e1e2e);
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border, #333);
border: 1px solid var(--border, #333);
- border-radius: var(--radius-sm, 4px);
- color: var(--text-muted, #888);
- cursor: pointer;
- z-index: 5;
- line-height: 1;
- transition: color var(--ov-dur-2, 0.15s), border-color var(--ov-dur-2, 0.15s);
-}
-.chat-msg-hoverable:hover .chat-reply-btn { display: inline-flex; }
-.chat-msg-hoverable .chat-reply-btn:hover {
- color: var(--accent, var(--accent));
- border-color: var(--accent, var(--accent));
-}
+ border-radius: 10px;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, .35);
+}
+/* Above the message, over the line before it, so its own words stay readable; the first message has none above. */
+.chat-msg:first-child > .chat-msg-actions { bottom: auto; top: calc(100% - 6px); }
+.chat-msg-hoverable:hover > .chat-msg-actions,
+.chat-msg-hoverable:focus-within > .chat-msg-actions,
+.chat-msg-hoverable.is-acting > .chat-msg-actions { display: inline-flex; }
+.chat-msg-actions > button {
+ display: grid; place-items: center; width: 28px; height: 26px; padding: 0;
+ border: 0; border-radius: 7px; background: transparent; color: var(--text-muted, #888);
+ font-size: 0.78rem; line-height: 1; cursor: pointer;
+ transition: color var(--ov-dur-2, 0.15s), background-color var(--ov-dur-2, 0.15s);
+}
+.chat-msg-actions > button:hover,
+.chat-msg-actions > button:focus-visible { color: var(--text-primary, #fff); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
+.chat-msg-actions > button:focus-visible { outline: 2px solid var(--accent-light, var(--accent)); outline-offset: -2px; }
+/* A phone has no hover: the tap decides, and each button is big enough for a thumb. */
+@media (hover: none) {
+ .chat-msg-hoverable:hover:not(.is-acting):not(:focus-within) > .chat-msg-actions { display: none; }
+ .chat-msg-actions { right: 4px; border-radius: 12px; }
+ .chat-msg-actions > button { width: 44px; height: 40px; font-size: 0.95rem; border-radius: 9px; }
+}
+@media (hover: hover) { .chat-msg-hoverable:hover { background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); border-radius: 6px; } }
+.chat-msg-hoverable.is-acting { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); border-radius: 6px; }
+.chat-msg.is-editing { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); border-radius: 6px; }
+/* "edited": one quiet word after the words, on the same line (the time is its title). */
+.chat-edited { margin-left: 5px; font-size: 0.72em; color: var(--text-muted, #888); opacity: .85; white-space: nowrap; }
+.chat-edit-bar .chat-reply-bar-content i { color: var(--accent-light, var(--accent)); }
/* ── Reply header (on replied messages) ──────────────────── */
/* ── Reply header (on replied messages) ──────────────────── */
.chat-reply-header {
.chat-reply-header {
@@ -56,6 +56,7 @@ let chatSlowModeCooldownEnd = 0;
@@ -56,6 +56,7 @@ let chatSlowModeCooldownEnd = 0;
// ── Reply-to state ──────────────────────────────────────────
// ── Reply-to state ──────────────────────────────────────────
let _chatReplyTo = null; // { id, username, user_id, message }
let _chatReplyTo = null; // { id, username, user_id, message }
+let _chatEditing = null; // { id, muted, draft }: your own message, being changed in the chat box
// Relay username colors, keyed by source_platform. Used for BOTH live and history
// Relay username colors, keyed by source_platform. Used for BOTH live and history
// so external (RS/Twitch/Kick/YouTube) names keep a consistent color — history rows
// so external (RS/Twitch/Kick/YouTube) names keep a consistent color — history rows
@@ -1906,6 +1907,31 @@ function _applyChatVipBadge(evt) {
@@ -1906,6 +1907,31 @@ function _applyChatVipBadge(evt) {
if (!evt || evt.id == null || !evt.vip_badge) return;
if (!evt || evt.id == null || !evt.vip_badge) return;
document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => _placeVipBadge(el, evt.vip_badge));
document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => _placeVipBadge(el, evt.vip_badge));
}
}
+/** "edited": one quiet word right after the message's words, on its line; the time it changed on hover. */
+function _chatEditedMark(el, at) {
+ const text = el.querySelector('.chat-text');
+ if (!text) return;
+ let mark = el.querySelector('.chat-edited');
+ if (!mark) { mark = document.createElement('small'); mark.className = 'chat-edited'; }
+ if (mark.previousSibling !== text) text.after(mark);
+ const when = at ? new Date(at) : null;
+ mark.textContent = 'edited';
+ mark.title = when && !isNaN(when) ? `Edited at ${when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'Edited';
+}
+
+/** One cluster of a message's actions (reply, edit, translate), at its corner, shown on hover, focus or a tap. */
+function _chatActions(el) {
+ let acts = el.querySelector(':scope > .chat-msg-actions');
+ if (!acts) { acts = document.createElement('span'); acts.className = 'chat-msg-actions'; el.appendChild(acts); }
+ return acts;
+}
+function _chatActionBtn(cls, icon, label) {
+ const b = document.createElement('button');
+ b.type = 'button'; b.className = cls; b.title = label; b.setAttribute('aria-label', label);
+ b.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`;
+ return b;
+}
+
function _applyChatEdited(evt) {
function _applyChatEdited(evt) {
const m = evt && evt.message;
const m = evt && evt.message;
if (!m || !Number.isSafeInteger(Number(evt.id))) return;
if (!m || !Number.isSafeInteger(Number(evt.id))) return;
@@ -1917,9 +1943,7 @@ function _applyChatEdited(evt) {
@@ -1917,9 +1943,7 @@ function _applyChatEdited(evt) {
el.classList.toggle('chat-msg-tts-off', muted);
el.classList.toggle('chat-msg-tts-off', muted);
const shown = muted ? raw.replace(/^\s*\.\s?/, '') : raw;
const shown = muted ? raw.replace(/^\s*\.\s?/, '') : raw;
text.innerHTML = typeof parseEmotes === 'function' ? parseEmotes(shown) : esc(shown);
text.innerHTML = typeof parseEmotes === 'function' ? parseEmotes(shown) : esc(shown);
- let mark = el.querySelector('.chat-edited');
- if (!mark) { mark = document.createElement('small'); mark.className = 'chat-edited'; el.appendChild(mark); }
- mark.textContent = ` edited · ${new Date(m.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
+ _chatEditedMark(el, m.edited_at);
el._ovRaw = raw;
el._ovRaw = raw;
if (el._ovMsg) el._ovMsg.message = raw.slice(0, 100);
if (el._ovMsg) el._ovMsg.message = raw.slice(0, 100);
});
});
@@ -2552,7 +2576,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2552,7 +2576,7 @@ function buildChatMessageEl(msg, opts = {}) {
// tts-off messages drop the ":" so it reads "name 🔇 message" after the mute marker.
// tts-off messages drop the ":" so it reads "name 🔇 message" after the mute marker.
const separator = (ttsOff || msg.message_type === 'soundboard' || msg.message_type === 'channel-sound') ? ' ' : ': ';
const separator = (ttsOff || msg.message_type === 'soundboard' || msg.message_type === 'channel-sound') ? ' ' : ': ';
el.innerHTML = `${replyHtml}${timestamp}${streamBadge}${voiceBadge}${gameBadge}${widget ? '' : `<span class="chat-avatar-wrap">${avatarHtml}</span>`}${badge}${_relayBadge}${_aiBadge}${hatHtml}${particleWrapOpen}<span class="chat-user${nameFXClass}" style="color:${esc(nameColor)}" data-username="${displayName}" data-core-username="${coreUsername}" data-user-id="${userId}" data-anon="${isAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>${particleWrapClose}${separator}<span class="chat-text">${text}</span>`;
el.innerHTML = `${replyHtml}${timestamp}${streamBadge}${voiceBadge}${gameBadge}${widget ? '' : `<span class="chat-avatar-wrap">${avatarHtml}</span>`}${badge}${_relayBadge}${_aiBadge}${hatHtml}${particleWrapOpen}<span class="chat-user${nameFXClass}" style="color:${esc(nameColor)}" data-username="${displayName}" data-core-username="${coreUsername}" data-user-id="${userId}" data-anon="${isAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>${particleWrapClose}${separator}<span class="chat-text">${text}</span>`;
- if (msg.edited_at) { const mark = document.createElement('small'); mark.className = 'chat-edited'; mark.textContent = ` edited · ${new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; el.appendChild(mark); }
+ if (msg.edited_at) _chatEditedMark(el, msg.edited_at);
// Auto-translation under the message (live 'chat_translation' event, or persisted
// Auto-translation under the message (live 'chat_translation' event, or persisted
// metadata.translation on history). See server/i18n/translate.js for the direction rules.
// metadata.translation on history). See server/i18n/translate.js for the direction rules.
@@ -2565,13 +2589,10 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2565,13 +2589,10 @@ function buildChatMessageEl(msg, opts = {}) {
if (msg.id && !widget) {
if (msg.id && !widget) {
el._ovMsg = { id: msg.id, username: msg.username || msg.displayName || 'anon', user_id: msg.user_id, message: (msg.message || msg.text || '').slice(0, 100) };
el._ovMsg = { id: msg.id, username: msg.username || msg.displayName || 'anon', user_id: msg.user_id, message: (msg.message || msg.text || '').slice(0, 100) };
el._ovRaw = rawText;
el._ovRaw = rawText;
- const replyBtn = document.createElement('button');
- replyBtn.className = 'chat-reply-btn';
- replyBtn.title = 'Reply';
- replyBtn.innerHTML = '<i class="fa-solid fa-reply"></i>';
- el.appendChild(replyBtn);
+ const acts = _chatActions(el);
+ acts.appendChild(_chatActionBtn('chat-reply-btn', 'fa-reply', 'Reply'));
if ((!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {
if ((!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {
- const editBtn = document.createElement('button'); editBtn.className = 'ovk-btn ovk-btn--quiet chat-edit-btn'; editBtn.type = 'button'; editBtn.textContent = 'Edit'; el.appendChild(editBtn);
+ acts.appendChild(_chatActionBtn('chat-edit-btn', 'fa-pen', 'Edit your message'));
}
}
// "Translate to my language", only on lines confidently in another language than the viewer's (decided
// "Translate to my language", only on lines confidently in another language than the viewer's (decided
// locally, public/js/lang-detect.js) and not already translated into it by the channel's auto-translation.
// locally, public/js/lang-detect.js) and not already translated into it by the channel's auto-translation.
@@ -2579,11 +2600,8 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2579,11 +2600,8 @@ function buildChatMessageEl(msg, opts = {}) {
const _foreign = window.OVLang ? OVLang.foreignFor(displayRaw || '', _me) : null;
const _foreign = window.OVLang ? OVLang.foreignFor(displayRaw || '', _me) : null;
if (_foreign && !(_tr && _tr.to === _me)) {
if (_foreign && !(_tr && _tr.to === _me)) {
el.dataset.lang = _foreign;
el.dataset.lang = _foreign;
- const trBtn = document.createElement('button');
- trBtn.className = 'chat-translate-btn';
- trBtn.title = `Translate from ${_LANG_LABEL[_foreign] || _foreign} to ${_LANG_LABEL[_me] || 'my language'}`;
- trBtn.innerHTML = '<i class="fa-solid fa-language"></i>';
- el.appendChild(trBtn);
+ const trBtn = _chatActionBtn('chat-translate-btn', 'fa-language', `Translate from ${_LANG_LABEL[_foreign] || _foreign} to ${_LANG_LABEL[_me] || 'my language'}`);
+ acts.prepend(trBtn);
_autoTr.add(el, displayRaw);
_autoTr.add(el, displayRaw);
}
}
el.classList.add('chat-msg-hoverable');
el.classList.add('chat-msg-hoverable');
@@ -3396,6 +3414,7 @@ function sendChat(overrideInput = null) {
@@ -3396,6 +3414,7 @@ function sendChat(overrideInput = null) {
const input = overrideInput || getChatEl().input;
const input = overrideInput || getChatEl().input;
if (!input) return;
if (!input) return;
let text = input.value.trim();
let text = input.value.trim();
+ if (_chatEditing) { _saveChatEdit(input, text); return; }
if (!text) return;
if (!text) return;
// TTS-off toggle: when the TTS button for this chat surface is OFF, prepend a
// TTS-off toggle: when the TTS button for this chat surface is OFF, prepend a
@@ -3471,10 +3490,11 @@ function sendChat(overrideInput = null) {
@@ -3471,10 +3490,11 @@ function sendChat(overrideInput = null) {
* Set the reply target — shows the reply bar above the chat input.
* Set the reply target — shows the reply bar above the chat input.
*/
*/
function setChatReply(replyData) {
function setChatReply(replyData) {
+ if (_chatEditing) clearChatEdit();
_chatReplyTo = replyData;
_chatReplyTo = replyData;
// Show/update reply bar on all chat input areas
// Show/update reply bar on all chat input areas
document.querySelectorAll('.chat-input-area').forEach(area => {
document.querySelectorAll('.chat-input-area').forEach(area => {
- let bar = area.querySelector('.chat-reply-bar');
+ let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');
if (!bar) {
if (!bar) {
bar = document.createElement('div');
bar = document.createElement('div');
bar.className = 'chat-reply-bar';
bar.className = 'chat-reply-bar';
@@ -3499,11 +3519,77 @@ function setChatReply(replyData) {
@@ -3499,11 +3519,77 @@ function setChatReply(replyData) {
*/
*/
function clearChatReply() {
function clearChatReply() {
_chatReplyTo = null;
_chatReplyTo = null;
- document.querySelectorAll('.chat-reply-bar').forEach(bar => {
+ document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {
bar.style.display = 'none';
bar.style.display = 'none';
});
});
}
}
+/* ── Editing your own message, in the chat box ───────────────────── */
+
+/** The box takes the message's words and a bar says so: Enter saves, Escape (or ×) puts back what was in the box. */
+function setChatEdit(row) {
+ if (!row || !row._ovMsg) return;
+ if (_chatReplyTo) clearChatReply();
+ const input = getChatEl().input;
+ if (!input) return;
+ const raw = String(row._ovRaw || '');
+ const muted = raw.trimStart().startsWith('.');
+ if (!_chatEditing) _chatEditing = { draft: input.value };
+ Object.assign(_chatEditing, { id: row._ovMsg.id, muted });
+ document.querySelectorAll('.chat-msg.is-editing').forEach((r) => r.classList.remove('is-editing'));
+ row.classList.add('is-editing');
+ document.querySelectorAll('.chat-input-area').forEach((area) => {
+ let bar = area.querySelector('.chat-edit-bar');
+ if (!bar) {
+ bar = document.createElement('div');
+ bar.className = 'chat-reply-bar chat-edit-bar';
+ const inputRow = area.querySelector('.chat-input-row');
+ if (inputRow) area.insertBefore(bar, inputRow); else area.prepend(bar);
+ const content = document.createElement('div'); content.className = 'chat-reply-bar-content';
+ content.innerHTML = '<i class="fa-solid fa-pen" aria-hidden="true"></i> <strong>Editing your message</strong><span class="chat-reply-bar-snippet">Enter saves · Esc cancels</span>';
+ const close = document.createElement('button');
+ close.type = 'button'; close.className = 'chat-reply-bar-close'; close.title = 'Stop editing'; close.setAttribute('aria-label', 'Stop editing');
+ close.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
+ close.addEventListener('click', () => clearChatEdit());
+ bar.append(content, close);
+ }
+ bar.style.display = '';
+ area.classList.add('is-editing');
+ });
+ input.value = muted ? raw.replace(/^\s*\.\s?/, '') : raw;
+ _autoResizeTextarea(input);
+ input.focus();
+ requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });
+}
+
+function clearChatEdit({ keepText = false } = {}) {
+ const was = _chatEditing;
+ _chatEditing = null;
+ document.querySelectorAll('.chat-edit-bar').forEach((bar) => { bar.style.display = 'none'; });
+ document.querySelectorAll('.chat-input-area.is-editing').forEach((a) => a.classList.remove('is-editing'));
+ document.querySelectorAll('.chat-msg.is-editing').forEach((r) => r.classList.remove('is-editing'));
+ const input = getChatEl().input;
+ if (input && was && !keepText) { input.value = was.draft || ''; _autoResizeTextarea(input); }
+}
+
+function _saveChatEdit(input, text) {
+ const ed = _chatEditing;
+ if (!ed) return;
+ if (!text) { addSystemMessage('A message can\'t be empty. To take it back, delete it from its menu.'); return; }
+ if (messageHitsSlurPolicy(text)) { showSlurNudgeModal(); return; }
+ const body = ed.muted && !text.startsWith('.') ? '.' + text : text;
+ input.disabled = true;
+ fetch(`/api/chat/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
+ .then(async (r) => {
+ const data = await r.json().catch(() => ({}));
+ if (!r.ok) throw Error(data.error || 'Could not change that message');
+ _applyChatEdited({ id: ed.id, message: data.message });
+ clearChatEdit();
+ })
+ .catch((err) => addSystemMessage(err.message))
+ .finally(() => { input.disabled = false; input.focus(); });
+}
+
/**
/**
* Scroll to and highlight the message being replied to.
* Scroll to and highlight the message being replied to.
*/
*/
@@ -3552,7 +3638,12 @@ function _chatTextareaKeydown(e) {
@@ -3552,7 +3638,12 @@ function _chatTextareaKeydown(e) {
return;
return;
}
}
- // Escape: clear reply
+ // Escape: stop editing, or clear the reply
+ if (e.key === 'Escape' && _chatEditing) {
+ clearChatEdit();
+ e.preventDefault();
+ return;
+ }
if (e.key === 'Escape' && _chatReplyTo) {
if (e.key === 'Escape' && _chatReplyTo) {
clearChatReply();
clearChatReply();
e.preventDefault();
e.preventDefault();
@@ -5230,11 +5321,12 @@ function _buildCrossfeedEl(msg) {
@@ -5230,11 +5321,12 @@ function _buildCrossfeedEl(msg) {
const cfUserId = msg.user_id || '';
const cfUserId = msg.user_id || '';
const cfIsAnon = !cfUserId && !isRelayMsg;
const cfIsAnon = !cfUserId && !isRelayMsg;
const cfCore = msg.core_username || (isRelayMsg ? '' : (msg.username || ''));
const cfCore = msg.core_username || (isRelayMsg ? '' : (msg.username || ''));
- el.innerHTML = `${tsHtml}${sourceBadge}${badge}${_relayBadge}${_aiBadge}<span class="chat-name" style="color:${nameColor}" data-username="${displayName}" data-core-username="${esc(cfCore)}" data-user-id="${esc(String(cfUserId))}" data-anon="${cfIsAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>: <span class="chat-text">${text}</span>${msg.edited_at ? `<small class="chat-edited"> edited · ${esc(new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}</small>` : ''}`;
+ el.innerHTML = `${tsHtml}${sourceBadge}${badge}${_relayBadge}${_aiBadge}<span class="chat-name" style="color:${nameColor}" data-username="${displayName}" data-core-username="${esc(cfCore)}" data-user-id="${esc(String(cfUserId))}" data-anon="${cfIsAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>: <span class="chat-text">${text}</span>${msg.edited_at ? `<small class="chat-edited" title="Edited at ${esc(new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}">edited</small>` : ''}`;
if (msg.id && (!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {
if (msg.id && (!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {
el._ovMsg = { id: msg.id, username: msg.username, user_id: msg.user_id, message: rawText.slice(0, 100) };
el._ovMsg = { id: msg.id, username: msg.username, user_id: msg.user_id, message: rawText.slice(0, 100) };
el._ovRaw = rawText;
el._ovRaw = rawText;
- const editBtn = document.createElement('button'); editBtn.className = 'ovk-btn ovk-btn--quiet chat-edit-btn'; editBtn.type = 'button'; editBtn.textContent = 'Edit'; el.appendChild(editBtn);
+ _chatActions(el).appendChild(_chatActionBtn('chat-edit-btn', 'fa-pen', 'Edit your message'));
+ el.classList.add('chat-msg-hoverable');
}
}
return el;
return el;
}
}
@@ -6703,15 +6795,14 @@ function closeChatUsersPanel(btn) {
@@ -6703,15 +6795,14 @@ function closeChatUsersPanel(btn) {
const edit = t.closest('.chat-edit-btn');
const edit = t.closest('.chat-edit-btn');
if (edit) {
if (edit) {
const row = edit.closest('.chat-msg');
const row = edit.closest('.chat-msg');
- if (!row || !row._ovMsg) return;
- const body = window.prompt('Edit message', row._ovRaw || '');
- if (body == null) return;
- edit.disabled = true;
- fetch(`/api/chat/messages/${encodeURIComponent(row._ovMsg.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
- .then(async (r) => { const data = await r.json(); if (!r.ok) throw Error(data.error || 'Could not edit message'); _applyChatEdited({ id: row._ovMsg.id, message: data.message }); })
- .catch((err) => addSystemMessage(err.message)).finally(() => { edit.disabled = false; });
+ if (row && row._ovMsg) { e.stopPropagation(); setChatEdit(row); }
return;
return;
}
}
+ const row = t.closest('.chat-msg-hoverable');
+ if (!t.closest('.chat-msg-actions')) {
+ document.querySelectorAll('.chat-msg.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); });
+ if (row && window.matchMedia && matchMedia('(hover: none)').matches && !t.closest('a, button, .chat-name, .chat-user, .chat-avatar-wrap, input, textarea, [contenteditable]')) row.classList.toggle('is-acting');
+ }
const tr = t.closest('.chat-translate-btn');
const tr = t.closest('.chat-translate-btn');
if (tr) {
if (tr) {
const row = tr.closest('.chat-msg');
const row = tr.closest('.chat-msg');
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+/**
+ * A chat message's actions (openvibe.design rule 138). The owner, 11-12, with phone screenshots of a stream's chat:
+ * "The edit button needs improvement … so it never happens again", "it also looks stupid as shit after you edit it".
+ * Until then every one of your own messages carried a full-size "Edit" pill inline in its words, editing opened the
+ * browser's prompt box, and "edited · 06:49 PM" fell onto a line of its own under the message.
+ *
+ * Now: reply, edit and translate are one cluster at the message's corner (hover or focus on a computer, a tap on a
+ * phone); editing happens in the chat box with an "Editing your message" bar; "edited" is one quiet word after the words.
+ * node test/chat-msg-actions.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const chat = fs.readFileSync(path.join(root, 'public/js/chat.js'), 'utf8');
+const css = fs.readFileSync(path.join(root, 'public/css/style.css'), 'utf8');
+const i18n = fs.readFileSync(path.join(root, 'public/css/i18n-star.css'), 'utf8');
+
+// No message action is a kit button with a word in it, and none is appended into the message's line.
+assert.ok(!/chat-edit-btn'; editBtn\.type = 'button'; editBtn\.textContent = 'Edit'/.test(chat), 'no "Edit" pill');
+assert.ok(!/ovk-btn[^'"]*chat-(edit|reply|translate)-btn/.test(chat), 'message actions are not kit buttons');
+for (const cls of ['chat-reply-btn', 'chat-edit-btn', 'chat-translate-btn']) {
+ assert.ok(new RegExp(`_chatActionBtn\\('${cls}'`).test(chat), `${cls} is made by _chatActionBtn (an icon with a label)`);
+}
+assert.ok(!/el\.appendChild\((replyBtn|editBtn|trBtn)\)/.test(chat), 'no action button is appended straight into the message');
+assert.ok(/function _chatActions\(el\)[\s\S]{0,300}chat-msg-actions/.test(chat), 'the actions go in one .chat-msg-actions cluster');
+
+// Editing is in the chat box, never the browser's prompt or alert.
+assert.ok(!/window\.prompt\('Edit message'/.test(chat), 'no prompt box for an edit');
+assert.ok(/function setChatEdit\(row\)/.test(chat) && /function clearChatEdit\(/.test(chat) && /function _saveChatEdit\(/.test(chat));
+assert.ok(/if \(_chatEditing\) \{ _saveChatEdit\(input, text\); return; \}/.test(chat), 'Enter in the box saves the edit');
+assert.ok(/e\.key === 'Escape' && _chatEditing/.test(chat), 'Escape stops editing');
+assert.ok(/Editing your message/.test(chat), 'the bar says what is happening');
+assert.ok(/'\.chat-reply-bar:not\(\.chat-edit-bar\)'/.test(chat), 'replying and editing never reuse each other\'s bar');
+assert.ok(/ed\.muted && !text\.startsWith\('\.'\) \? '\.' \+ text : text/.test(chat), 'a message sent without TTS stays without TTS when edited');
+
+// "edited" is one word after the words, with the time as its title.
+assert.ok(/function _chatEditedMark\(el, at\)[\s\S]{0,400}text\.after\(mark\)[\s\S]{0,300}mark\.textContent = 'edited'/.test(chat), '"edited" sits right after the words');
+assert.ok(!/edited · \$\{/.test(chat), 'no "edited · time" line');
+
+// The cluster: absolute at the corner, shown only on hover, focus or a tap; thumb-sized where there is no hover.
+const block = (sel) => { const i = css.indexOf(`${sel} {`); assert.ok(i >= 0, `${sel} is styled`); return css.slice(i, css.indexOf('}', i)); };
+assert.match(block('.chat-msg-actions'), /position: absolute/);
+assert.match(block('.chat-msg-actions'), /display: none/);
+assert.match(css, /\.chat-msg-hoverable:hover > \.chat-msg-actions,\s*\.chat-msg-hoverable:focus-within > \.chat-msg-actions,\s*\.chat-msg-hoverable\.is-acting > \.chat-msg-actions \{ display: inline-flex; \}/);
+assert.match(css, /@media \(hover: none\) \{[\s\S]{0,400}width: 44px; height: 40px/, 'a phone gets thumb-sized actions');
+assert.match(block('.chat-edited'), /white-space: nowrap/);
+assert.ok(!/\.chat-msg-hoverable \.chat-(reply|translate)-btn \{/.test(css + i18n), 'no button keeps a position of its own');
+
+console.log('chat-msg-actions: one cluster at the corner, editing in the box, "edited" after the words');