openvibers/openvibe.live Commits

Stream chat: the shared composer v2 (#850)

OP OpenVibers committed 1 days ago · 7e868117ce · 7 files, +99 −6

* Stream chat: the shared composer v2

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

* Merge origin/main

* Stream chat: load the shared composer with the chat feature, not the home page

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 43252202..68e88779 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.152.0",
-- "shared": "openvibe-shared v4.214.0",
++ "shared": "openvibe-shared v4.215.0",
"capabilities": [
"live.chat_context.read",
"live.chat_effects.write",
ddiff --git a/package-lock.json b/package-lock.json
iindex 21cf1c94..64e77c2d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.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.152.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.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -3417,9 +3417,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 bc1af0fa..c648569e 100644
---- a/package.json
++++ b/package.json
@@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.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.152.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.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/public/features.json b/public/features.json
iindex aca66b05..da7241ea 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -8,6 +8,8 @@
],
"js": [
"/js/chat-shared.js",
++ "/shared/ov-composer.js",
++ "/js/composer.js",
"/js/chat.js"
],
"stubs": [
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 5c89c293..9f6b67d3 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -1304,6 +1304,7 @@ function _chatInputIdForTarget(targetId) {
}
function initChat(streamId, channelUserId = null) {
++ if (window.OVChatComposer) window.OVChatComposer.mount();
const prevChannelUserId = chatChannelUserId;
chatChannelUserId = channelUserId || null;
_syncOwnTtsLeadership();
@@@ -3477,6 +3478,7 @@ function sendChat(overrideInput = null) {
chatWs.send(JSON.stringify(msg));
input.value = '';
++ input.dispatchEvent(new Event('input', { bubbles: true }));
_autoResizeTextarea(input);
input.focus();
clearChatReply();
@@@ -6392,6 +6394,7 @@ function fcwSendChat() {
chatWs.send(JSON.stringify({ type: 'chat', message: text, streamId: null }));
input.value = '';
++ input.dispatchEvent(new Event('input', { bubbles: true }));
_autoResizeTextarea(input);
input.focus();
}
ddiff --git a/public/js/composer.js b/public/js/composer.js
nnew file mode 100644
iindex 00000000..cee02de6
---- /dev/null
++++ b/public/js/composer.js
@@@ -0,0 +1,41 @@
++/* Live's stream chat on the shared composer (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset chat): each
++ .chat-input-row (a stream, a channel, the global page, the floating chat, a broadcast) keeps its textarea's id
++ and .chat-textarea class, so sendChat() and the emote picker still find it; the box and Send are the shared ones.
++ Loaded after /shared/ov-composer.js; without it the old textarea stays. */
++(function () {
++ 'use strict';
++ function upgrade(row) {
++ if (!row || row.dataset.ovcMounted || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
++ var old = row.querySelector('textarea');
++ if (!old) return;
++ var id = old.id, max = Number(old.getAttribute('maxlength')) || 500, placeholder = old.placeholder || 'Send a message...';
++ var c = window.OpenVibeComposer.mount(document.createElement('div'), {
++ preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emoji'],
++ onSubmit: function () {
++ var before = c.text.value;
++ if (id === 'fcw-chat-input' && typeof window.fcwSendChat === 'function') window.fcwSendChat();
++ else if (typeof window.sendChat === 'function') window.sendChat(c.text);
++ return c.text.value !== before;
++ },
++ });
++ if (!c) return;
++ c.text.id = id;
++ c.text.classList.add('chat-textarea');
++ c.text.setAttribute('enterkeyhint', 'send');
++ row.replaceChildren(c.form);
++ row.dataset.ovcMounted = '1';
++ }
++ function mount() {
++ Array.prototype.forEach.call(document.querySelectorAll('.chat-input-row'), upgrade);
++ }
++ window.OVChatComposer = { mount: mount, upgrade: upgrade };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true }); else mount();
++ // A page's chat frame is fetched after the route changes: catch the row when it lands, once per row.
++ var queued = 0;
++ if (window.MutationObserver && document.body) {
++ new MutationObserver(function () {
++ if (queued) return;
++ queued = setTimeout(function () { queued = 0; mount(); }, 60);
++ }).observe(document.body, { childList: true, subtree: true });
++ }
++})();
ddiff --git a/test/composer.test.js b/test/composer.test.js
nnew file mode 100644
iindex 00000000..e4e6763a
---- /dev/null
++++ b/test/composer.test.js
@@@ -0,0 +1,47 @@
++'use strict';
++// Live's stream chat on the shared composer (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset chat): the
++// chat input row becomes form.ovc-v2[data-preset=chat], keeps the textarea's id and .chat-textarea class so
++// sendChat() still finds it, and Send sends. 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 row = `<div class="chat-input-area"><div class="chat-input-row"><textarea id="chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500" rows="1"></textarea><button class="chat-send-btn" aria-label="Send message">Send</button></div></div>`;
++const server = http.createServer((req, res) => {
++ 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>Live chat</title><link rel="icon" href="data:,"></head><body style="margin:0;padding:12px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div id="chat-input-area">${row}</div><script>window.sent=[]; window.sendChat=function(t){ if(!t||!t.value.trim()) return; window.sent.push(t.value.trim()); t.value=''; t.dispatchEvent(new Event('input',{bubbles:true})); };</script><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/js/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(), 'live-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('.chat-input-row form.ovc-v2')`)); i++) await wait(40);
++ const shape = await page.evaluate(`(() => { const f = document.querySelector('.chat-input-row form'); const t = f.querySelector('textarea'); return { preset: f.dataset.preset, id: t.id, cls: t.className, placeholder: t.placeholder, max: t.maxLength, old: !!f.querySelector('.chat-send-btn') }; })()`);
++ assert.deepEqual(shape, { preset: 'chat', id: 'chat-input', cls: 'chat-textarea', placeholder: 'Send a message...', max: 500, old: false });
++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
++ await page.evaluate(`(() => { const t = document.querySelector('#chat-input'); t.value = 'Hello stream'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('.chat-input-row [data-ovt-send]').click(); })()`);
++ for (let i = 0; i < 100 && !(await page.evaluate(`window.sent.length`)); i++) await wait(40);
++ assert.deepEqual(await page.evaluate(`window.sent`), ['Hello stream']);
++ console.log('composer: the stream chat is the shared chat composer, keeps its id, 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); });