openvibers/openvibe.chat Commits
Chat: a message's actions in one cluster, editing in the box (#540)
Changes
@@@ -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",@@@ -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",@@@ -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"},@@@ -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; }@@@ -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();@@@ -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 sharedbox with its tools and Send. Add stays beside them where the place takes pictures. Without ov-composer.js theserver'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 };})();@@@ -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; }@@@ -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(); });@@@ -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>@@@ -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);@@@ -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) {nnew file mode 100644@@@ -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); });
@@ -9,7 +9,7 @@
@@ -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)",
"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",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.150.0",
"sdk": "openvibe-sdk v0.150.0",
- "shared": "openvibe-shared v4.215.0",
+ "shared": "openvibe-shared v4.218.0",
"capabilities": [
"capabilities": [
"chat.message.send",
"chat.message.send",
"chat.event.publish",
"chat.event.publish",
@@ -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.410.0",
"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-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",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1957,9 +1957,9 @@
@@ -1957,9 +1957,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"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.410.0",
"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-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",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -835,3 +835,21 @@ div.oc-set-row { cursor: default; }
@@ -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-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; }
.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%); } }
@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; }
@@ -314,47 +314,155 @@
@@ -314,47 +314,155 @@
}
}
function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }
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) {
function applyEdit(id, m) {
if (!feed) return;
if (!feed) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
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); }
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(); });
Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
addCard(li, m);
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 (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' })
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 (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'); });
.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'] };
@@ -365,7 +473,7 @@
@@ -365,7 +473,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();
- addEditButton(li); feed.appendChild(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;
@@ -416,8 +524,9 @@
@@ -416,8 +524,9 @@
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));
+ if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
addCard(li, m);
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;
return li;
}
}
function removeIds(ids) {
function removeIds(ids) {
@@ -503,13 +612,14 @@
@@ -503,13 +612,14 @@
// ── A conversation ──
// ── A conversation ──
function dmItem(m) {
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(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
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));
+ if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
addCard(li, m);
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;
return li;
}
}
function startDm() {
function startDm() {
@@ -599,15 +709,17 @@
@@ -599,15 +709,17 @@
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'); textWithRoomEmoji(t, m.message); }
else { t = el('span', 'oc-text'); textWithRoomEmoji(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 (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 (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)) {
+ 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 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;
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);
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;
return li;
}
}
// OpenVibe.Pics pictures a message links (server/rooms/pictures.js): thumbnails linked to the picture's page.
// OpenVibe.Pics pictures a message links (server/rooms/pictures.js): thumbnails linked to the picture's page.
@@ -814,6 +926,7 @@
@@ -814,6 +926,7 @@
});
});
});
});
}
}
+ hydrateFeed();
toBottom();
toBottom();
if (cfg.view === 'global') startGlobal();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'dm') startDm();
@@ -1,4 +1,4 @@
@@ -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
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
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. */
server's form is untouched. */
@@ -20,12 +20,18 @@
@@ -20,12 +20,18 @@
});
});
if (!c) return;
if (!c) return;
c.text.id = ta.id || 'oc-input';
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 };
window.OVChatComposer = { scan: scan, upgrade: upgrade };
})();
})();
@@ -279,3 +279,12 @@
@@ -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. */
/* 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); }
.ovk-sheet.vb-upload { width: min(440px, 100vw); }
[data-vb-progress] { max-width: var(--vb-col); margin: 0 auto 8px; }
[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; }
@@ -286,8 +286,9 @@
@@ -286,8 +286,9 @@
b.appendChild(el('span', 'oc-sr', 'You said:'));
b.appendChild(el('span', 'oc-sr', 'You said:'));
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); }
+ b.dataset.raw = String(text);
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); }
+ if (id) vibeActions(li);
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.
@@ -509,29 +510,80 @@
@@ -509,29 +510,80 @@
}
}
if (stick) toBottom();
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) {
function applyEdited(m) {
var li = have(m && m.id), bubble = li && li.querySelector('.ovt-you .ovt-bubble');
var li = have(m && m.id), bubble = li && li.querySelector('.ovt-you .ovt-bubble');
if (!bubble) return;
if (!bubble) return;
bubble.textContent = '';
bubble.textContent = '';
bubble.appendChild(el('span', 'oc-sr', 'You said:'));
bubble.appendChild(el('span', 'oc-sr', 'You said:'));
bubble.appendChild(document.createTextNode(String(m.message || '')));
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 ─────────────────────────────────────────────
// ── 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_…
@@ -881,6 +933,7 @@
@@ -881,6 +933,7 @@
}
}
refreshCompare(); setInterval(refreshCompare, 1500);
refreshCompare(); setInterval(refreshCompare, 1500);
}
}
+ hydrateVibe();
canvasInit();
canvasInit();
connect();
connect();
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
@@ -162,6 +162,7 @@ ${head}
@@ -162,6 +162,7 @@ ${head}
${appIcon.headTags({ site: 'network' })}
${appIcon.headTags({ site: 'network' })}
${INSTALL_LINKS}
${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
<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')}">` : ''}
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.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
@@ -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.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.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>` : ''}
${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>` : ''}
${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')}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
@@ -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}" 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') {
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}${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
// 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">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;
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);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -157,7 +157,7 @@ function resultChips(m, slug) {
@@ -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. */
/** 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)}${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. */
/** 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,82 @@
@@ -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); });