openvibers/openvibe.live Commits
Stream chat: the shared composer v2 (#850)
Changes
@@@ -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",@@@ -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",@@@ -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",@@@ -8,6 +8,8 @@],"js": ["/js/chat-shared.js",++ "/shared/ov-composer.js",++ "/js/composer.js","/js/chat.js"],"stubs": [@@@ -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();}nnew file mode 100644@@@ -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 });++ }++})();nnew file mode 100644@@@ -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); });
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.410.0",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.152.0",
"sdk": "openvibe-sdk v0.152.0",
- "shared": "openvibe-shared v4.214.0",
+ "shared": "openvibe-shared v4.215.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"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.152.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",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3417,9 +3417,9 @@
@@ -3417,9 +3417,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"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.152.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",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
],
],
"js": [
"js": [
"/js/chat-shared.js",
"/js/chat-shared.js",
+ "/shared/ov-composer.js",
+ "/js/composer.js",
"/js/chat.js"
"/js/chat.js"
],
],
"stubs": [
"stubs": [
@@ -1304,6 +1304,7 @@ function _chatInputIdForTarget(targetId) {
@@ -1304,6 +1304,7 @@ function _chatInputIdForTarget(targetId) {
}
}
function initChat(streamId, channelUserId = null) {
function initChat(streamId, channelUserId = null) {
+ if (window.OVChatComposer) window.OVChatComposer.mount();
const prevChannelUserId = chatChannelUserId;
const prevChannelUserId = chatChannelUserId;
chatChannelUserId = channelUserId || null;
chatChannelUserId = channelUserId || null;
_syncOwnTtsLeadership();
_syncOwnTtsLeadership();
@@ -3477,6 +3478,7 @@ function sendChat(overrideInput = null) {
@@ -3477,6 +3478,7 @@ function sendChat(overrideInput = null) {
chatWs.send(JSON.stringify(msg));
chatWs.send(JSON.stringify(msg));
input.value = '';
input.value = '';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
_autoResizeTextarea(input);
_autoResizeTextarea(input);
input.focus();
input.focus();
clearChatReply();
clearChatReply();
@@ -6392,6 +6394,7 @@ function fcwSendChat() {
@@ -6392,6 +6394,7 @@ function fcwSendChat() {
chatWs.send(JSON.stringify({ type: 'chat', message: text, streamId: null }));
chatWs.send(JSON.stringify({ type: 'chat', message: text, streamId: null }));
input.value = '';
input.value = '';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
_autoResizeTextarea(input);
_autoResizeTextarea(input);
input.focus();
input.focus();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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 });
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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); });