openvibers/openvibe.chat Commits

Chat: a message's actions in one cluster, editing in the box (#540)

OP OpenVibers committed 1 days ago · 83e100f422 · 12 files, +352 −69

* Chat: a message's actions in one cluster, editing in the box

* openvibe-shared v4.218.0 (the composer and inline comment release)

* Earlier versions sheet: its list can shrink on a phone

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex c9473db..71710c7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.150.0",
-- "shared": "openvibe-shared v4.215.0",
++ "shared": "openvibe-shared v4.218.0",
"capabilities": [
"chat.message.send",
"chat.event.publish",
ddiff --git a/package-lock.json b/package-lock.json
iindex 2490158..186364a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.218.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
@@@ -1957,9 +1957,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.215.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
-- "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",
++ "version": "4.218.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.218.0",
++ "integrity": "sha512-nJ4hp5puM5H0gU5PdsnSJBpVtxp8TuPxxmY+JHEH9bmxvx0NosDDiNgee4uxZn+ACbJe2y//rRj8TwuSEZfMBw==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 35da612..ddff0fb 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.218.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex c10c257..9319fe2 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -835,3 +835,21 @@ div.oc-set-row { cursor: default; }
.oc-emoji-report { grid-column: 1 / -1; justify-self: start; margin-top: 4px; font-size: .8rem; color: var(--oc-muted); }
.oc-emoji-none { grid-column: 1 / -1; margin: 6px 4px; color: var(--oc-muted); font-size: .88rem; }
@media (min-width: 761px) { .oc-command-menu { right: auto; width: min(420px, 100%); } }
++
++/* A message's actions ride the shared messages.css cluster (openvibe.design rule 138). The box's editing bar and
++ the moderators' Earlier versions sheet are the same on every view. */
++.oc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--oc-accent) 14%, transparent); font-size: 13px; line-height: 1.3; }
++.oc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--oc-accent); }
++.oc-bar > b { flex: none; font-weight: 700; color: var(--oc-text); }
++.oc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--oc-muted); }
++.oc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--oc-muted); cursor: pointer; font-size: 18px; }
++.oc-bar-x:hover, .oc-bar-x:focus-visible { background: color-mix(in srgb, var(--oc-text) 10%, transparent); color: var(--oc-text); }
++.oc-edits-sheet { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); color: var(--oc-text); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
++.oc-edits-sheet::backdrop { background: rgba(2, 6, 14, .55); }
++.oc-edits-sheet header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 8px; }
++.oc-edits-sheet header button { width: 36px; height: 36px; border: 0; border-radius: 50%; background: transparent; color: var(--oc-muted); font-size: 22px; cursor: pointer; }
++.oc-edits-sheet header button:hover { background: var(--oc-panel-2); color: var(--oc-text); }
++.oc-edits-sheet ol { list-style: none; margin: 0; padding: 0 16px 16px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; overflow: auto; }
++.oc-edits-sheet li { padding: 10px 12px; border-radius: 12px; background: var(--oc-panel-2); }
++.oc-edits-sheet time { display: block; font-size: 12px; color: var(--oc-muted); }
++.oc-edits-sheet p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex e081321..717f3e6 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -314,47 +314,155 @@
}
function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }
++ // A message's text element for an edit or an "edited" mark (a room line, a DM line, a bubble).
++ function rowText(li) { return li && li.querySelector('.oc-text, .oc-bubble'); }
++ function rowMine(li) {
++ if (!li || !cfg.me) return false;
++ if (cfg.view === 'dm') return li.classList.contains('oc-mine') || li.classList.contains('oc-bubble-mine') || Number(li.dataset.user) === Number(cfg.me);
++ return Number(li.dataset.user) === Number(cfg.me);
++ }
++ function rowMessage(li) {
++ var mark = li && li.querySelector('.oc-edited');
++ var t = rowText(li);
++ return {
++ id: li ? Number(li.dataset.id) || 0 : 0,
++ user_id: li ? Number(li.dataset.user) || 0 : 0,
++ message: t ? String(t.textContent || '').trim() : '',
++ edited_at: mark ? mark.getAttribute('data-edited-at') || null : null,
++ };
++ }
++ // openvibe.design rule 138: one cluster at the row's corner, never in its words (shared messages.js).
++ function offerActions(li, m, mine) {
++ if (!li || !m || !m.id || !window.OVMessages || !mine) return;
++ var ids = ['edit'];
++ if (cfg.view === 'room' || cfg.view === 'dm') ids.push('delete');
++ var old = li.querySelector('.oc-del');
++ if (old) old.hidden = true;
++ window.OVMessages.actions(li, ids, { message: m, mine: true, onAction: onMessageAction });
++ li.__ovm = m;
++ }
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');
++ var t = rowText(li);
if (t) { t.textContent = ''; if (cfg.view === 'room') textWithRoomEmoji(t, m.message); else 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-edited, .ovm-edited'), function (x) { x.remove(); });
++ if (t && m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
++ var mm = { id: Number(m.id) || Number(id), user_id: Number(m.user_id) || 0, message: String(m.message || ''), edited_at: m.edited_at || null };
++ li.__ovm = mm;
++ if (rowMine(li)) offerActions(li, mm, true);
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);
++ function editBase() {
++ if (cfg.view === 'dm') return '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
++ if (cfg.view === 'room') return '/api/chat/rooms/' + encodeURIComponent(cfg.room);
++ return '/api/chat';
++ }
++ function saveEdit(m, body) {
++ if (!m || !m.id) return Promise.resolve(false);
++ return fetch(editBase() + '/messages/' + encodeURIComponent(m.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(m.id, d.message); return true; });
++ }).catch(function (err) { showError(err.message); return false; });
++ }
++ // A view whose box is not on mount() gets the same bar and keys in plain markup (Vibe has its own).
++ function plainEdit(li, m) {
++ if (!form || !input) return;
++ var bar = form.querySelector('.oc-edit-bar');
++ if (!bar) {
++ bar = el('div', 'oc-bar oc-edit-bar');
++ bar.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4z"/></svg><b>Editing your message</b><span>Enter saves · Esc cancels</span>';
++ var x = el('button', 'oc-bar-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Stop editing');
++ x.addEventListener('click', function () { stopPlainEdit(); });
++ bar.appendChild(x); form.insertBefore(bar, form.firstChild);
++ }
++ var draft = input.value;
++ function stopPlainEdit() { if (bar) bar.remove(); input.value = draft; input.dispatchEvent(new Event('input', { bubbles: true })); form.__plainEdit = null; input.focus(); }
++ function keydown(e) {
++ if (!form.__plainEdit) return;
++ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPlainEdit(); }
++ else if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); e.stopPropagation(); saveEdit(m, input.value.trim()).then(function (ok) { if (ok) stopPlainEdit(); }); }
++ }
++ input.value = m.message; input.focus(); input.setSelectionRange(input.value.length, input.value.length);
++ form.__plainEdit = { stop: stopPlainEdit };
++ input.addEventListener('keydown', keydown, true);
++ var oldStop = stopPlainEdit;
++ stopPlainEdit = function () { input.removeEventListener('keydown', keydown, true); oldStop(); };
++ }
++ function startEdit(li) {
++ var m = li && (li.__ovm || rowMessage(li));
++ if (!m || !m.id) return;
++ var c = form && form.__ovcComposer;
++ if (c && typeof c.edit === 'function') {
++ c.edit({ text: m.message, onSave: function (next) { return saveEdit(m, next); } });
++ return;
++ }
++ plainEdit(li, m);
++ }
++ function deleteRow(li, m) {
++ var f = li && li.querySelector('.oc-del form');
++ if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); return; }
++ if (cfg.view === 'dm') {
++ fetch('/api/dm/conversations/' + encodeURIComponent(cfg.conversation) + '/messages/' + encodeURIComponent(m.id), { method: 'DELETE', credentials: 'same-origin' })
++ .then(function (r) { if (!r.ok) throw Error('Could not delete this message'); li.remove(); })
++ .catch(function (err) { showError(err.message); });
++ return;
++ }
++ showError('This message cannot be deleted here.');
++ }
++ function onMessageAction(action, m, li) {
++ if (action === 'edit') startEdit(li);
++ else if (action === 'delete') deleteRow(li, m);
++ }
++ // The server draws the first page: give its rows the same cluster and "edited" word as a row that arrives live.
++ function hydrateFeed() {
++ if (!feed || !window.OVMessages) return;
++ if (typeof window.OVMessages.tapToAct === 'function') window.OVMessages.tapToAct(feed);
++ Array.prototype.forEach.call(feed.querySelectorAll('[data-id]'), function (li) {
++ if (li.__ovmHydrated) return; li.__ovmHydrated = true;
++ var m = rowMessage(li); if (!m.id) return;
++ var mark = li.querySelector('.oc-edited'), t = rowText(li);
++ if (mark) {
++ var at = mark.getAttribute('data-edited-at');
++ mark.remove();
++ if (at && t) window.OVMessages.edited(t, at);
++ }
++ if (rowMine(li)) { m.user_id = Number(cfg.me); offerActions(li, m, true); }
++ });
++ }
++ // The moderators' Earlier versions, in a small sheet (never window.alert).
++ function showEditions(versions) {
++ var d = document.createElement('dialog'); d.className = 'oc-edits-sheet'; d.setAttribute('aria-label', 'Earlier versions');
++ var head = document.createElement('header');
++ var h = document.createElement('b'); h.textContent = 'Earlier versions'; head.appendChild(h);
++ var x = document.createElement('button'); x.type = 'button'; x.setAttribute('aria-label', 'Close'); x.textContent = '×';
++ x.addEventListener('click', function () { d.close(); }); head.appendChild(x);
++ var list = document.createElement('ol');
++ (Array.isArray(versions) ? versions : []).forEach(function (v) {
++ var li = document.createElement('li'); var time = document.createElement('time');
++ var at = v && v.edited_at ? new Date(String(v.edited_at).replace(' ', 'T') + (/[zZ]|[+-]\d{2}:?\d{2}$/.test(String(v.edited_at)) ? '' : 'Z')) : null;
++ time.textContent = at && !isNaN(at) ? at.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : '';
++ var p = document.createElement('p'); p.textContent = String(v && v.message || '');
++ li.append(time, p); list.appendChild(li);
++ });
++ if (!list.children.length) { var p = document.createElement('p'); p.textContent = 'No earlier versions.'; list.appendChild(p); }
++ d.append(head, list);
++ d.addEventListener('click', function (e) { if (e.target === d) d.close(); });
++ document.body.appendChild(d);
++ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
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;
++ var earlier = e.target.closest && e.target.closest('.oc-edits');
++ if (!earlier || !cfg.room) return;
++ var li = earlier.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')); })
++ .then(function (d) { showEditions(d && d.versions); })
.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'] };
@@@ -365,7 +473,7 @@
if (id && feed.querySelector('[data-id="' + id + '"]')) return;
var stick = nearBottom();
removeEmpty();
-- addEditButton(li); feed.appendChild(li);
++ feed.appendChild(li);
while (feed.children.length > 300) {
var oldest = feed.querySelector('[data-id]');
if (!oldest) break;
@@@ -416,8 +524,9 @@
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));
++ if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
addCard(li, m);
++ offerActions(li, { id: m.id, user_id: m.user_id, message: m.message, edited_at: m.edited_at }, Number(m.user_id) === Number(cfg.me));
return li;
}
function removeIds(ids) {
@@@ -503,13 +612,14 @@
// ── A conversation ──
function dmItem(m) {
-- var li = el('li', 'oc-msg' + (m.sender_id === cfg.me ? ' oc-mine' : '')); li.dataset.id = m.id || '';
++ var li = el('li', 'oc-msg' + (m.sender_id === cfg.me ? ' oc-mine' : '')); li.dataset.id = m.id || ''; li.dataset.user = m.sender_id || '';
li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
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));
++ if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
addCard(li, m);
++ offerActions(li, { id: m.id, user_id: m.sender_id, message: m.message, edited_at: m.edited_at }, Number(m.sender_id) === Number(cfg.me));
return li;
}
function startDm() {
@@@ -599,15 +709,17 @@
if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }
else { t = el('span', 'oc-text'); textWithRoomEmoji(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 (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
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)) {
++ var mine = !ai && Number(m.user_id) === Number(cfg.me);
++ if (cfg.moderate || mine) {
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
var x = el('button'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message'); x.innerHTML = TRASH;
f.appendChild(x); li.appendChild(f);
}
++ offerActions(li, { id: m.id, user_id: m.user_id, message: m.message, edited_at: m.edited_at }, mine);
return li;
}
// OpenVibe.Pics pictures a message links (server/rooms/pictures.js): thumbnails linked to the picture's page.
@@@ -814,6 +926,7 @@
});
});
}
++ hydrateFeed();
toBottom();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
ddiff --git a/public/web/composer.js b/public/web/composer.js
iindex 2893b6b..627ca56 100644
---- a/public/web/composer.js
++++ b/public/web/composer.js
@@@ -1,4 +1,4 @@
--/* The Chat composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset chat): the room,
++/* The Chat composer on the shared one (openvibe-shared 4.217.0, OpenVibeComposer.mount, preset chat): the room,
conversation and global message boxes keep their form (action, socket submit, no-JS post) and become the shared
box with its tools and Send. Add stays beside them where the place takes pictures. Without ov-composer.js the
server's form is untouched. */
@@@ -20,12 +20,18 @@
});
if (!c) return;
c.text.id = ta.id || 'oc-input';
-- form.textContent = '';
-- while (c.form.firstChild) form.appendChild(c.form.firstChild);
-- form.classList.add('ovt-composer', 'ovc-v2');
-- form.dataset.preset = 'chat';
-- if (add) { var tools = form.querySelector('.ovt-c-tools'); if (tools) tools.insertBefore(add, tools.firstChild); }
-- form.__ovcComposer = c;
++ // The mounted form IS the box (not its children in the old one): api.edit() and its submit capture belong to
++ // this form, so Enter saves an edit before the room's send handler hears it.
++ c.form.id = form.id || 'oc-compose';
++ c.form.className = 'oc-compose ovt-composer ovc-v2';
++ c.form.dataset.preset = 'chat';
++ c.form.dataset.ovcCompose = '';
++ c.form.setAttribute('action', form.getAttribute('action') || '');
++ c.form.setAttribute('method', form.getAttribute('method') || 'post');
++ Array.prototype.forEach.call(form.querySelectorAll('input[type="hidden"]'), function (h) { c.form.appendChild(h.cloneNode(true)); });
++ if (add) { var tools = c.form.querySelector('.ovt-c-tools'); if (tools) tools.insertBefore(add, tools.firstChild); }
++ c.form.__ovcComposer = c;
++ if (form.parentNode) form.parentNode.replaceChild(c.form, form);
}
window.OVChatComposer = { scan: scan, upgrade: upgrade };
})();
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 79cbdca..91dbf63 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -279,3 +279,12 @@
/* Add a file: the kit's sheet (a bottom sheet on a phone), a little narrower than its 560px on a computer. */
.ovk-sheet.vb-upload { width: min(440px, 100vw); }
[data-vb-progress] { max-width: var(--vb-col); margin: 0 auto 8px; }
++
++/* Vibe's box edit bar: the shared composer's bar (openvibe.design rule 138), in Vibe's own form. */
++.vb-edit-bar { display: flex; align-items: center; gap: 8px; min-width: 0; margin: 0 0 6px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 14%, transparent); font-size: 13px; line-height: 1.3; }
++.vb-edit-bar > svg { flex: none; width: 15px; height: 15px; color: var(--accent-light, var(--accent)); }
++.vb-edit-bar > b { flex: none; font-weight: 700; color: var(--text-primary); }
++.vb-edit-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); }
++.vb-edit-bar .oc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 18px; }
++.vb-edit-bar .oc-bar-x:hover, .vb-edit-bar .oc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary) 10%, transparent); color: var(--text-primary); }
++#vb-composer.is-editing .ovt-c-row { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 12px; }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 104167e..191e5a7 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -286,8 +286,9 @@
b.appendChild(el('span', 'oc-sr', 'You said:'));
b.appendChild(document.createTextNode(String(text)));
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
++ b.dataset.raw = String(text);
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); }
++ if (id) vibeActions(li);
return li;
}
// The answer side of a turn: Vibe's mark, then its reply.
@@@ -509,29 +510,80 @@
}
if (stick) toBottom();
}
++ function vibeActions(li) {
++ if (!li || !window.OVMessages || !room) return;
++ var id = li.getAttribute('data-me'); if (!id) return;
++ var bubble = li.querySelector('.ovt-you .ovt-bubble'); if (!bubble) return;
++ var raw = bubble.dataset.raw || String(bubble.textContent || '').replace(/^You said:/, '');
++ window.OVMessages.actions(li, ['edit'], { mine: true, message: { id: Number(id), message: raw, edited_at: bubble.dataset.editedAt || null }, onAction: function (a) { if (a === 'edit') startVibeEdit(li); } });
++ }
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); });
-- });
++ bubble.dataset.raw = String(m.message || '');
++ Array.prototype.forEach.call(li.querySelectorAll('.vb-edited, .ovm-edited'), function (x) { x.remove(); });
++ if (m.edited_at && window.OVMessages) window.OVMessages.edited(bubble, m.edited_at);
++ vibeActions(li);
++ }
++ // Editing your own message, in Vibe's box: the same bar and keys as the shared composer (rule 138).
++ var vibeEditing = null;
++ function startVibeEdit(li) {
++ if (!li || !room || !form) return;
++ var bubble = li.querySelector('.ovt-you .ovt-bubble'); if (!bubble) return;
++ var id = li.getAttribute('data-me'); if (!id) return;
++ var raw = bubble.dataset.raw || String(bubble.textContent || '').replace(/^You said:/, '');
++ cancelVibeEdit();
++ var draft = input.value;
++ var bar = el('div', 'oc-bar vb-edit-bar');
++ bar.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4z"/></svg><b>Editing your message</b><span>Enter saves · Esc cancels</span>';
++ var x = el('button', 'oc-bar-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Stop editing');
++ bar.appendChild(x);
++ form.insertBefore(bar, form.firstChild);
++ form.classList.add('is-editing');
++ input.value = raw; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();
++ input.setSelectionRange(input.value.length, input.value.length);
++ function stop(restore) {
++ input.removeEventListener('keydown', keys, true);
++ form.removeEventListener('submit', submit, true);
++ if (bar.parentNode) bar.remove();
++ form.classList.remove('is-editing');
++ input.value = restore ? draft : draft;
++ input.dispatchEvent(new Event('input', { bubbles: true }));
++ vibeEditing = null;
++ input.focus();
++ }
++ function save() {
++ var body = input.value.trim();
++ if (!body) { notice('A message can\'t be empty.'); return; }
++ fetch('/api/chat/rooms/' + encodeURIComponent(room) + '/messages/' + encodeURIComponent(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'); applyEdited(d.message); stop(false); }); })
++ .catch(function (err) { notice(err.message); });
++ }
++ function keys(e) {
++ if (!vibeEditing) return;
++ if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); stop(true); }
++ else if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); e.stopImmediatePropagation(); save(); }
++ }
++ function submit(e) { if (!vibeEditing) return; e.preventDefault(); e.stopImmediatePropagation(); save(); }
++ x.addEventListener('click', function () { stop(true); });
++ input.addEventListener('keydown', keys, true);
++ form.addEventListener('submit', submit, true);
++ vibeEditing = { stop: stop };
++ }
++ function cancelVibeEdit() { if (vibeEditing) vibeEditing.stop(true); }
++ function hydrateVibe() {
++ if (!window.OVMessages) return;
++ Array.prototype.forEach.call(app.querySelectorAll('.ovt-turn[data-me]'), function (li) {
++ var bubble = li.querySelector('.ovt-you .ovt-bubble'); if (!bubble) return;
++ var mark = li.querySelector('.vb-edited');
++ if (mark) { var at = mark.getAttribute('data-edited-at'); mark.remove(); if (at) { bubble.dataset.editedAt = at; window.OVMessages.edited(bubble, at); } }
++ if (!bubble.dataset.raw) bubble.dataset.raw = String(bubble.textContent || '').replace(/^You said:/, '');
++ vibeActions(li);
++ });
++ }
// ── 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_…
@@@ -881,6 +933,7 @@
}
refreshCompare(); setInterval(refreshCompare, 1500);
}
++ hydrateVibe();
canvasInit();
connect();
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 13d8aca..c897ea2 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -162,6 +162,7 @@ ${head}
${appIcon.headTags({ site: 'network' })}
${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
++<link rel="stylesheet" href="${ovServe.url('messages.css')}">
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
@@@ -174,6 +175,7 @@ ${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<scrip
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
${o.script || o.app ? `<script src="${asset('agent-run-card.js')}" defer></script>` : ''}
++${String(o.body || '').includes('id="oc-compose"') || o.app ? `<script src="${ovServe.url('messages.js')}" defer></script>` : ''}
${String(o.body || '').includes('id="oc-compose"') ? `<script src="${ovServe.url('ov-composer.js')}" defer></script>\n<script src="${asset('composer.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 5fb0c89..d562bab 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -126,13 +126,13 @@ function globalMessage(m) {
const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');
const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${hatHtml(m)}${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}" 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>`;
++ 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" data-edited-at="${esc(m.edited_at)}">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}${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>`;
++ if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.sender_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" data-edited-at="${esc(m.edited_at)}">· edited ${esc(hhmm(m.edited_at).text)}</span>` : ''}</time></li>`;
++ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.sender_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" data-edited-at="${esc(m.edited_at)}">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">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : 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}${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>`;
++ 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" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 51619a9..60a9ac4 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -157,7 +157,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)}${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>' : ''}` };
++ 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" data-edited-at="${esc(m.edited_at)}">edited</small>` : ''}${pictures.pageHtml(m.message)}` };
}
/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
function answerOf(m, hows, retrySlug, slug) {
ddiff --git a/test/chat-actions.test.js b/test/chat-actions.test.js
nnew file mode 100644
iindex 0000000..7809e0a
---- /dev/null
++++ b/test/chat-actions.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * A message's actions are one cluster at its corner (openvibe.design rule 138): edit and delete on your own rows,
++ * edit in the room's shared composer, "edited" after the words, and no Edit pill, window.prompt or window.alert.
++ * A real Chrome at 390 px; skipped without Chrome.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { ids } = require('openvibe-contracts');
++const { boot } = require('./helpers');
++const h = require('openvibe-shared/browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('chat actions: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const ROOT = path.join(__dirname, '..');
++const chatJs = fs.readFileSync(path.join(ROOT, 'public/web/chat.js'), 'utf8');
++const vibeJs = fs.readFileSync(path.join(ROOT, 'public/web/vibe.js'), 'utf8');
++const pages = fs.readFileSync(path.join(ROOT, 'server/web/pages.js'), 'utf8');
++const vibePages = fs.readFileSync(path.join(ROOT, 'server/web/vibe-pages.js'), 'utf8');
++assert.ok(!/window\.prompt\s*\(|window\.alert\s*\(/.test(chatJs), 'Chat edits never prompt or alert');
++assert.ok(!/window\.prompt\s*\(|window\.alert\s*\(/.test(vibeJs), 'Vibe edits never prompt or alert');
++assert.ok(!/['"]oc-edit['"]/.test(chatJs), 'no Edit pill in the chat client');
++assert.ok(!/['"]vb-edit['"]/.test(vibeJs) && !/vb-edit['"]/.test(vibePages), 'no Edit pill in Vibe');
++assert.ok(pages.includes('data-edited-at'), 'the server says when a message was edited');
++assert.ok(chatJs.includes('oc-edits-sheet') && !/window\.alert\s*\(/.test(chatJs), 'Earlier versions opens a sheet, not an alert');
++
++(async () => {
++ const t = await boot();
++ const ann = t.addUser('ann', { subject: ids.newId('user') });
++ const bob = t.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [ann, bob]) await t.ctx.upsertUser(t.live.users.get(u.id));
++ const api = (method, p, u, body) => t.http(method, '/api/chat/rooms' + p, { token: u && u.token, body });
++ let r = await api('POST', '/', ann, { name: 'Actions Room', topic: 'Rule 138' });
++ assert.strictEqual(r.status, 201, r.text);
++ await api('POST', '/actions-room/join', bob);
++ await api('POST', '/actions-room/messages', bob, { message: 'from bob' });
++ r = await api('POST', '/actions-room/messages', ann, { message: 'from me' });
++ assert.strictEqual(r.status, 201, r.text);
++ const mineId = r.body.message.id;
++ const patched = await t.http('PATCH', '/api/chat/rooms/actions-room/messages/' + mineId, { token: ann.token, body: { message: 'from me' } });
++ assert.strictEqual(patched.status, 200, 'the API saves an edit');
++
++ let ok = false;
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-actions-'));
++ const browser = await h.launch({ tmpDir: tmp });
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ const wait = (ms) => new Promise((ok) => setTimeout(ok, ms));
++ const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(50); } return false; };
++ try {
++ await page.send('Network.setCookie', { name: 'ov_token', value: ann.token, domain: '127.0.0.1', path: '/', httpOnly: false });
++ await page.goto(`${t.base}/r/actions-room`, { maxMs: 10000 });
++ assert.ok(await until(`document.querySelectorAll('#oc-feed [data-id]').length >= 2`), 'the room rows are there');
++ assert.ok(await until(`!!document.querySelector('#oc-feed [data-id="${mineId}"] .ovm-act-edit')`), 'my row has the edit action');
++ const shape = await page.evaluate(`(() => { const mine = document.querySelector('#oc-feed [data-id="${mineId}"]'), other = [...document.querySelectorAll('#oc-feed [data-id]')].find((x) => x.dataset.id !== '${mineId}'); return { mine: [...mine.querySelectorAll('.ovm-act')].map((b) => b.dataset.action), other: other ? [...other.querySelectorAll('.ovm-act')].map((b) => b.dataset.action) : null, pill: !!document.querySelector('.oc-edit, .ovk-btn.oc-edit') }; })()`);
++ assert.deepStrictEqual(shape.mine, ['edit', 'delete'], 'my own row: edit and delete in one cluster');
++ assert.deepStrictEqual(shape.other, [], 'another person\'s row has no edit');
++ assert.strictEqual(shape.pill, false, 'no Edit pill');
++ // A tap on a phone shows the cluster (rule 138: hover on a computer, a tap on a phone).
++ await page.evaluate(`document.querySelector('#oc-feed [data-id="${mineId}"] .oc-text').click()`);
++ assert.ok(await until(`document.querySelector('#oc-feed [data-id="${mineId}"]').classList.contains('is-acting')`), 'a tap shows the actions');
++ // Edit in the box: the bar, the words, the send as a check.
++ await page.evaluate(`document.querySelector('#oc-feed [data-id="${mineId}"] .ovm-act-edit').click()`);
++ assert.ok(await until(`document.querySelector('.oc-compose, form.ovc-v2') && document.querySelector('.ovc-v2').classList.contains('is-editing')`), 'the box is editing');
++ const editShape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2'); return { bar: f.querySelector('.ovc-bar b').textContent, value: f.querySelector('textarea').value, send: f.querySelector('[data-ovt-send]').getAttribute('aria-label') }; })()`);
++ assert.deepStrictEqual(editShape, { bar: 'Editing your message', value: 'from me', send: 'Save' });
++ // The browser's own PATCH would be cross-origin in this test (the page is on 127.0.0.1, the site is
++ // openvibe.chat), so the client answers with the API's message shape; the real API was exercised above.
++ await page.evaluate(`(() => { const real = window.fetch; window.fetch = function (url, opts) { if (opts && opts.method === 'PATCH') return Promise.resolve(new Response(JSON.stringify({ message: { id: ${mineId}, message: 'edited once', edited_at: new Date().toISOString() } }), { status: 200, headers: { 'Content-Type': 'application/json' } })); return real.apply(this, arguments); }; const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'edited once'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);
++ assert.ok(await until(`document.querySelector('#oc-feed [data-id="${mineId}"] .oc-text').textContent === 'edited once'`), 'the message shows the new words');
++ const mark = await page.evaluate(`(() => { const t = document.querySelector('#oc-feed [data-id="${mineId}"] .oc-text'); const m = t.nextElementSibling; return m && m.classList.contains('ovm-edited') ? m.textContent : null; })()`);
++ assert.strictEqual(mark, 'edited', '"edited" is the quiet word right after the words');
++ assert.strictEqual(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
++ ok = true;
++ console.log('chat actions: one cluster on own rows, tap to act, edit in the box, edited after the words, 390 px');
++ } finally {
++ await page.close().catch(() => {});
++ await browser.close().catch(() => {});
++ await Promise.race([t.close(), new Promise((r) => setTimeout(r, 2000))]).catch(() => {});
++ fs.rmSync(tmp, { recursive: true, force: true });
++ if (ok) process.exit(0);
++ }
++})().catch((e) => { console.error(e); process.exit(1); });