openvibers/openvibe.chat Commits
Chat: edit messages for 24 hours across stores and clients (#512)
Changes
@@@ -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 -->nnew file mode 100644@@@ -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;@@@ -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",@@@ -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"},@@@ -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) {@@@ -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);@@@ -419,7 +419,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinasync 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;@@@ -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) {@@@ -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;@@@ -205,7 +205,7 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = nullif (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 = nullreturn 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,@@@ -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) };}@@@ -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(() => {});}@@@ -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; },nnew file mode 100644@@@ -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 };@@@ -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, hourcard,});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@@@ -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;@@@ -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,};@@@ -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@@@ -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);@@@ -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) {nnew file mode 100644@@@ -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(); });@@@ -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);@@@ -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();
@@ -983,5 +983,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -983,5 +983,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- 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 -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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;
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"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.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1682,9 +1682,9 @@
@@ -1682,9 +1682,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",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"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.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -198,12 +198,53 @@
@@ -198,12 +198,53 @@
}
}
// A preview that landed after its message: replace the line's cards with the finished list.
// A preview that landed after its message: replace the line's cards with the finished list.
function updateEmbeds(id, embeds) {
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) + '"]');
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
if (!li) return;
Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
addCard(li, { embeds: embeds });
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.
// 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'] };
var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
@@ -214,7 +255,7 @@
@@ -214,7 +255,7 @@
if (id && feed.querySelector('[data-id="' + id + '"]')) return;
if (id && feed.querySelector('[data-id="' + id + '"]')) return;
var stick = nearBottom();
var stick = nearBottom();
removeEmpty();
removeEmpty();
- feed.appendChild(li);
+ addEditButton(li); feed.appendChild(li);
while (feed.children.length > 300) {
while (feed.children.length > 300) {
var oldest = feed.querySelector('[data-id]');
var oldest = feed.querySelector('[data-id]');
if (!oldest) break;
if (!oldest) break;
@@ -254,7 +295,7 @@
@@ -254,7 +295,7 @@
// ── Global chat ──
// ── Global chat ──
function globalItem(m) {
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(' '));
li.appendChild(clock(m.timestamp)); li.appendChild(document.createTextNode(' '));
var handle = m.core_username || (m.user_id ? m.username : null);
var handle = m.core_username || (m.user_id ? m.username : null);
var name = m.display_name || m.username || m.anon_id || 'someone';
var name = m.display_name || m.username || m.anon_id || 'someone';
@@ -265,6 +306,7 @@
@@ -265,6 +306,7 @@
var b = badge(m.role); if (b) li.appendChild(b);
var b = badge(m.role); if (b) li.appendChild(b);
li.appendChild(document.createTextNode(' '));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
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);
addCard(li, m);
return li;
return li;
}
}
@@ -320,6 +362,8 @@
@@ -320,6 +362,8 @@
append(globalItem(m), m.id);
append(globalItem(m), m.id);
} else if (m.type === 'chat_embeds' && m.id) {
} else if (m.type === 'chat_embeds' && m.id) {
updateEmbeds(m.id, m.embeds);
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)) {
} else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {
removeIds(m.ids || m.message_ids);
removeIds(m.ids || m.message_ids);
} else if (m.type === 'error' && m.message) {
} else if (m.type === 'error' && m.message) {
@@ -354,11 +398,19 @@
@@ -354,11 +398,19 @@
li.appendChild(el('span', 'oc-name', m.display_name || m.username));
li.appendChild(el('span', 'oc-name', m.display_name || m.username));
li.appendChild(document.createTextNode(' '));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
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);
addCard(li, m);
return li;
return li;
}
}
function startDm() {
function startDm() {
var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
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) {
window.OvChatHistory.attach(feed, document.getElementById('oc-history-state'), function (before) {
return fetch(base + '/messages?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
return fetch(base + '/messages?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; });
.then(function (r) { return r.ok ? r.json() : null; });
@@ -437,6 +489,8 @@
@@ -437,6 +489,8 @@
if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }
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); }
else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
li.appendChild(t);
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); }
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
addCard(li, m);
addCard(li, m);
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
@@ -522,6 +576,8 @@
@@ -522,6 +576,8 @@
showThinking(m.name);
showThinking(m.name);
} else if (m.type === 'room_message_embeds' && m.room === cfg.room) {
} else if (m.type === 'room_message_embeds' && m.room === cfg.room) {
updateEmbeds(m.id, m.embeds);
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) {
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
} else if (m.type === 'room_error' && m.message) {
} else if (m.type === 'room_error' && m.message) {
@@ -287,6 +287,7 @@
@@ -287,6 +287,7 @@
b.appendChild(document.createTextNode(String(text)));
b.appendChild(document.createTextNode(String(text)));
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
you.appendChild(b); li.appendChild(you);
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;
return li;
}
}
// The answer side of a turn: Vibe's mark, then its reply.
// The answer side of a turn: Vibe's mark, then its reply.
@@ -499,6 +500,29 @@
@@ -499,6 +500,29 @@
}
}
if (stick) toBottom();
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 ─────────────────────────────────────────────
// ── 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_…
// 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 @@
@@ -656,6 +680,7 @@
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === 'room_joined') catchUp();
if (m.type === 'room_joined') catchUp();
else if (m.type === 'room_message' && m.room === room) add(m.message);
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_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_ai_delta' && m.room === room) streamText(m.text);
else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
@@ -419,7 +419,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -419,7 +419,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
async function handleUpgrade(req, socket, head) {
async function handleUpgrade(req, socket, head) {
const url = req.url || '';
const url = req.url || '';
const origin = normalizeOrigin(req.headers.origin);
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`);
console.warn(`[Server] WebSocket upgrade rejected — origin "${origin}" not in allowed origins`);
socket.destroy();
socket.destroy();
return;
return;
@@ -1189,7 +1189,7 @@ class ChatServer {
@@ -1189,7 +1189,7 @@ class ChatServer {
card,
card,
});
});
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
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
// 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.
// carries embeds: [] and a later frame carries the filled list. Channel/global viewers get the same frame.
if (result.lastInsertRowid) {
if (result.lastInsertRowid) {
@@ -36,7 +36,7 @@ async function dmSettingsOf(target) {
@@ -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.
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
function publicMessage(m) {
function publicMessage(m) {
- if (m) delete m.sender_subject_id;
+ if (m) { delete m.sender_subject_id; delete m.edit_history; }
return m;
return m;
}
}
@@ -564,4 +564,23 @@ router.delete('/conversations/:id/messages/:msgId', deleteLimit, async (req, res
@@ -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;
module.exports = router;
@@ -205,7 +205,7 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
@@ -205,7 +205,7 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
if (row) {
if (row) {
row.card = card;
row.card = card;
row.embeds = []; // the job fills them; the send answer starts empty, as a room's does
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
// 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).
// the conversation left link previews on (part 4 privacy: one side off fetches nothing).
if (await previewSettings.allowedIn(conversationId)) {
if (await previewSettings.allowedIn(conversationId)) {
@@ -222,6 +222,40 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
@@ -222,6 +222,40 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
return row;
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).
* Get messages in a conversation (paginated, newest first).
*/
*/
@@ -260,6 +294,7 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
@@ -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
// 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).
// earlier message already had (server/chat/dm-preview-settings.js).
await linkPreviews.attach(messages, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
await linkPreviews.attach(messages, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
+ for (const message of messages) delete message.edit_history;
return messages;
return messages;
}
}
@@ -276,6 +311,7 @@ async function getMessagesPage(conversationId, { before = null, limit = 50 } = {
@@ -276,6 +311,7 @@ async function getMessagesPage(conversationId, { before = null, limit = 50 } = {
rows.length = Math.min(rows.length, lim);
rows.length = Math.min(rows.length, lim);
await linkCards.hydrate(rows, 'dm_messages');
await linkCards.hydrate(rows, 'dm_messages');
await linkPreviews.attach(rows, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
await linkPreviews.attach(rows, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
+ for (const row of rows) delete row.edit_history;
return { messages: rows, more };
return { messages: rows, more };
}
}
@@ -489,6 +525,7 @@ module.exports = {
@@ -489,6 +525,7 @@ module.exports = {
getParticipants,
getParticipants,
getConversation,
getConversation,
sendMessage,
sendMessage,
+ editMessage,
getMessages, getMessagesPage,
getMessages, getMessagesPage,
markRead,
markRead,
getTotalUnread,
getTotalUnread,
@@ -135,6 +135,7 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
@@ -135,6 +135,7 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
await linkCards.hydrate(messages, 'chat_messages');
await linkCards.hydrate(messages, 'chat_messages');
await linkPreviews.attach(messages, 'chat');
await linkPreviews.attach(messages, 'chat');
+ for (const message of messages) delete message.edit_history;
return { messages, latest_id: latestIdOf(rows), more: page.more };
return { messages, latest_id: latestIdOf(rows), more: page.more };
}
}
@@ -153,6 +154,7 @@ async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
@@ -153,6 +154,7 @@ async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
await linkCards.hydrate(messages, 'chat_messages');
await linkCards.hydrate(messages, 'chat_messages');
await linkPreviews.attach(messages, 'chat');
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) };
return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: await deletedIds(room, after) };
}
}
@@ -259,10 +259,12 @@ async function forSend(text, budgetMs = SEND_BUDGET_MS) {
@@ -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. */
/** 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;
if (!TABLES.has(table) || !id || !pending) return;
pending.then((card) => {
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;
return null;
}).catch(() => {});
}).catch(() => {});
}
}
@@ -570,6 +570,9 @@ async function store(job, position, preview) {
@@ -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
// 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.
// must not overwrite, so a preview removed while its job was still running never comes back.
if (!preview) return;
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 (?, ?, ?, ?, ?)
await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (message_scope, message_id, position)
ON CONFLICT (message_scope, message_id, position)
DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data
DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data
@@ -577,6 +580,11 @@ async function store(job, position, preview) {
@@ -577,6 +580,11 @@ async function store(job, position, preview) {
[job.scope, job.messageId, position, preview.url, JSON.stringify(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) {
async function announce(job) {
const embeds = await embedsOf(job.scope, job.messageId);
const embeds = await embedsOf(job.scope, job.messageId);
if (typeof job.onReady === 'function') {
if (typeof job.onReady === 'function') {
@@ -669,7 +677,7 @@ async function attach(rows, scope = 'room', { enabled = true } = {}) {
@@ -669,7 +677,7 @@ async function attach(rows, scope = 'room', { enabled = true } = {}) {
module.exports = {
module.exports = {
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
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,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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 };
@@ -22,6 +22,7 @@ const linkCards = require('./link-cards');
@@ -22,6 +22,7 @@ const linkCards = require('./link-cards');
const networkBlocks = require('./network-blocks');
const networkBlocks = require('./network-blocks');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const { positiveInt } = require('./route-params');
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
* 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) {
@@ -68,7 +69,7 @@ function csvCell(v) {
// Chat-internal columns (the author's Network subject) are not part of Live's API.
// Chat-internal columns (the author's Network subject) are not part of Live's API.
function publicRows(rows) {
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;
return rows;
}
}
@@ -281,6 +282,29 @@ async function hydrateReplies(messages) {
@@ -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).
// 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
// 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.
// 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) => {
router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
try {
try {
const text = (req.body.message || '').trim();
const text = (req.body.message || '').trim();
@@ -375,7 +399,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -375,7 +399,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
card,
card,
});
});
savedId = result.lastInsertRowid;
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 */ }
} catch { /* non-critical */ }
// Attach message ID and reply context to broadcast
// Attach message ID and reply context to broadcast
@@ -11,8 +11,8 @@ const SOURCE = 'chat';
@@ -11,8 +11,8 @@ const SOURCE = 'chat';
const SERVICE_ACTOR = { type: 'service', id: SOURCE };
const SERVICE_ACTOR = { type: 'service', id: SOURCE };
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_TYPES = [
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',
'agent.step.started', 'agent.step.updated', 'agent.step.ended',
];
];
let instance = null;
let instance = null;
@@ -282,6 +282,43 @@ async function announceDeleted(room, ids) {
@@ -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 = [] } = {}) {
async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [] } = {}) {
const a = await access(room, user);
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
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 =
@@ -332,7 +369,7 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
const row = await messageById(id);
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.
// 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 } });
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
// 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 } = {
@@ -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.
// 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.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,
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
m.agent_stream, u.profile_color, u.role AS user_role
@@ -600,7 +637,7 @@ module.exports = {
@@ -600,7 +637,7 @@ module.exports = {
LIMITS,
LIMITS,
isWorkspace, isStaffRoom,
isWorkspace, isStaffRoom,
postAsAi,
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,
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,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
};
@@ -274,6 +274,24 @@ router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res)
@@ -274,6 +274,24 @@ router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res)
} catch (err) { send(res, err); }
} 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
* 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
* position, that one address; without, every preview on the line. The row becomes a tombstone, so a job still
@@ -126,13 +126,13 @@ function globalMessage(m) {
@@ -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 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 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>` : '';
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') {
function dmMessage(m, me, view = 'room') {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
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
// 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.
// 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
@@ -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)}`;
: `<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.
// 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>`;
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);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -156,7 +156,7 @@ function resultChips(m, slug) {
@@ -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. */
/** What the person said, for their bubble: their words (the bubble keeps line breaks) and any picture. */
function youOf(m, me) {
function youOf(m, me) {
const mine = me && Number(m.user_id) === Number(me.id);
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. */
/** 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) {
function answerOf(m, hows, retrySlug, slug) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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(); });
@@ -220,6 +220,26 @@ t('a room message stores and draws its card too', async () => {
@@ -220,6 +220,26 @@ t('a room message stores and draws its card too', async () => {
assert.ok(html.text.includes('Watch together'));
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 () => {
t('global chat stores and draws its card, and the card is one per message', async () => {
const text = 'https://openvibe.video/v/456';
const text = 'https://openvibe.video/v/456';
assert.strictEqual((await globalSend(ann, text)).status, 200);
assert.strictEqual((await globalSend(ann, text)).status, 200);
@@ -395,6 +395,10 @@ t('a token in the socket address is not read, and the socket says how to send it
@@ -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 () => {
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);
assert.strictEqual(await h.wsRefused({ origin: 'https://evil.example' }), true);
h.live.addBan({ ip_address: '192.0.2.0/24' });
h.live.addBan({ ip_address: '192.0.2.0/24' });
await h.ctx.invalidateBans();
await h.ctx.invalidateBans();