openvibers/openvibe.live Commits
Voice channels: a signed-in person joins as themselves (the call socket signs in with its first message, as Chat's call server expects; no token in the URL), and in a call the panel and the channel list share the sidebar (the panel scrolls with its controls held at the bottom instead of pushing the list away and being cut off) (#280)
Changes
@@@ -199,7 +199,21 @@background: var(--bg-primary);display: flex;flex-direction: column;-- flex-shrink: 0;++ /* In a call the panel shares the sidebar with the channel list, and on a laptop it did not fit: it pushed the++ list out of sight and the sidebar cut off its bottom (Maticus, 2026-10-20). Now the list keeps a few rows and++ the panel scrolls, its controls held at the bottom so Mute and Leave are always there. */++ flex: 0 1 auto;++ min-height: 0;++ overflow-y: auto;++ overscroll-behavior: contain;++}++.vc-connected-panel > * { flex-shrink: 0; }++.vc-sidebar:has(> .vc-connected-panel.is-open) .vc-channel-list { min-height: 112px; }++.vc-connected-panel .vc-controls {++ position: sticky;++ bottom: 0;++ z-index: 4; /* above the tiles' menu buttons (3) scrolling behind it */++ background: var(--bg-primary);}.vc-connected-header {@@@ -321,6 +335,9 @@/* The channel list (at least the Public room) fills in after the page: one row is held for it, so the chat belowit does not move down when it lands (a 46-58 px shift on phones, CLS 0.04). */.vc-channel-list { min-height: 58px; }++ /* In a call on a phone the panel gets under half the screen (the chat keeps the rest) and scrolls inside it. */++ .vc-sidebar:has(> .vc-connected-panel.is-open) { max-height: 45vh; max-height: 45dvh; overflow: hidden; }++ .vc-sidebar:has(> .vc-connected-panel.is-open) .vc-channel-list { min-height: 58px; }.vc-connected-header {padding: 8px 10px;}@@@ -890,13 +890,8 @@ function destroyCall() {function _connectCallWs() {const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';-- const token = _getAuthToken();const channelParam = callState.channelId || callState.streamId;-- let url = `${proto}//${location.host}/ws/call?channelId=${encodeURIComponent(channelParam)}`;-- // The server reads the session cookie on the upgrade; the token only rides in the URL (and-- // every proxy log) for a browser that has no cookie.-- const hasCookie = /(?:^|;\s*)(?:ov_token|token)=/.test(document.cookie);-- if (token && !hasCookie) url += `&token=${encodeURIComponent(token)}`;++ const url = `${proto}//${location.host}/ws/call?channelId=${encodeURIComponent(channelParam)}`;if (callState.reconnectTimer) {clearTimeout(callState.reconnectTimer);@@@ -928,6 +923,11 @@ function _connectCallWs() {ws.onopen = () => {if (callState.ws !== ws) return;callState.reconnectDelay = 3000;++ // /ws/call is OpenVibe.Chat's: it signs a browser's socket in from this first message, never from the++ // cookie or a token in the URL, so without it every signed-in person joined as an anon number (Maticus,++ // 2026-10-20). Chat holds the socket's other messages until it has read this one.++ const token = _getAuthToken();++ if (token) _sendCallMsg({ type: 'auth-update', token });};ws.onmessage = (e) => {@@@ -222,7 +222,7 @@ async function vcJoinChannel(ch) {toast(`Failed to join voice channel: ${err.message || 'Unknown error'}`, 'error');callState.channelId = null;callState.vcMode = false;-- if (connPanel) connPanel.style.display = 'none';++ if (connPanel) { connPanel.style.display = 'none'; connPanel.classList.remove('is-open'); }return;}if (seq !== vcState.joinSeq) return; // another channel was chosen meanwhile@@@ -460,7 +460,7 @@ function vcRenderUI() {if (callState.connecting) return;// Disconnected (kicked, banned, call ended, etc.)const connPanel = document.getElementById('vc-connected-panel');-- if (connPanel) connPanel.style.display = 'none';++ if (connPanel) { connPanel.style.display = 'none'; connPanel.classList.remove('is-open'); }// Reset VC statecallState.vcMode = false;callState.channelId = null;nnew file mode 100644@@@ -0,0 +1,95 @@++/**++ * Voice channels (Maticus, 2026-10-20: "shows me as anon in the list and the full pop out is chopped").++ *++ * - /ws/call is OpenVibe.Chat's. It signs a browser's socket in from the socket's first auth-update message, never++ * from the cookie or a token in the URL; call.js sent none on open, so every signed-in person joined as an anon++ * number. _connectCallWs now sends the token as the first message and keeps it out of the URL (proxy logs).++ * - In a call, the in-call panel shares the sidebar with the channel list: the list keeps its rows and the panel++ * scrolls, its controls held at the bottom (on a laptop the panel pushed the list out of sight and was cut off).++ *++ * node test/voice-channels-signin.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++const callSrc = read('public/js/call.js');++++/** The source of a top-level function declaration, braces matched (after its parameter list). */++function extract(src, name) {++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);++ assert.ok(m, `${name}() must exist`);++ let i = m.index + m[0].length;++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }++ i = src.indexOf('{', i);++ for (let depth = 0; i < src.length; i++) {++ if (src[i] === '{') depth++;++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);++ }++ throw new Error(`unbalanced ${name}`);++}++++/** Opens a call socket the way the page does and returns { url, sent } once it is open. */++function connect(token) {++ const sockets = [];++ class FakeWebSocket {++ constructor(url) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.sent = []; sockets.push(this); }++ send(data) { this.sent.push(JSON.parse(data)); }++ close() {}++ }++ FakeWebSocket.OPEN = 1;++ const ctx = {++ WebSocket: FakeWebSocket,++ location: { protocol: 'https:', host: 'openvibe.live' },++ document: { cookie: token ? `ov_token=${token}` : '' },++ callState: { channelId: 'public', streamId: null, peers: new Map(), ws: null, reconnectTimer: null },++ CALL_WS_MAX_BUFFERED_AMOUNT: 1 << 20,++ _getAuthToken: () => token,++ _closePeer() {}, _handleCallMessage() {}, _setConnState() {},++ clearTimeout, setTimeout, console, JSON, Math,++ };++ vm.createContext(ctx);++ vm.runInContext(`${extract(callSrc, '_sendCallMsg')}\n${extract(callSrc, '_connectCallWs')}\n_connectCallWs();`, ctx);++ const ws = sockets[0];++ ws.readyState = 1;++ ws.onopen();++ return { url: ws.url, sent: ws.sent };++}++++const signedIn = connect('net.jwt.token');++assert.deepStrictEqual(signedIn.sent[0], { type: 'auth-update', token: 'net.jwt.token' }, 'the first message signs the socket in');++assert.strictEqual(signedIn.url, 'wss://openvibe.live/ws/call?channelId=public', 'no token in the URL (Chat ignores it; proxies log it)');++++const anonymous = connect(null);++assert.deepStrictEqual(anonymous.sent, [], 'a signed-out visitor sends nothing and joins as an anon number');++assert.ok(!/token=\$\{encodeURIComponent\(token\)\}/.test(extract(callSrc, '_connectCallWs')));++++// The in-call panel and the channel list share the sidebar instead of the panel crowding the list out.++const css = read('public/css/features/chatpage.css');++const rule = (sel) => {++ const at = css.indexOf(`${sel} {`);++ assert.ok(at >= 0, `${sel} has a rule`);++ return css.slice(at, css.indexOf('}', at));++};++const panel = rule('.vc-connected-panel');++assert.match(panel, /flex: 0 1 auto;/);++assert.match(panel, /min-height: 0;/);++assert.match(panel, /overflow-y: auto;/);++assert.match(rule('.vc-connected-panel > *'), /flex-shrink: 0;/, 'the panel scrolls as a whole; its grid does not collapse');++assert.match(rule('.vc-sidebar:has(> .vc-connected-panel.is-open) .vc-channel-list'), /min-height: 112px;/, 'the list keeps a few rows in a call');++const controls = rule('.vc-connected-panel .vc-controls');++assert.match(controls, /position: sticky;/);++assert.match(controls, /bottom: 0;/);++assert.match(css, /@media \(max-width: 768px\)[\s\S]*\.vc-sidebar:has\(> \.vc-connected-panel\.is-open\) \{ max-height: 45vh; max-height: 45dvh; overflow: hidden; \}/);++++// Every way out of a call takes the panel's is-open off, so the in-call layout ends with it.++const vc = read('public/js/voice-channels.js');++const hides = vc.match(/connPanel\.style\.display = 'none'[^\n]*/g) || [];++assert.ok(hides.length >= 3);++for (const line of hides) assert.match(line, /classList\.remove\('is-open'\)/, line);++++console.log('voice channels: signed in on open, panel and list share the sidebar — all checks passed');
@@ -199,7 +199,21 @@
@@ -199,7 +199,21 @@
background: var(--bg-primary);
background: var(--bg-primary);
display: flex;
display: flex;
flex-direction: column;
flex-direction: column;
- flex-shrink: 0;
+ /* In a call the panel shares the sidebar with the channel list, and on a laptop it did not fit: it pushed the
+ list out of sight and the sidebar cut off its bottom (Maticus, 2026-10-20). Now the list keeps a few rows and
+ the panel scrolls, its controls held at the bottom so Mute and Leave are always there. */
+ flex: 0 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.vc-connected-panel > * { flex-shrink: 0; }
+.vc-sidebar:has(> .vc-connected-panel.is-open) .vc-channel-list { min-height: 112px; }
+.vc-connected-panel .vc-controls {
+ position: sticky;
+ bottom: 0;
+ z-index: 4; /* above the tiles' menu buttons (3) scrolling behind it */
+ background: var(--bg-primary);
}
}
.vc-connected-header {
.vc-connected-header {
@@ -321,6 +335,9 @@
@@ -321,6 +335,9 @@
/* The channel list (at least the Public room) fills in after the page: one row is held for it, so the chat below
/* The channel list (at least the Public room) fills in after the page: one row is held for it, so the chat below
it does not move down when it lands (a 46-58 px shift on phones, CLS 0.04). */
it does not move down when it lands (a 46-58 px shift on phones, CLS 0.04). */
.vc-channel-list { min-height: 58px; }
.vc-channel-list { min-height: 58px; }
+ /* In a call on a phone the panel gets under half the screen (the chat keeps the rest) and scrolls inside it. */
+ .vc-sidebar:has(> .vc-connected-panel.is-open) { max-height: 45vh; max-height: 45dvh; overflow: hidden; }
+ .vc-sidebar:has(> .vc-connected-panel.is-open) .vc-channel-list { min-height: 58px; }
.vc-connected-header {
.vc-connected-header {
padding: 8px 10px;
padding: 8px 10px;
}
}
@@ -890,13 +890,8 @@ function destroyCall() {
@@ -890,13 +890,8 @@ function destroyCall() {
function _connectCallWs() {
function _connectCallWs() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const token = _getAuthToken();
const channelParam = callState.channelId || callState.streamId;
const channelParam = callState.channelId || callState.streamId;
- let url = `${proto}//${location.host}/ws/call?channelId=${encodeURIComponent(channelParam)}`;
- // The server reads the session cookie on the upgrade; the token only rides in the URL (and
- // every proxy log) for a browser that has no cookie.
- const hasCookie = /(?:^|;\s*)(?:ov_token|token)=/.test(document.cookie);
- if (token && !hasCookie) url += `&token=${encodeURIComponent(token)}`;
+ const url = `${proto}//${location.host}/ws/call?channelId=${encodeURIComponent(channelParam)}`;
if (callState.reconnectTimer) {
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
clearTimeout(callState.reconnectTimer);
@@ -928,6 +923,11 @@ function _connectCallWs() {
@@ -928,6 +923,11 @@ function _connectCallWs() {
ws.onopen = () => {
ws.onopen = () => {
if (callState.ws !== ws) return;
if (callState.ws !== ws) return;
callState.reconnectDelay = 3000;
callState.reconnectDelay = 3000;
+ // /ws/call is OpenVibe.Chat's: it signs a browser's socket in from this first message, never from the
+ // cookie or a token in the URL, so without it every signed-in person joined as an anon number (Maticus,
+ // 2026-10-20). Chat holds the socket's other messages until it has read this one.
+ const token = _getAuthToken();
+ if (token) _sendCallMsg({ type: 'auth-update', token });
};
};
ws.onmessage = (e) => {
ws.onmessage = (e) => {
@@ -222,7 +222,7 @@ async function vcJoinChannel(ch) {
@@ -222,7 +222,7 @@ async function vcJoinChannel(ch) {
toast(`Failed to join voice channel: ${err.message || 'Unknown error'}`, 'error');
toast(`Failed to join voice channel: ${err.message || 'Unknown error'}`, 'error');
callState.channelId = null;
callState.channelId = null;
callState.vcMode = false;
callState.vcMode = false;
- if (connPanel) connPanel.style.display = 'none';
+ if (connPanel) { connPanel.style.display = 'none'; connPanel.classList.remove('is-open'); }
return;
return;
}
}
if (seq !== vcState.joinSeq) return; // another channel was chosen meanwhile
if (seq !== vcState.joinSeq) return; // another channel was chosen meanwhile
@@ -460,7 +460,7 @@ function vcRenderUI() {
@@ -460,7 +460,7 @@ function vcRenderUI() {
if (callState.connecting) return;
if (callState.connecting) return;
// Disconnected (kicked, banned, call ended, etc.)
// Disconnected (kicked, banned, call ended, etc.)
const connPanel = document.getElementById('vc-connected-panel');
const connPanel = document.getElementById('vc-connected-panel');
- if (connPanel) connPanel.style.display = 'none';
+ if (connPanel) { connPanel.style.display = 'none'; connPanel.classList.remove('is-open'); }
// Reset VC state
// Reset VC state
callState.vcMode = false;
callState.vcMode = false;
callState.channelId = null;
callState.channelId = null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+/**
+ * Voice channels (Maticus, 2026-10-20: "shows me as anon in the list and the full pop out is chopped").
+ *
+ * - /ws/call is OpenVibe.Chat's. It signs a browser's socket in from the socket's first auth-update message, never
+ * from the cookie or a token in the URL; call.js sent none on open, so every signed-in person joined as an anon
+ * number. _connectCallWs now sends the token as the first message and keeps it out of the URL (proxy logs).
+ * - In a call, the in-call panel shares the sidebar with the channel list: the list keeps its rows and the panel
+ * scrolls, its controls held at the bottom (on a laptop the panel pushed the list out of sight and was cut off).
+ *
+ * node test/voice-channels-signin.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+const callSrc = read('public/js/call.js');
+
+/** The source of a top-level function declaration, braces matched (after its parameter list). */
+function extract(src, name) {
+ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
+ assert.ok(m, `${name}() must exist`);
+ let i = m.index + m[0].length;
+ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
+ i = src.indexOf('{', i);
+ for (let depth = 0; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
+ }
+ throw new Error(`unbalanced ${name}`);
+}
+
+/** Opens a call socket the way the page does and returns { url, sent } once it is open. */
+function connect(token) {
+ const sockets = [];
+ class FakeWebSocket {
+ constructor(url) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.sent = []; sockets.push(this); }
+ send(data) { this.sent.push(JSON.parse(data)); }
+ close() {}
+ }
+ FakeWebSocket.OPEN = 1;
+ const ctx = {
+ WebSocket: FakeWebSocket,
+ location: { protocol: 'https:', host: 'openvibe.live' },
+ document: { cookie: token ? `ov_token=${token}` : '' },
+ callState: { channelId: 'public', streamId: null, peers: new Map(), ws: null, reconnectTimer: null },
+ CALL_WS_MAX_BUFFERED_AMOUNT: 1 << 20,
+ _getAuthToken: () => token,
+ _closePeer() {}, _handleCallMessage() {}, _setConnState() {},
+ clearTimeout, setTimeout, console, JSON, Math,
+ };
+ vm.createContext(ctx);
+ vm.runInContext(`${extract(callSrc, '_sendCallMsg')}\n${extract(callSrc, '_connectCallWs')}\n_connectCallWs();`, ctx);
+ const ws = sockets[0];
+ ws.readyState = 1;
+ ws.onopen();
+ return { url: ws.url, sent: ws.sent };
+}
+
+const signedIn = connect('net.jwt.token');
+assert.deepStrictEqual(signedIn.sent[0], { type: 'auth-update', token: 'net.jwt.token' }, 'the first message signs the socket in');
+assert.strictEqual(signedIn.url, 'wss://openvibe.live/ws/call?channelId=public', 'no token in the URL (Chat ignores it; proxies log it)');
+
+const anonymous = connect(null);
+assert.deepStrictEqual(anonymous.sent, [], 'a signed-out visitor sends nothing and joins as an anon number');
+assert.ok(!/token=\$\{encodeURIComponent\(token\)\}/.test(extract(callSrc, '_connectCallWs')));
+
+// The in-call panel and the channel list share the sidebar instead of the panel crowding the list out.
+const css = read('public/css/features/chatpage.css');
+const rule = (sel) => {
+ const at = css.indexOf(`${sel} {`);
+ assert.ok(at >= 0, `${sel} has a rule`);
+ return css.slice(at, css.indexOf('}', at));
+};
+const panel = rule('.vc-connected-panel');
+assert.match(panel, /flex: 0 1 auto;/);
+assert.match(panel, /min-height: 0;/);
+assert.match(panel, /overflow-y: auto;/);
+assert.match(rule('.vc-connected-panel > *'), /flex-shrink: 0;/, 'the panel scrolls as a whole; its grid does not collapse');
+assert.match(rule('.vc-sidebar:has(> .vc-connected-panel.is-open) .vc-channel-list'), /min-height: 112px;/, 'the list keeps a few rows in a call');
+const controls = rule('.vc-connected-panel .vc-controls');
+assert.match(controls, /position: sticky;/);
+assert.match(controls, /bottom: 0;/);
+assert.match(css, /@media \(max-width: 768px\)[\s\S]*\.vc-sidebar:has\(> \.vc-connected-panel\.is-open\) \{ max-height: 45vh; max-height: 45dvh; overflow: hidden; \}/);
+
+// Every way out of a call takes the panel's is-open off, so the in-call layout ends with it.
+const vc = read('public/js/voice-channels.js');
+const hides = vc.match(/connPanel\.style\.display = 'none'[^\n]*/g) || [];
+assert.ok(hides.length >= 3);
+for (const line of hides) assert.match(line, /classList\.remove\('is-open'\)/, line);
+
+console.log('voice channels: signed in on open, panel and list share the sidebar — all checks passed');