openvibers/openvibe.chat Commits

Chat: edit messages for 24 hours across stores and clients (#512)

OP OpenVibers committed 2 days ago · 1c01296162 · 23 files, +422 −28

* Chat: allow editing room and DM messages

* Finish live message edits across Chat stores and web views

* Keep edit controls compatible with lightweight page clients

* Merge remote-tracking branch 'origin/main' into cx/chat-editable

* Keep edits consistent across queued cards and Dock sockets

* Deliver private edit events to each DM participant

* Pin shared Dock edit support in Chat

* Move editable messages migration to 0073

* Merge remote-tracking branch 'origin/main' into cx/chat-editable

Changes

ddiff --git a/README.md b/README.md
iindex 21b91c3..6ff2d19 100644
---- a/README.md
++++ b/README.md
@@@ -983,5 +983,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.399.0
- openvibe-sdk: v0.147.0
--- openvibe-shared: v4.202.0
++- openvibe-shared: v4.203.0
<!-- versions:end -->
ddiff --git a/migrations/0073_editable_messages.sql b/migrations/0073_editable_messages.sql
nnew file mode 100644
iindex 0000000..62c3f60
---- /dev/null
++++ b/migrations/0073_editable_messages.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Preserve prior text for moderators and mark the current revision on every message store.
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS edited_at text COLLATE "C";
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS edit_history jsonb NOT NULL DEFAULT '[]'::jsonb;
++ALTER TABLE dm_messages ADD COLUMN IF NOT EXISTS edited_at text COLLATE "C";
++ALTER TABLE dm_messages ADD COLUMN IF NOT EXISTS edit_history jsonb NOT NULL DEFAULT '[]'::jsonb;
++ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at text COLLATE "C";
++ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edit_history jsonb NOT NULL DEFAULT '[]'::jsonb;
ddiff --git a/package-lock.json b/package-lock.json
iindex eb9aa5f..a967241 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"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.0",
"ws": "^8.18.0"
},
@@@ -1682,9 +1682,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",
ddiff --git a/package.json b/package.json
iindex 20053bc..7cd91c9 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"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.0",
"ws": "^8.18.0"
},
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex e32b9cf..8fe343a 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -198,12 +198,53 @@
}
// A preview that landed after its message: replace the line's cards with the finished list.
function updateEmbeds(id, embeds) {
-- if (!feed || !Array.isArray(embeds) || !embeds.length) return;
++ if (!feed || !Array.isArray(embeds)) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
addCard(li, { embeds: embeds });
}
++ function applyEdit(id, m) {
++ if (!feed) return;
++ var li = feed.querySelector('[data-id="' + Number(id) + '"]');
++ if (!li) return;
++ var t = li.querySelector('.oc-text, .oc-bubble');
++ if (t) { t.textContent = ''; textWithLinks(t, m.message); }
++ var mark = li.querySelector('.oc-edited');
++ if (!mark) { mark = el('span', 'oc-muted oc-edited'); if (t) t.insertAdjacentElement('afterend', mark); else li.appendChild(mark); }
++ mark.textContent = ' edited · ' + clock(m.edited_at).textContent;
++ Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
++ addCard(li, m);
++ }
++ function addEditButton(li) {
++ if (!li || li.querySelector('.oc-edit') || !cfg.me) return;
++ var owned = cfg.view === 'dm' ? li.classList.contains('oc-mine') || li.classList.contains('oc-bubble-mine') : Number(li.dataset.user) === Number(cfg.me);
++ if (!owned) return;
++ var b = el('button', 'ovk-btn ovk-btn--quiet oc-edit', 'Edit'); b.type = 'button'; li.appendChild(b);
++ }
++ if (feed) feed.addEventListener('click', function (e) {
++ if (!e.target.classList || !e.target.classList.contains('oc-edits') || !cfg.room) return;
++ var li = e.target.closest('[data-id]'); if (!li) return;
++ fetch('/api/chat/rooms/' + encodeURIComponent(cfg.room) + '/messages/' + encodeURIComponent(li.dataset.id) + '/edits', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { if (d && d.versions) window.alert(d.versions.map(function (v) { return String(v.edited_at || '') + '\n' + String(v.message || ''); }).join('\n\n')); })
++ .catch(function () { showError('Could not load earlier versions'); });
++ });
++ if (feed && (cfg.view === 'global' || cfg.view === 'dm' || cfg.view === 'room')) {
++ if (feed.querySelectorAll) Array.prototype.forEach.call(feed.querySelectorAll('[data-id]'), addEditButton);
++ feed.addEventListener('click', function (e) {
++ if (!e.target.classList || !e.target.classList.contains('oc-edit')) return;
++ var li = e.target.closest('[data-id]'), t = li && li.querySelector('.oc-text, .oc-bubble');
++ if (!li || !t) return;
++ var body = window.prompt('Edit message', t.textContent);
++ if (body == null || body.trim() === t.textContent.trim()) return;
++ var base = cfg.view === 'dm' ? '/api/dm/conversations/' + encodeURIComponent(cfg.conversation) : cfg.view === 'room' ? '/api/chat/rooms/' + encodeURIComponent(cfg.room) : '/api/chat';
++ e.target.disabled = true;
++ fetch(base + '/messages/' + encodeURIComponent(li.dataset.id), { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
++ .then(function (r) { return r.json().then(function (d) { if (!r.ok) throw Error(d.error || 'Could not edit'); applyEdit(li.dataset.id, d.message); }); })
++ .catch(function (err) { showError(err.message); }).then(function () { e.target.disabled = false; });
++ });
++ }
// Display only (the server decides who may do what): the same badges the page renders.
var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
@@@ -214,7 +255,7 @@
if (id && feed.querySelector('[data-id="' + id + '"]')) return;
var stick = nearBottom();
removeEmpty();
-- feed.appendChild(li);
++ addEditButton(li); feed.appendChild(li);
while (feed.children.length > 300) {
var oldest = feed.querySelector('[data-id]');
if (!oldest) break;
@@@ -254,7 +295,7 @@
// ── Global chat ──
function globalItem(m) {
-- var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';
++ var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
li.appendChild(clock(m.timestamp)); li.appendChild(document.createTextNode(' '));
var handle = m.core_username || (m.user_id ? m.username : null);
var name = m.display_name || m.username || m.anon_id || 'someone';
@@@ -265,6 +306,7 @@
var b = badge(m.role); if (b) li.appendChild(b);
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ if (m.edited_at) li.appendChild(el('span', 'oc-muted oc-edited', ' edited · ' + clock(m.edited_at).textContent));
addCard(li, m);
return li;
}
@@@ -320,6 +362,8 @@
append(globalItem(m), m.id);
} else if (m.type === 'chat_embeds' && m.id) {
updateEmbeds(m.id, m.embeds);
++ } else if (m.type === 'chat_edited' && m.message) {
++ applyEdit(m.id, m.message);
} else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {
removeIds(m.ids || m.message_ids);
} else if (m.type === 'error' && m.message) {
@@@ -354,11 +398,19 @@
li.appendChild(el('span', 'oc-name', m.display_name || m.username));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ if (m.edited_at) li.appendChild(el('span', 'oc-muted oc-edited', ' edited · ' + clock(m.edited_at).textContent));
addCard(li, m);
return li;
}
function startDm() {
var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
++ function connectDm() {
++ var dmSocket = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
++ dmSocket.onopen = function () { dmSocket.send(JSON.stringify({ type: 'join', token: sessionToken() })); };
++ dmSocket.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (_) { return; } if (Number(m.conversation_id) !== Number(cfg.conversation)) return; if (m.type === 'dm_edited' && m.message) applyEdit(m.message.id, m.message); else if (m.type === 'dm_embeds') updateEmbeds(m.id, m.embeds); };
++ dmSocket.onclose = function () { setTimeout(connectDm, 3000); };
++ }
++ connectDm();
window.OvChatHistory.attach(feed, document.getElementById('oc-history-state'), function (before) {
return fetch(base + '/messages?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; });
@@@ -437,6 +489,8 @@
if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }
else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
li.appendChild(t);
++ if (m.edited_at) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted oc-edited', 'edited · ' + clock(m.edited_at).textContent)); }
++ if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
addCard(li, m);
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
@@@ -522,6 +576,8 @@
showThinking(m.name);
} else if (m.type === 'room_message_embeds' && m.room === cfg.room) {
updateEmbeds(m.id, m.embeds);
++ } else if (m.type === 'room_message_edited' && m.room === cfg.room && m.message) {
++ applyEdit(m.message.id, m.message);
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
} else if (m.type === 'room_error' && m.message) {
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 6abf14a..ae74b67 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -287,6 +287,7 @@
b.appendChild(document.createTextNode(String(text)));
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
you.appendChild(b); li.appendChild(you);
++ if (id) { var edit = el('button', 'ovk-btn ovk-btn--quiet vb-edit', 'Edit'); edit.type = 'button'; you.appendChild(edit); }
return li;
}
// The answer side of a turn: Vibe's mark, then its reply.
@@@ -499,6 +500,29 @@
}
if (stick) toBottom();
}
++ function applyEdited(m) {
++ var li = have(m && m.id), bubble = li && li.querySelector('.ovt-you .ovt-bubble');
++ if (!bubble) return;
++ bubble.textContent = '';
++ bubble.appendChild(el('span', 'oc-sr', 'You said:'));
++ bubble.appendChild(document.createTextNode(String(m.message || '')));
++ if (!li.querySelector('.vb-edit')) { var edit = el('button', 'ovk-btn ovk-btn--quiet vb-edit', 'Edit'); edit.type = 'button'; li.querySelector('.ovt-you').appendChild(edit); }
++ var mark = li.querySelector('.vb-edited');
++ if (!mark) { mark = el('small', 'vb-edited'); li.querySelector('.ovt-you').appendChild(mark); }
++ mark.textContent = 'edited · ' + String(m.edited_at || '').slice(11, 16);
++ }
++ app.addEventListener('click', function (e) {
++ if (!e.target.classList || !e.target.classList.contains('vb-edit') || !room) return;
++ var li = e.target.closest('[data-me]'), bubble = li && li.querySelector('.ovt-bubble');
++ if (!li || !bubble) return;
++ var copy = bubble.cloneNode(true);
++ Array.prototype.forEach.call(copy.querySelectorAll('button, small'), function (x) { x.remove(); });
++ var body = window.prompt('Edit message', copy.textContent.replace(/^You said:/, ''));
++ if (body == null) return;
++ fetch('/api/chat/rooms/' + encodeURIComponent(room) + '/messages/' + encodeURIComponent(li.getAttribute('data-me')), { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
++ .then(function (r) { return r.json().then(function (d) { if (!r.ok) throw Error(d.error || 'Could not edit'); applyEdited(d.message); }); })
++ .catch(function (err) { notice(err.message); });
++ });
// ── The canvas (plan HO9): the agent's data panel beside the chat ─────────────────────────────────────────────
// Only the newest task in the chat, and only when OpenVibe.Actor holds it (the task's actor_task is the tsk_…
@@@ -656,6 +680,7 @@
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === 'room_joined') catchUp();
else if (m.type === 'room_message' && m.room === room) add(m.message);
++ else if (m.type === 'room_message_edited' && m.room === room) applyEdited(m.message);
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
ddiff --git a/server/app.js b/server/app.js
iindex 92f129b..7931147 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -419,7 +419,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
async function handleUpgrade(req, socket, head) {
const url = req.url || '';
const origin = normalizeOrigin(req.headers.origin);
-- if (origin && !allowedOrigins.has(origin)) {
++ // The shared Dock opens a bearer-authenticated chat socket from every first-party site.
++ if (origin && !allowedOrigins.has(origin) && !(url.startsWith('/ws/chat') && FIRST_PARTY.has(origin))) {
console.warn(`[Server] WebSocket upgrade rejected — origin "${origin}" not in allowed origins`);
socket.destroy();
return;
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex c110c94..30f9efc 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1189,7 +1189,7 @@ class ChatServer {
card,
});
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
-- if (pending && result.lastInsertRowid) linkCards.fillLater('chat_messages', Number(result.lastInsertRowid), pending);
++ if (pending && result.lastInsertRowid) linkCards.fillLater('chat_messages', Number(result.lastInsertRowid), pending, text);
// General address previews run after the line is out (server/chat/link-previews.js): the saved message
// carries embeds: [] and a later frame carries the filled list. Channel/global viewers get the same frame.
if (result.lastInsertRowid) {
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex a119799..56bad32 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -36,7 +36,7 @@ async function dmSettingsOf(target) {
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
function publicMessage(m) {
-- if (m) delete m.sender_subject_id;
++ if (m) { delete m.sender_subject_id; delete m.edit_history; }
return m;
}
@@@ -564,4 +564,23 @@ router.delete('/conversations/:id/messages/:msgId', deleteLimit, async (req, res
}
});
++router.patch('/conversations/:id/messages/:msgId', sendLimit, async (req, res) => {
++ const convId = Number(req.params.id), msgId = Number(req.params.msgId);
++ if (!await dm.isParticipant(convId, req.user.id)) return res.status(403).json({ error: 'Not a participant' });
++ try {
++ const message = publicMessage(await dm.editMessage(convId, msgId, req.user.id, req.body && req.body.message));
++ if (!message) return res.status(404).json({ error: 'No such message' });
++ const participants = await dm.getParticipants(convId);
++ for (const p of participants) await require('./chat-server').sendDm(p.id, { type: 'dm_edited', conversation_id: convId, message });
++ return res.json({ message });
++ } catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to edit message' }); }
++});
++
++router.get('/conversations/:id/messages/:msgId/edits', async (req, res) => {
++ if (!['admin', 'global_mod'].includes(req.user.role)) return res.status(403).json({ error: 'Moderators only' });
++ const row = await database.get('SELECT edit_history FROM dm_messages WHERE id = ? AND conversation_id = ?', [Number(req.params.msgId), Number(req.params.id)]);
++ if (!row) return res.status(404).json({ error: 'No such message' });
++ return res.set('Cache-Control', 'private, no-store').json({ versions: row.edit_history || [] });
++});
++
module.exports = router;
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
iindex e2f9c55..7ba5d2d 100644
---- a/server/chat/dm.js
++++ b/server/chat/dm.js
@@@ -205,7 +205,7 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
if (row) {
row.card = card;
row.embeds = []; // the job fills them; the send answer starts empty, as a room's does
-- if (pending) linkCards.fillLater('dm_messages', row.id, pending);
++ if (pending) linkCards.fillLater('dm_messages', row.id, pending, trimmed);
// Address previews run after the line is out (server/chat/link-previews.js), and only when every person in
// the conversation left link previews on (part 4 privacy: one side off fetches nothing).
if (await previewSettings.allowedIn(conversationId)) {
@@@ -222,6 +222,40 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
return row;
}
++async function editMessage(conversationId, messageId, senderId, text, { now = Date.now() } = {}) {
++ if (!await isParticipant(conversationId, senderId)) return null;
++ const msg = await db.get('SELECT * FROM dm_messages WHERE id = ? AND conversation_id = ?', [messageId, conversationId]);
++ if (!msg) return null;
++ if (Number(msg.sender_id) !== Number(senderId)) { const e = new Error('Only the author can edit this'); e.status = 403; throw e; }
++ const sentAt = Date.parse(String(msg.created_at).includes('T') ? msg.created_at : `${String(msg.created_at).replace(' ', 'T')}Z`);
++ if (!Number.isFinite(sentAt) || now - sentAt > 24 * 60 * 60 * 1000) { const e = new Error('Messages can be edited for 24 hours'); e.status = 403; throw e; }
++ const body = String(text || '').trim().slice(0, 2000);
++ if (!body) return null;
++ const { card, pending } = await linkCards.forSend(body);
++ const editedAt = new Date(now).toISOString();
++ const updated = await db.tx(async () => {
++ const changed = await db.run(`UPDATE dm_messages SET edit_history = edit_history || jsonb_build_array(jsonb_build_object('message', message, 'edited_at', COALESCE(edited_at, created_at))), message = ?, card = ?, edited_at = ? WHERE id = ?`, [body, linkCards.toJson(card), editedAt, messageId]);
++ if (!changed.changes) { const e = new Error('No such message'); e.status = 404; throw e; }
++ await linkPreviews.replaceForEdit('dm', Number(messageId));
++ const participants = await db.all('SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?', [conversationId]);
++ const senderSubject = await db.subjectFor(senderId);
++ for (const participant of participants) {
++ const subject = participant.subject_id || await db.subjectFor(participant.user_id);
++ if (!subject) continue;
++ await outbox.emit({ event_type: 'chat.dm.edited', visibility: 'subject', subject: { type: 'user', id: subject }, payload: { message_id: Number(messageId), conversation_id: Number(conversationId), text: body, edited_at: editedAt, sender_subject: senderSubject, card: card || null } });
++ }
++ return db.get('SELECT * FROM dm_messages WHERE id = ?', [messageId]);
++ });
++ updated.card = card; updated.edited_at = editedAt;
++ delete updated.edit_history;
++ if (pending) linkCards.fillLater('dm_messages', Number(messageId), pending, body);
++ if (await previewSettings.allowedIn(conversationId)) linkPreviews.schedule({ scope: 'dm', messageId: Number(messageId), text: body, onReady: async (embeds) => {
++ const participants = await getParticipants(conversationId);
++ for (const participant of participants) await require('./chat-server').sendDm(participant.id, { type: 'dm_embeds', conversation_id: Number(conversationId), id: Number(messageId), embeds });
++ } });
++ return updated;
++}
++
/**
* Get messages in a conversation (paginated, newest first).
*/
@@@ -260,6 +294,7 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
// A preview is shown only while everyone in the conversation has the switch on: turning it off hides what an
// earlier message already had (server/chat/dm-preview-settings.js).
await linkPreviews.attach(messages, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
++ for (const message of messages) delete message.edit_history;
return messages;
}
@@@ -276,6 +311,7 @@ async function getMessagesPage(conversationId, { before = null, limit = 50 } = {
rows.length = Math.min(rows.length, lim);
await linkCards.hydrate(rows, 'dm_messages');
await linkPreviews.attach(rows, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
++ for (const row of rows) delete row.edit_history;
return { messages: rows, more };
}
@@@ -489,6 +525,7 @@ module.exports = {
getParticipants,
getConversation,
sendMessage,
++ editMessage,
getMessages, getMessagesPage,
markRead,
getTotalUnread,
ddiff --git a/server/chat/history-store.js b/server/chat/history-store.js
iindex ec72348..305ac84 100644
---- a/server/chat/history-store.js
++++ b/server/chat/history-store.js
@@@ -135,6 +135,7 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
await linkCards.hydrate(messages, 'chat_messages');
await linkPreviews.attach(messages, 'chat');
++ for (const message of messages) delete message.edit_history;
return { messages, latest_id: latestIdOf(rows), more: page.more };
}
@@@ -153,6 +154,7 @@ async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
await linkCards.hydrate(messages, 'chat_messages');
await linkPreviews.attach(messages, 'chat');
++ for (const message of messages) delete message.edit_history;
return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: await deletedIds(room, after) };
}
ddiff --git a/server/chat/link-cards.js b/server/chat/link-cards.js
iindex 92b795d..85c9fd7 100644
---- a/server/chat/link-cards.js
++++ b/server/chat/link-cards.js
@@@ -259,10 +259,12 @@ async function forSend(text, budgetMs = SEND_BUDGET_MS) {
}
/** Write a card that arrived after its message went out into that row. Never awaited by the request. */
--function fillLater(table, id, pending) {
++function fillLater(table, id, pending, expectedText = null) {
if (!TABLES.has(table) || !id || !pending) return;
pending.then((card) => {
-- if (card) return db.run(`UPDATE ${table} SET card = ? WHERE id = ?`, [toJson(card), id]);
++ if (card) return expectedText == null
++ ? db.run(`UPDATE ${table} SET card = ? WHERE id = ?`, [toJson(card), id])
++ : db.run(`UPDATE ${table} SET card = ? WHERE id = ? AND message = ?`, [toJson(card), id, expectedText]);
return null;
}).catch(() => {});
}
ddiff --git a/server/chat/link-previews.js b/server/chat/link-previews.js
iindex 24401b0..4d2b9b1 100644
---- a/server/chat/link-previews.js
++++ b/server/chat/link-previews.js
@@@ -570,6 +570,9 @@ async function store(job, position, preview) {
// Nothing found is nothing written (a retry never leaves a stale preview); a removed row is a tombstone the job
// must not overwrite, so a preview removed while its job was still running never comes back.
if (!preview) return;
++ const table = job.scope === 'room' ? 'room_messages' : job.scope === 'dm' ? 'dm_messages' : 'chat_messages';
++ const current = await db.get(`SELECT message FROM ${table} WHERE id = ?`, [job.messageId]);
++ if (!current || addresses(current.message)[position] !== job.urls[position]) return;
await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (message_scope, message_id, position)
DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data
@@@ -577,6 +580,11 @@ async function store(job, position, preview) {
[job.scope, job.messageId, position, preview.url, JSON.stringify(preview)]);
}
++async function replaceForEdit(scope, messageId) {
++ if (!['room', 'dm', 'chat'].includes(scope)) return;
++ await db.run('DELETE FROM link_previews WHERE message_scope = ? AND message_id = ?', [scope, Number(messageId)]);
++}
++
async function announce(job) {
const embeds = await embedsOf(job.scope, job.messageId);
if (typeof job.onReady === 'function') {
@@@ -669,7 +677,7 @@ async function attach(rows, scope = 'room', { enabled = true } = {}) {
module.exports = {
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
-- previewOf, schedule, embedsOf, attach, remove, idle, ownLink,
++ previewOf, schedule, embedsOf, attach, remove, replaceForEdit, idle, ownLink,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
ddiff --git a/server/chat/message-edits.js b/server/chat/message-edits.js
nnew file mode 100644
iindex 0000000..54a11dc
---- /dev/null
++++ b/server/chat/message-edits.js
@@@ -0,0 +1,39 @@
++'use strict';
++const db = require('../db/database');
++const linkCards = require('./link-cards');
++const linkPreviews = require('./link-previews');
++const wordFilter = require('./word-filter');
++const outbox = require('../events/service-outbox');
++const history = require('./history-store');
++const WINDOW_MS = 24 * 60 * 60 * 1000;
++const millis = (value) => Date.parse(String(value || '').includes('T') ? value : `${String(value || '').replace(' ', 'T')}Z`);
++function error(status, message) { const e = new Error(message); e.status = status; return e; }
++async function edit(id, user, text, { now = Date.now() } = {}) {
++ const row = await db.get('SELECT * FROM chat_messages WHERE id = ?', [id]);
++ if (!row || row.is_deleted || row.message_type !== 'chat' || (row.auto_delete_at && Date.parse(row.auto_delete_at) <= now)) throw error(404, 'No such message');
++ if (!user || Number(row.user_id) !== Number(user.id)) throw error(403, 'Only the author can edit this');
++ if (!Number.isFinite(millis(row.timestamp)) || now - millis(row.timestamp) > WINDOW_MS) throw error(403, 'Messages can be edited for 24 hours');
++ const body = String(text || '').replace(/\r\n/g, '\n').trim();
++ if (!body || body.length > 6000) throw error(422, 'Write a message of at most 6000 characters');
++ const filtered = wordFilter.check(body);
++ const message = filtered.safe ? body : filtered.filtered;
++ if (wordFilter.isSpam(message)) throw error(422, 'Message blocked: detected as spam');
++ const { card, pending } = await linkCards.forSend(message);
++ const editedAt = new Date(now).toISOString();
++ await db.tx(async () => {
++ const changed = await db.run(`UPDATE chat_messages SET edit_history = edit_history || jsonb_build_array(jsonb_build_object('message', message, 'edited_at', COALESCE(edited_at, timestamp))), message = ?, card = ?, edited_at = ? WHERE id = ? AND is_deleted = 0`, [message, linkCards.toJson(card), editedAt, id]);
++ if (!changed.changes) throw error(404, 'No such message');
++ await linkPreviews.replaceForEdit('chat', id);
++ await outbox.emit({ event_type: 'chat.message.edited', visibility: 'public', actorSubject: row.subject_id || await db.subjectFor(user.id), subject: { type: 'chat_message', id: String(id) }, payload: { message_id: Number(id), room: row.stream_id || row.channel_user_id ? { type: 'channel', channel_user_id: row.channel_user_id || null, stream_id: row.stream_id || null } : { type: 'global' }, user_subject: row.subject_id || await db.subjectFor(user.id), text: message.slice(0, 2000), edited_at: editedAt, card: card || null } });
++ });
++ history.invalidate();
++ if (pending) linkCards.fillLater('chat_messages', Number(id), pending, message);
++ linkPreviews.schedule({ scope: 'chat', messageId: Number(id), text: message, onReady: async (embeds) => {
++ const server = require('./chat-server');
++ const frame = { type: 'chat_embeds', id: Number(id), embeds };
++ if (row.stream_id || row.channel_user_id) await server.broadcastToChannelRoom(row.channel_user_id, row.stream_id, frame);
++ await server.broadcastGlobal(frame);
++ } });
++ return { id: Number(id), message, edited_at: editedAt, card, stream_id: row.stream_id || null, channel_user_id: row.channel_user_id || null, is_global: !!row.is_global, timestamp: row.timestamp, user_id: row.user_id };
++}
++module.exports = { edit, WINDOW_MS };
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex f40a660..c14ab05 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -22,6 +22,7 @@ const linkCards = require('./link-cards');
const networkBlocks = require('./network-blocks');
const { limits } = require('../net/actor-limits');
const { positiveInt } = require('./route-params');
++const messageEdits = require('./message-edits');
/**
* A signed-in reader does not get the lines of people they blocked on the network (one-way; only
@@@ -68,7 +69,7 @@ function csvCell(v) {
// Chat-internal columns (the author's Network subject) are not part of Live's API.
function publicRows(rows) {
-- if (Array.isArray(rows)) for (const r of rows) { if (r) delete r.subject_id; }
++ if (Array.isArray(rows)) for (const r of rows) { if (r) { delete r.subject_id; delete r.edit_history; } }
return rows;
}
@@@ -281,6 +282,29 @@ async function hydrateReplies(messages) {
// The browser posts over /ws/chat and presents its token in the join (the upgrade itself carries none).
// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300
// an hour), refusals included; the word and spam filters below still decide each line.
++router.patch('/messages/:id', requireAuth, limits('chat.message.edit', { minute: 30, hour: 300 }), async (req, res) => {
++ const id = Number(req.params.id);
++ if (!Number.isSafeInteger(id) || id < 1) return res.status(404).json({ error: 'No such message' });
++ try {
++ const message = await messageEdits.edit(id, req.user, req.body && req.body.message);
++ const frame = { type: 'chat_edited', id, message };
++ const chatServer = require('./chat-server');
++ if (message.channel_user_id || message.stream_id) await chatServer.broadcastToChannelRoom(message.channel_user_id, message.stream_id, frame);
++ await chatServer.broadcastGlobal(frame);
++ return res.json({ message });
++ } catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to edit message' }); }
++});
++
++router.get('/messages/:id/edits', requireAuth, async (req, res) => {
++ const id = Number(req.params.id);
++ if (!Number.isSafeInteger(id) || id < 1) return res.status(404).json({ error: 'No such message' });
++ const row = await db.get('SELECT channel_user_id, stream_id, edit_history FROM chat_messages WHERE id = ?', [id]);
++ if (!row) return res.status(404).json({ error: 'No such message' });
++ const allowed = row.channel_user_id ? await permissions.canModerateChannel(req.user, row.channel_user_id) : row.stream_id ? await permissions.canModerateStream(req.user, row.stream_id) : permissions.isStaff(req.user);
++ if (!allowed) return res.status(403).json({ error: 'Moderators only' });
++ return res.set('Cache-Control', 'private, no-store').json({ versions: row.edit_history || [] });
++});
++
router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
try {
const text = (req.body.message || '').trim();
@@@ -375,7 +399,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
card,
});
savedId = result.lastInsertRowid;
-- if (pending && savedId) linkCards.fillLater('chat_messages', Number(savedId), pending);
++ if (pending && savedId) linkCards.fillLater('chat_messages', Number(savedId), pending, filtered);
} catch { /* non-critical */ }
// Attach message ID and reply context to broadcast
ddiff --git a/server/events/service-outbox.js b/server/events/service-outbox.js
iindex 83f1373..e88753d 100644
---- a/server/events/service-outbox.js
++++ b/server/events/service-outbox.js
@@@ -11,8 +11,8 @@ const SOURCE = 'chat';
const SERVICE_ACTOR = { type: 'service', id: SOURCE };
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_TYPES = [
-- 'chat.message.created', 'chat.message.deleted', 'chat.dm.created',
-- 'chat.moderation.action', 'chat.room.message.created', 'chat.room.message.deleted',
++ 'chat.message.created', 'chat.message.edited', 'chat.message.deleted', 'chat.dm.created', 'chat.dm.edited',
++ 'chat.moderation.action', 'chat.room.message.created', 'chat.room.message.edited', 'chat.room.message.deleted',
'agent.step.started', 'agent.step.updated', 'agent.step.ended',
];
let instance = null;
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 0b6e001..da570a1 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -282,6 +282,43 @@ async function announceDeleted(room, ids) {
}
}
++async function announceEdited(room, user, id, message, editedAt, card) {
++ if (room.visibility !== 'public') return;
++ await require('../events/service-outbox').emit({
++ event_type: 'chat.room.message.edited', visibility: 'public',
++ actorSubject: user.subject_id || null,
++ subject: { type: 'chat_room_message', id: String(id) },
++ payload: { message_id: id, room: room.slug, user_subject: user.subject_id || null, text: message, edited_at: editedAt, card: card || null },
++ });
++}
++
++async function editMessage(room, user, messageId, text, { now = Date.now() } = {}) {
++ const m = await db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);
++ if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');
++ if (!user || Number(m.user_id) !== Number(user.id)) fail(403, 'rooms.not_yours', 'Only the author can edit this');
++ const sentAt = Date.parse(String(m.created_at).includes('T') ? m.created_at : `${String(m.created_at).replace(' ', 'T')}Z`);
++ if (!Number.isFinite(sentAt) || now - sentAt > 24 * 60 * 60 * 1000) fail(403, 'rooms.edit_expired', 'Messages can be edited for 24 hours');
++ const body = String(text || '').replace(/\r\n/g, '\n').trim();
++ if (!body) fail(422, 'rooms.empty', 'Write something first');
++ const ownVibe = Number(room.assistant) === 1 && Number(room.owner_id) === Number(user.id);
++ const max = ownVibe ? MAX_TEXT_VIBE : MAX_TEXT;
++ if (body.length > max) fail(422, 'rooms.too_long', `Messages are at most ${max} characters`);
++ const filtered = wordFilter.check(body);
++ const message = filtered.safe ? body : filtered.filtered;
++ const { card, pending } = await linkCards.forSend(message);
++ const editedAt = new Date(now).toISOString();
++ await db.tx(async () => {
++ const changed = await db.run(`UPDATE room_messages SET edit_history = edit_history || jsonb_build_array(jsonb_build_object('message', message, 'edited_at', COALESCE(edited_at, created_at))), message = ?, card = ?, edited_at = ? WHERE id = ? AND is_deleted = 0`, [message, linkCards.toJson(card), editedAt, m.id]);
++ if (!changed.changes) fail(404, 'rooms.no_message', 'No such message');
++ await linkPreviews.replaceForEdit('room', Number(m.id));
++ await announceEdited(room, user, Number(m.id), message, editedAt, card);
++ });
++ if (pending) linkCards.fillLater('room_messages', Number(m.id), pending, message);
++ const updated = await messageById(m.id);
++ if (room.link_previews !== false) linkPreviews.schedule({ scope: 'room', messageId: Number(m.id), text: message, room: { id: room.id, slug: room.slug } });
++ return updated;
++}
++
async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [] } = {}) {
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
@@@ -332,7 +369,7 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
return Number(r.lastInsertRowid);
});
const row = await messageById(id);
-- if (pending) linkCards.fillLater('room_messages', id, pending);
++ if (pending) linkCards.fillLater('room_messages', id, pending, message);
// General address previews are a background job: the message and its response never wait on another site.
if (room.link_previews !== false) linkPreviews.schedule({ scope: 'room', messageId: id, text: message, room: { id: room.id, slug: room.slug } });
// Activity: the people this names, and the person it answers (server/chat/activity.js). A failure here never
@@@ -371,7 +408,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE u.display_name END AS display_name,
m.agent_stream, u.profile_color, u.role AS user_role
@@@ -600,7 +637,7 @@ module.exports = {
LIMITS,
isWorkspace, isStaffRoom,
postAsAi,
-- ensureSchema, create, list, bySlug, access, join, leave, post, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
++ ensureSchema, create, list, bySlug, access, join, leave, post, editMessage, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, insightOn, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex bb10366..31125e8 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -274,6 +274,24 @@ router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res)
} catch (err) { send(res, err); }
});
++router.patch('/:slug/messages/:id', requireAuth, postLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try {
++ const message = await rooms.editMessage(room, req.user, parseInt(req.params.id, 10), req.body && req.body.message);
++ chatServer().broadcastToRoom(room.id, { type: 'room_message_edited', room: room.slug, message });
++ res.json({ message });
++ } catch (err) { send(res, err); }
++});
++
++router.get('/:slug/messages/:id/edits', requireAuth, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ const can = await rooms.access(room, req.user);
++ if (!can.moderate) return res.status(403).json({ error: 'Moderators only' });
++ const row = await db.get('SELECT edit_history FROM room_messages WHERE id = ? AND room_id = ?', [Number(req.params.id), room.id]);
++ if (!row) return res.status(404).json({ error: 'No such message' });
++ return res.set('Cache-Control', 'private, no-store').json({ versions: row.edit_history || [] });
++});
++
/**
* A preview on a room message, taken back by its author or a room moderator (server/chat/link-previews.js): with a
* position, that one address; without, every preview on the line. The row becomes a tombstone, so a job still
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex ff62991..f9c11b3 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -126,13 +126,13 @@ function globalMessage(m) {
const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');
const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${hatHtml(m)}${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.messageCards(m)}</li>`;
++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${m.edited_at ? ` <span class="oc-muted oc-edited">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ui.messageCards(m)}</li>`;
}
function dmMessage(m, me, view = 'room') {
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
-- if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}">${!mine ? `<span class="oc-bubble-author">${esc(m.display_name || m.username)}</span>` : ''}<div class="oc-bubble">${linkify(m.message)}</div>${ui.messageCards(m)}<time class="oc-bubble-time" datetime="${t.iso}">${t.text}</time></li>`;
-- return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.messageCards(m)}</li>`;
++ if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}">${!mine ? `<span class="oc-bubble-author">${esc(m.display_name || m.username)}</span>` : ''}<div class="oc-bubble">${linkify(m.message)}</div>${ui.messageCards(m)}<time class="oc-bubble-time" datetime="${t.iso}">${t.text}${m.edited_at ? ` <span class="oc-edited">· edited ${esc(hhmm(m.edited_at).text)}</span>` : ''}</time></li>`;
++ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${m.edited_at ? ` <span class="oc-muted oc-edited">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ui.messageCards(m)}</li>`;
}
// Vibe, OpenVibe's assistant, as a room's AI member (one click on the room's settings; a person's own chats with it are
// server/rooms/vibe.js): how it should be in a group.
@@@ -938,7 +938,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${linkify(m.message)}</span>`;
-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${del}</li>`;
++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex c60e800..27363ad 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -156,7 +156,7 @@ function resultChips(m, slug) {
/** What the person said, for their bubble: their words (the bubble keeps line breaks) and any picture. */
function youOf(m, me) {
const mine = me && Number(m.user_id) === Number(me.id);
-- return { html: `<span class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</span>${esc(m.message)}${pictures.pageHtml(m.message)}` };
++ return { html: `<span class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</span>${esc(m.message)}${m.edited_at ? ` <small class="vb-edited">edited · ${esc(String(m.edited_at).slice(11, 16))}</small>` : ''}${pictures.pageHtml(m.message)}${mine ? '<button type="button" class="ovk-btn ovk-btn--quiet vb-edit">Edit</button>' : ''}` };
}
/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
function answerOf(m, hows, retrySlug, slug) {
ddiff --git a/test/editable-messages.test.js b/test/editable-messages.test.js
nnew file mode 100644
iindex 0000000..1e9314d
---- /dev/null
++++ b/test/editable-messages.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++const t = suite('editable messages');
++let h, ann, bob, mod;
++const room = (method, path, user, body) => h.http(method, `/api/chat/rooms${path}`, { token: user && user.token, body });
++const dm = (method, path, user, body) => h.http(method, `/api/dm${path}`, { token: user && user.token, body });
++const chat = (method, path, user, body) => h.http(method, `/api/chat${path}`, { token: user && user.token, body });
++const millis = (value) => Date.parse(String(value).includes('T') ? value : `${String(value).replace(' ', 'T')}Z`);
++const event = async (type, id) => (await h.db.all('SELECT envelope FROM service_outbox WHERE envelope->>\'event_type\' = ?', [type])).map((r) => r.envelope).find((e) => e.payload.message_id === id);
++
++t('boot', async () => {
++ h = await boot();
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ mod = h.addUser('moderator', { subject: ids.newId('user'), role: 'global_mod' });
++ for (const u of [ann, bob, mod]) await h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('room and Vibe store: author, window, history, moderator view, socket and Events', async () => {
++ await room('POST', '/', ann, { name: 'Edit Room' });
++ const id = (await room('POST', '/edit-room/messages', ann, { message: 'first room' })).body.message.id;
++ const ws = await h.ws({ ip: '198.51.100.42', token: bob.token });
++ ws.sendJson({ type: 'join_room', room: 'edit-room', token: bob.token });
++ await ws.next((m) => m.type === 'room_joined');
++ assert.strictEqual((await room('PATCH', `/edit-room/messages/${id}`, bob, { message: 'stolen' })).status, 403);
++ const edited = await room('PATCH', `/edit-room/messages/${id}`, ann, { message: 'second room' });
++ assert.strictEqual(edited.status, 200, edited.text);
++ assert.ok(edited.body.message.edited_at);
++ assert.strictEqual((await ws.next((m) => m.type === 'room_message_edited' && m.message.id === id)).message.message, 'second room');
++ assert.strictEqual((await event('chat.room.message.edited', id)).payload.text, 'second room');
++ assert.strictEqual((await room('PATCH', `/edit-room/messages/${id}`, ann, { message: 'third room' })).status, 200);
++ assert.ok(!(await room('GET', '/edit-room/messages', bob)).body.messages[0].edit_history);
++ assert.strictEqual((await room('GET', `/edit-room/messages/${id}/edits`, bob)).status, 403);
++ assert.deepStrictEqual((await room('GET', `/edit-room/messages/${id}/edits`, mod)).body.versions.map((v) => v.message), ['first room', 'second room']);
++ const rooms = require('../server/rooms/rooms');
++ const r = await rooms.bySlug('edit-room');
++ const createdAt = (await h.db.get('SELECT created_at FROM room_messages WHERE id = ?', [id])).created_at;
++ await assert.rejects(() => rooms.editMessage(r, ann, id, 'too late', { now: millis(createdAt) + 86_400_001 }), (e) => e.status === 403);
++ ws.close();
++});
++
++t('DM store: participant delivery, history privacy and window', async () => {
++ const created = await dm('POST', '/conversations', ann, { user_ids: [bob.id] });
++ assert.strictEqual(created.status, 200, created.text);
++ const conv = created.body.conversation.id;
++ const sent = await dm('POST', `/conversations/${conv}/messages`, ann, { message: 'first dm' });
++ const id = sent.body.message.id;
++ const ws = await h.ws({ ip: '198.51.100.44', token: bob.token });
++ ws.sendJson({ type: 'join', token: bob.token });
++ await ws.next((m) => m.type === 'auth');
++ assert.strictEqual((await dm('PATCH', `/conversations/${conv}/messages/${id}`, bob, { message: 'stolen' })).status, 403);
++ const edited = await dm('PATCH', `/conversations/${conv}/messages/${id}`, ann, { message: 'second dm' });
++ assert.strictEqual(edited.status, 200, edited.text);
++ assert.strictEqual((await ws.next((m) => m.type === 'dm_edited' && m.message.id === id)).message.message, 'second dm');
++ assert.strictEqual((await event('chat.dm.edited', id)).payload.text, 'second dm');
++ const privateEvents = (await h.db.all("SELECT envelope FROM service_outbox WHERE envelope->>'event_type' = 'chat.dm.edited'")).map((r) => r.envelope).filter((e) => e.payload.message_id === id);
++ assert.deepStrictEqual(privateEvents.map((e) => e.subject.id).sort(), [ann.subject_id, bob.subject_id].sort(), 'one subject event for each participant');
++ assert.ok(privateEvents.every((e) => e.visibility === 'subject' && e.subject.type === 'user'));
++ const list = await dm('GET', `/conversations/${conv}/messages`, bob);
++ assert.strictEqual(list.body.messages[0].message, 'second dm');
++ assert.ok(!('edit_history' in list.body.messages[0]));
++ assert.strictEqual((await dm('GET', `/conversations/${conv}/messages/${id}/edits`, bob)).status, 403);
++ assert.strictEqual((await dm('GET', `/conversations/${conv}/messages/${id}/edits`, mod)).body.versions[0].message, 'first dm');
++ const createdAt = (await h.db.get('SELECT created_at FROM dm_messages WHERE id = ?', [id])).created_at;
++ await assert.rejects(() => require('../server/chat/dm').editMessage(conv, id, ann.id, 'too late', { now: millis(createdAt) + 86_400_001 }), (e) => e.status === 403);
++ ws.close();
++});
++
++t('stream chat store: author, history, edit event and window', async () => {
++ const channel = h.addChannel(ann.id);
++ const stream = h.addStream(ann.id, channel);
++ await h.ctx.sync();
++ const ws = await h.ws({ ip: '198.51.100.43', token: ann.token, stream });
++ ws.sendJson({ type: 'join', streamId: stream, token: ann.token });
++ await ws.next((m) => m.type === 'auth');
++ ws.sendJson({ type: 'chat', message: 'first stream' });
++ const msg = await ws.next((m) => m.type === 'chat' && m.message === 'first stream');
++ const id = msg.id;
++ assert.strictEqual((await chat('PATCH', `/messages/${id}`, bob, { message: 'stolen' })).status, 403);
++ const edited = await chat('PATCH', `/messages/${id}`, ann, { message: 'second stream' });
++ assert.strictEqual(edited.status, 200, edited.text);
++ assert.strictEqual((await ws.next((m) => m.type === 'chat_edited' && m.id === id)).message.message, 'second stream');
++ assert.strictEqual((await event('chat.message.edited', id)).payload.text, 'second stream');
++ const row = await h.db.get('SELECT edit_history FROM chat_messages WHERE id = ?', [id]);
++ assert.strictEqual(row.edit_history[0].message, 'first stream');
++ assert.strictEqual((await chat('GET', `/messages/${id}/edits`, bob)).status, 403);
++ assert.strictEqual((await chat('GET', `/messages/${id}/edits`, mod)).body.versions[0].message, 'first stream');
++ const timestamp = (await h.db.get('SELECT timestamp FROM chat_messages WHERE id = ?', [id])).timestamp;
++ await assert.rejects(() => require('../server/chat/message-edits').edit(id, ann, 'too late', { now: millis(timestamp) + 86_400_001 }), (e) => e.status === 403);
++ ws.close();
++});
++t.run(async () => { if (h) await h.close(); });
ddiff --git a/test/link-cards.test.js b/test/link-cards.test.js
iindex b3017b5..4351e7e 100644
---- a/test/link-cards.test.js
++++ b/test/link-cards.test.js
@@@ -220,6 +220,26 @@ t('a room message stores and draws its card too', async () => {
assert.ok(html.text.includes('Watch together'));
});
++t('editing a link refreshes the current card in rooms, DMs and chat', async () => {
++ const first = 'https://openvibe.video/v/123';
++ const next = 'https://openvibe.video/v/456';
++ const roomSent = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: ann.token, body: { message: first } });
++ const roomEdit = await h.http('PATCH', `/api/chat/rooms/${room.slug}/messages/${roomSent.body.message.id}`, { token: ann.token, body: { message: next } });
++ assert.strictEqual(roomEdit.status, 200, roomEdit.text);
++ assert.strictEqual(roomEdit.body.message.card.url, next);
++ const dmSent = await dmSend(ann, first);
++ const dmEdit = await h.http('PATCH', `/api/dm/conversations/${conv}/messages/${dmSent.body.message.id}`, { token: ann.token, body: { message: next } });
++ assert.strictEqual(dmEdit.status, 200, dmEdit.text);
++ assert.strictEqual(dmEdit.body.message.card.url, next);
++ await globalSend(ann, first);
++ const global = (await h.http('GET', '/api/chat/global/history?limit=50', { token: ann.token })).body.messages.find((m) => m.message === first);
++ const globalEdit = await h.http('PATCH', `/api/chat/messages/${global.id}`, { token: ann.token, body: { message: next } });
++ assert.strictEqual(globalEdit.status, 200, globalEdit.text);
++ assert.strictEqual(globalEdit.body.message.card.url, next);
++ const cleared = await h.http('PATCH', `/api/chat/messages/${global.id}`, { token: ann.token, body: { message: 'no link now' } });
++ assert.strictEqual(cleared.body.message.card, null);
++});
++
t('global chat stores and draws its card, and the card is one per message', async () => {
const text = 'https://openvibe.video/v/456';
assert.strictEqual((await globalSend(ann, text)).status, 200);
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 009e0fd..7863f6c 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -395,6 +395,10 @@ t('a token in the socket address is not read, and the socket says how to send it
});
t('upgrade guards: foreign origin refused, banned address refused (admins exempt)', async () => {
++ const dock = await h.ws({ origin: 'https://openvibe.video' });
++ dock.sendJson({ type: 'join', token: alice.token });
++ assert.strictEqual((await dock.next((m) => m.type === 'auth')).authenticated, true, 'the shared Dock connects from a first-party site');
++ dock.close();
assert.strictEqual(await h.wsRefused({ origin: 'https://evil.example' }), true);
h.live.addBan({ ip_address: '192.0.2.0/24' });
await h.ctx.invalidateBans();