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)

OP OpenVibers committed 24 days ago · 625c3648ce · 4 files, +121 −9

Changes

ddiff --git a/public/css/features/chatpage.css b/public/css/features/chatpage.css
iindex 4d1088be..ba2948ce 100644
---- a/public/css/features/chatpage.css
++++ b/public/css/features/chatpage.css
@@@ -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 below
it 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;
}
ddiff --git a/public/js/call.js b/public/js/call.js
iindex 493f9b58..cd44386b 100644
---- a/public/js/call.js
++++ b/public/js/call.js
@@@ -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) => {
ddiff --git a/public/js/voice-channels.js b/public/js/voice-channels.js
iindex 07528d9c..143718e7 100644
---- a/public/js/voice-channels.js
++++ b/public/js/voice-channels.js
@@@ -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 state
callState.vcMode = false;
callState.channelId = null;
ddiff --git a/test/voice-channels-signin.test.js b/test/voice-channels-signin.test.js
nnew file mode 100644
iindex 00000000..085aca9b
---- /dev/null
++++ b/test/voice-channels-signin.test.js
@@@ -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');