openvibers/openvibe.live Commits
Live: show and edit chat messages in place (#798)
Changes
@@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.399.0- openvibe-sdk: v0.147.0--- openvibe-shared: v4.202.0++- openvibe-shared: v4.203.0<!-- versions:end -->@@@ -26,7 +26,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -3417,9 +3417,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.202.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",-- "integrity": "sha512-lSZavwvUc8nI7eS7ZzLjVaz2d2xVmvYGh81H83d+i1LPp7cbS7eK9HCiQS8ESK9N8eH0i/Ok+sqBFxeUwsnVPQ==",++ "version": "4.203.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",++ "integrity": "sha512-ZdxqZTQNqVStJUGE96raOdaOFmCKBe+qDV6qevfyXprLxWqMoaBn/ZL6HJmdNfzt+y4pL4aIhNQiLXXONuZfsA==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -46,7 +46,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -466,7 +466,10 @@const msgs = p.messageCount == null ? null : Number(p.messageCount);let modBtns = '';if (canMod || isGlobalMod) {-- if (d.msgId) modBtns += `<button class="ctx-btn ctx-btn-warn" onclick="ctxDeleteMessage('${esc(String(d.msgId))}')"><i class="fa-solid fa-trash"></i> Delete Message</button>`;++ if (d.msgId) {++ modBtns += `<button class="ctx-btn" onclick="OpenVibeChat.viewEarlierVersions('${esc(String(d.msgId))}')">Earlier versions</button>`;++ modBtns += `<button class="ctx-btn ctx-btn-warn" onclick="ctxDeleteMessage('${esc(String(d.msgId))}')"><i class="fa-solid fa-trash"></i> Delete Message</button>`;++ }modBtns += `<button class="ctx-btn ctx-btn-warn" data-uid="${esc(String(p.id))}" onclick="ctxDeleteAllUserMessages(this.dataset.uid)"><i class="fa-solid fa-trash-can"></i> Delete All Messages</button>`;}return `@@@ -558,6 +561,16 @@);}++ async function viewEarlierVersions(id) {++ try {++ const response = await fetch(`/api/chat/messages/${encodeURIComponent(id)}/edits`, { credentials: 'include' });++ const data = await response.json();++ if (!response.ok) throw new Error(data.error || 'Could not load earlier versions');++ const versions = Array.isArray(data.versions) ? data.versions : [];++ window.alert(versions.length ? versions.map((v) => `${new Date(v.edited_at).toLocaleString()}\n${v.message}`).join('\n\n') : 'No earlier versions');++ } catch (err) { window.alert(err.message || 'Could not load earlier versions'); }++ }++window.OpenVibeChat = {escape: esc,linkify,@@@ -568,6 +581,7 @@showLinkContextMenu,isTrusted: (domain) => _trustedDomains.has(String(domain || '').toLowerCase()),buildUserMenu,++ viewEarlierVersions,parseRelayUsername,relayPlatformBadge,badgeHTML: _badgeHTML,@@@ -1250,6 +1250,12 @@ function _chatCacheForget(ids) {c.entry.data.messages = c.entry.data.messages.filter((m) => !drop.has(String(m.id)));try { localStorage.setItem(_chatCacheKey(c.room), JSON.stringify(c.entry)); } catch { /* */ }}++function _chatCacheEdit(id, message) {++ const c = _chatCacheLive;++ if (!c.entry) return;++ for (const row of c.entry.data.messages) if (String(row.id) === String(id)) { row.message = message.message; row.edited_at = message.edited_at; row.card = message.card || null; }++ try { localStorage.setItem(_chatCacheKey(c.room), JSON.stringify(c.entry)); } catch { /* cache is optional */ }++}/*** Remove the oldest rows past `max`. Only a reader who has scrolled back needs the offset given back,@@@ -1898,6 +1904,25 @@ 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));}++function _applyChatEdited(evt) {++ const m = evt && evt.message;++ if (!m || !Number.isSafeInteger(Number(evt.id))) return;++ document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => {++ const text = el.querySelector('.chat-text');++ if (!text) return;++ const raw = String(m.message || '');++ const muted = raw.trimStart().startsWith('.');++ 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' })}`;++ el._ovRaw = raw;++ if (el._ovMsg) el._ovMsg.message = raw.slice(0, 100);++ });++ _chatCacheEdit(evt.id, m);++}function handleChatMessage(msg) {switch (msg.type) {@@@ -1907,6 +1932,9 @@ function handleChatMessage(msg) {case 'chat_vip_badge':_applyChatVipBadge(msg);break;++ case 'chat_edited':++ _applyChatEdited(msg);++ break;case 'chat':addChatMessage(msg);// Self-mode TTS: speak every incoming chat message via browser synthesis@@@ -2469,8 +2497,8 @@ function buildChatMessageEl(msg, opts = {}) {const isAnon = displayName.startsWith('anon');// Mention highlighting-- const currentUser = getCurrentUsername();-- const isMention = currentUser && rawText.toLowerCase().includes(`@${currentUser.toLowerCase()}`);++ const currentUsername = getCurrentUsername();++ const isMention = currentUsername && rawText.toLowerCase().includes(`@${currentUsername.toLowerCase()}`);if (isMention && chatSettings.mentionHighlight) {el.classList.add('chat-msg-mention');// Only alert (tab flash / sound) for LIVE mentions — not when replaying@@@ -2521,7 +2549,8 @@ 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}${text}`;++ 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); }// Auto-translation under the message (live 'chat_translation' event, or persisted// metadata.translation on history). See server/i18n/translate.js for the direction rules.@@@ -2533,12 +2562,15 @@ function buildChatMessageEl(msg, opts = {}) {// file) reads what it needs from these expandos, so 500 rows cost 0 listeners, not 1,000.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 = displayRaw;++ 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);++ 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);++ }// "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.const _me = _viewerLang();@@@ -5191,7 +5223,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>`;++ 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>` : ''}`;++ 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);++ }return el;}@@@ -6652,6 +6689,18 @@ function closeChatUsersPanel(btn) {if (row && row._ovMsg) { e.stopPropagation(); setChatReply({ ...row._ovMsg }); }return;}++ 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; });++ return;++ }const tr = t.closest('.chat-translate-btn');if (tr) {const row = tr.closest('.chat-msg');
@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.399.0
- openvibe-contracts: v0.399.0
- openvibe-sdk: v0.147.0
- openvibe-sdk: v0.147.0
-- openvibe-shared: v4.202.0
+- openvibe-shared: v4.203.0
<!-- versions:end -->
<!-- versions:end -->
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3417,9 +3417,9 @@
@@ -3417,9 +3417,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.202.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
- "integrity": "sha512-lSZavwvUc8nI7eS7ZzLjVaz2d2xVmvYGh81H83d+i1LPp7cbS7eK9HCiQS8ESK9N8eH0i/Ok+sqBFxeUwsnVPQ==",
+ "version": "4.203.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
+ "integrity": "sha512-ZdxqZTQNqVStJUGE96raOdaOFmCKBe+qDV6qevfyXprLxWqMoaBn/ZL6HJmdNfzt+y4pL4aIhNQiLXXONuZfsA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.203.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -466,7 +466,10 @@
@@ -466,7 +466,10 @@
const msgs = p.messageCount == null ? null : Number(p.messageCount);
const msgs = p.messageCount == null ? null : Number(p.messageCount);
let modBtns = '';
let modBtns = '';
if (canMod || isGlobalMod) {
if (canMod || isGlobalMod) {
- if (d.msgId) modBtns += `<button class="ctx-btn ctx-btn-warn" onclick="ctxDeleteMessage('${esc(String(d.msgId))}')"><i class="fa-solid fa-trash"></i> Delete Message</button>`;
+ if (d.msgId) {
+ modBtns += `<button class="ctx-btn" onclick="OpenVibeChat.viewEarlierVersions('${esc(String(d.msgId))}')">Earlier versions</button>`;
+ modBtns += `<button class="ctx-btn ctx-btn-warn" onclick="ctxDeleteMessage('${esc(String(d.msgId))}')"><i class="fa-solid fa-trash"></i> Delete Message</button>`;
+ }
modBtns += `<button class="ctx-btn ctx-btn-warn" data-uid="${esc(String(p.id))}" onclick="ctxDeleteAllUserMessages(this.dataset.uid)"><i class="fa-solid fa-trash-can"></i> Delete All Messages</button>`;
modBtns += `<button class="ctx-btn ctx-btn-warn" data-uid="${esc(String(p.id))}" onclick="ctxDeleteAllUserMessages(this.dataset.uid)"><i class="fa-solid fa-trash-can"></i> Delete All Messages</button>`;
}
}
return `
return `
@@ -558,6 +561,16 @@
@@ -558,6 +561,16 @@
);
);
}
}
+ async function viewEarlierVersions(id) {
+ try {
+ const response = await fetch(`/api/chat/messages/${encodeURIComponent(id)}/edits`, { credentials: 'include' });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || 'Could not load earlier versions');
+ const versions = Array.isArray(data.versions) ? data.versions : [];
+ window.alert(versions.length ? versions.map((v) => `${new Date(v.edited_at).toLocaleString()}\n${v.message}`).join('\n\n') : 'No earlier versions');
+ } catch (err) { window.alert(err.message || 'Could not load earlier versions'); }
+ }
+
window.OpenVibeChat = {
window.OpenVibeChat = {
escape: esc,
escape: esc,
linkify,
linkify,
@@ -568,6 +581,7 @@
@@ -568,6 +581,7 @@
showLinkContextMenu,
showLinkContextMenu,
isTrusted: (domain) => _trustedDomains.has(String(domain || '').toLowerCase()),
isTrusted: (domain) => _trustedDomains.has(String(domain || '').toLowerCase()),
buildUserMenu,
buildUserMenu,
+ viewEarlierVersions,
parseRelayUsername,
parseRelayUsername,
relayPlatformBadge,
relayPlatformBadge,
badgeHTML: _badgeHTML,
badgeHTML: _badgeHTML,
@@ -1250,6 +1250,12 @@ function _chatCacheForget(ids) {
@@ -1250,6 +1250,12 @@ function _chatCacheForget(ids) {
c.entry.data.messages = c.entry.data.messages.filter((m) => !drop.has(String(m.id)));
c.entry.data.messages = c.entry.data.messages.filter((m) => !drop.has(String(m.id)));
try { localStorage.setItem(_chatCacheKey(c.room), JSON.stringify(c.entry)); } catch { /* */ }
try { localStorage.setItem(_chatCacheKey(c.room), JSON.stringify(c.entry)); } catch { /* */ }
}
}
+function _chatCacheEdit(id, message) {
+ const c = _chatCacheLive;
+ if (!c.entry) return;
+ for (const row of c.entry.data.messages) if (String(row.id) === String(id)) { row.message = message.message; row.edited_at = message.edited_at; row.card = message.card || null; }
+ try { localStorage.setItem(_chatCacheKey(c.room), JSON.stringify(c.entry)); } catch { /* cache is optional */ }
+}
/**
/**
* Remove the oldest rows past `max`. Only a reader who has scrolled back needs the offset given back,
* Remove the oldest rows past `max`. Only a reader who has scrolled back needs the offset given back,
@@ -1898,6 +1904,25 @@ function _applyChatVipBadge(evt) {
@@ -1898,6 +1904,25 @@ 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));
}
}
+function _applyChatEdited(evt) {
+ const m = evt && evt.message;
+ if (!m || !Number.isSafeInteger(Number(evt.id))) return;
+ document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => {
+ const text = el.querySelector('.chat-text');
+ if (!text) return;
+ const raw = String(m.message || '');
+ const muted = raw.trimStart().startsWith('.');
+ 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' })}`;
+ el._ovRaw = raw;
+ if (el._ovMsg) el._ovMsg.message = raw.slice(0, 100);
+ });
+ _chatCacheEdit(evt.id, m);
+}
function handleChatMessage(msg) {
function handleChatMessage(msg) {
switch (msg.type) {
switch (msg.type) {
@@ -1907,6 +1932,9 @@ function handleChatMessage(msg) {
@@ -1907,6 +1932,9 @@ function handleChatMessage(msg) {
case 'chat_vip_badge':
case 'chat_vip_badge':
_applyChatVipBadge(msg);
_applyChatVipBadge(msg);
break;
break;
+ case 'chat_edited':
+ _applyChatEdited(msg);
+ break;
case 'chat':
case 'chat':
addChatMessage(msg);
addChatMessage(msg);
// Self-mode TTS: speak every incoming chat message via browser synthesis
// Self-mode TTS: speak every incoming chat message via browser synthesis
@@ -2469,8 +2497,8 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2469,8 +2497,8 @@ function buildChatMessageEl(msg, opts = {}) {
const isAnon = displayName.startsWith('anon');
const isAnon = displayName.startsWith('anon');
// Mention highlighting
// Mention highlighting
- const currentUser = getCurrentUsername();
- const isMention = currentUser && rawText.toLowerCase().includes(`@${currentUser.toLowerCase()}`);
+ const currentUsername = getCurrentUsername();
+ const isMention = currentUsername && rawText.toLowerCase().includes(`@${currentUsername.toLowerCase()}`);
if (isMention && chatSettings.mentionHighlight) {
if (isMention && chatSettings.mentionHighlight) {
el.classList.add('chat-msg-mention');
el.classList.add('chat-msg-mention');
// Only alert (tab flash / sound) for LIVE mentions — not when replaying
// Only alert (tab flash / sound) for LIVE mentions — not when replaying
@@ -2521,7 +2549,8 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2521,7 +2549,8 @@ 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}${text}`;
+ 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); }
// 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.
@@ -2533,12 +2562,15 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2533,12 +2562,15 @@ function buildChatMessageEl(msg, opts = {}) {
// file) reads what it needs from these expandos, so 500 rows cost 0 listeners, not 1,000.
// file) reads what it needs from these expandos, so 500 rows cost 0 listeners, not 1,000.
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 = displayRaw;
+ el._ovRaw = rawText;
const replyBtn = document.createElement('button');
const replyBtn = document.createElement('button');
replyBtn.className = 'chat-reply-btn';
replyBtn.className = 'chat-reply-btn';
replyBtn.title = 'Reply';
replyBtn.title = 'Reply';
replyBtn.innerHTML = '<i class="fa-solid fa-reply"></i>';
replyBtn.innerHTML = '<i class="fa-solid fa-reply"></i>';
el.appendChild(replyBtn);
el.appendChild(replyBtn);
+ 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);
+ }
// "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.
const _me = _viewerLang();
const _me = _viewerLang();
@@ -5191,7 +5223,12 @@ function _buildCrossfeedEl(msg) {
@@ -5191,7 +5223,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>`;
+ 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>` : ''}`;
+ 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);
+ }
return el;
return el;
}
}
@@ -6652,6 +6689,18 @@ function closeChatUsersPanel(btn) {
@@ -6652,6 +6689,18 @@ function closeChatUsersPanel(btn) {
if (row && row._ovMsg) { e.stopPropagation(); setChatReply({ ...row._ovMsg }); }
if (row && row._ovMsg) { e.stopPropagation(); setChatReply({ ...row._ovMsg }); }
return;
return;
}
}
+ 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; });
+ return;
+ }
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');