openvibers/openvibe.chat Commits

Rooms and conversations: the shared composer v2 (#538)

OP OpenVibers committed 1 days ago · 867730cdd4 · 9 files, +96 −9

* Rooms and conversations: the shared composer v2

* openvibe-shared v4.215.0 (the composer v2 release)

* Merge origin/main

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 8361e59..c9473db 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.150.0",
-- "shared": "openvibe-shared v4.214.0",
++ "shared": "openvibe-shared v4.215.0",
"capabilities": [
"chat.message.send",
"chat.event.publish",
ddiff --git a/package-lock.json b/package-lock.json
iindex b46bffa..2490158 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
@@@ -1957,9 +1957,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.214.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
-- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",
++ "version": "4.215.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
++ "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 67061e0..35da612 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 0a2e9b0..e081321 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -561,7 +561,7 @@
var text = input.value.trim();
if (!text) { e.preventDefault(); return; }
e.preventDefault();
-- var btn = form.querySelector('button'); if (btn) btn.disabled = true;
++ var btn = form.querySelector('[data-ovt-send]') || form.querySelector('.oc-send') || form.querySelector('button'); if (btn) btn.disabled = true;
fetch(base + '/messages', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
ddiff --git a/public/web/composer.js b/public/web/composer.js
nnew file mode 100644
iindex 0000000..2893b6b
---- /dev/null
++++ b/public/web/composer.js
@@@ -0,0 +1,31 @@
++/* The Chat composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset chat): the room,
++ conversation and global message boxes keep their form (action, socket submit, no-JS post) and become the shared
++ box with its tools and Send. Add stays beside them where the place takes pictures. Without ov-composer.js the
++ server's form is untouched. */
++(function () {
++ 'use strict';
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan, { once: true }); else scan();
++ function scan() {
++ Array.prototype.forEach.call(document.querySelectorAll('form[data-ovc-compose]'), upgrade);
++ }
++ function upgrade(form) {
++ if (!form || form.__ovcComposer || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
++ var ta = form.querySelector('textarea');
++ if (!ta) return;
++ var add = form.querySelector('[data-oc-attach]');
++ var c = window.OpenVibeComposer.mount(document.createElement('div'), {
++ preset: 'chat', name: ta.name || 'message', maxLength: Number(ta.getAttribute('maxlength')) || 4000,
++ placeholder: ta.placeholder || 'Write a message', label: ta.getAttribute('aria-label') || form.getAttribute('aria-label') || 'Message',
++ tools: ['emoji'],
++ });
++ 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;
++ }
++ window.OVChatComposer = { scan: scan, upgrade: upgrade };
++})();
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex f0f3069..13d8aca 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -174,6 +174,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"') ? `<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>
<script src="${ovServe.url('navbar.js')}" defer></script>
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex bd30300..916b101 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -230,7 +230,7 @@ ${youBar(o.you || {})}`;
function composer(o = {}) {
const questHook = o.action === '/send' ? ' data-ov-guide="chat.message"' : '';
const add = o.add || '';
-- return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose"><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>
++ return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose" data-ovc-compose data-ovc-preset="chat"><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>
<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div>
<div class="oc-compose-row">${add ? `<button type="button" class="oc-compose-add" data-oc-attach aria-expanded="false" aria-controls="oc-add-panel" aria-label="Add a picture" title="Add a picture">${IC.plus}</button>` : ''}<textarea id="oc-input" name="message" maxlength="${Number(o.max) || 2000}" rows="1" required placeholder="${esc(o.placeholder || 'Write a message')}"></textarea><button type="submit" class="oc-send"${questHook} aria-label="Send" title="Send">${IC.send}</button></div></div></form>${add}</div>`;
}
ddiff --git a/test/composer.test.js b/test/composer.test.js
nnew file mode 100644
iindex 0000000..7c5bad3
---- /dev/null
++++ b/test/composer.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++// The Chat composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset chat): a room or
++// conversation's form keeps its action, its Add button and its no-JS post; the words and Send become the shared
++// box. 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 http = require('http');
++const h = require('openvibe-shared/browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const SHARED = process.env.OV_SHARED_DIR || path.join(__dirname, '..', 'node_modules', 'openvibe-shared');
++const SITE = path.join(__dirname, '..');
++const form = `<div class="oc-dock has-add"><form class="oc-compose" method="post" action="/r/test" id="oc-compose" data-ovc-compose data-ovc-preset="chat"><label for="oc-input" class="oc-sr">Message</label>
++<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div><div class="oc-compose-row"><button type="button" class="oc-compose-add" data-oc-attach aria-label="Add a picture">+</button><textarea id="oc-input" name="message" maxlength="500" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send">Send</button></div></div></form></div>`;
++let posted = '';
++const server = http.createServer((req, res) => {
++ if (req.method === 'POST') {
++ let body = '';
++ req.on('data', (d) => { body += d; });
++ req.on('end', () => { posted = body; res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>'); });
++ return;
++ }
++ if (req.url === '/') {
++ res.writeHead(200, { 'content-type': 'text/html' });
++ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head><body style="margin:0;padding:12px;background:#0b1220;color:#e6edf7;font-family:sans-serif">${form}<script src="/ov-composer.js"></script><script src="/composer.js"></script></body></html>`);
++ }
++ if (req.url === '/ov-composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SHARED, 'ov-composer.js'))); }
++ if (req.url === '/composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SITE, 'public/web/composer.js'))); }
++ res.writeHead(404); res.end();
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-composer-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ try {
++ await page.goto(base + '/', { maxMs: 6000 });
++ for (let i = 0; i < 100 && !(await page.evaluate(`!!document.querySelector('form.ovc-v2')`)); i++) await wait(40);
++ const shape = await page.evaluate(`(() => { const f = document.querySelector('#oc-compose'); return { preset: f.dataset.preset, cls: f.className, action: f.getAttribute('action'), text: !!f.querySelector('#oc-input[data-ovt-text]'), add: !!f.querySelector('.ovt-c-tools [data-oc-attach]'), send: f.querySelector('[data-ovt-send]').getAttribute('aria-label'), box: !!f.querySelector('.oc-compose-box') }; })()`);
++ assert.deepEqual(shape, { preset: 'chat', cls: 'oc-compose ovt-composer ovc-v2', action: '/r/test', text: true, add: true, send: 'Send', box: false });
++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
++ await page.evaluate(`(() => { const t = document.querySelector('#oc-input'); t.value = 'Hello room'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('#oc-compose [data-ovt-send]').click(); })()`);
++ for (let i = 0; i < 100 && !posted; i++) await wait(40);
++ assert.match(posted, /message=Hello\+room/, 'the form still posts its message');
++ console.log('composer: the room box is the shared chat composer, keeps Add and its action, sends, 390 px');
++ } finally {
++ await page.close().catch(() => {});
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex ed41aca..ede0b89 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -86,7 +86,7 @@ t('the shell: the rail, the side column, the members column and the bottom bar',
const asBob = await page(`/s/${slug}`, bob);
assert.ok(/data-s="leave"/.test(asBob.text) && !/data-s="settings"/.test(asBob.text) && !/data-s="delete"/.test(asBob.text), 'a member: leave, and not a word about settings or deleting');
// One composer: a box, Send on its right; no picture store in this run, so no Add.
-- assert.match(r.text, /<form class="oc-compose" method="post" action="\/s\/saturday-raiders\/general" id="oc-compose">/);
++ assert.match(r.text, /<form class="oc-compose" method="post" action="\/s\/saturday-raiders\/general" id="oc-compose" data-ovc-compose data-ovc-preset="chat">/);
assert.match(r.text, /<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden><\/div>/);
assert.match(r.text, /<textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Message #general"><\/textarea><button type="submit" class="oc-send" aria-label="Send"/);
});