openvibers/openvibe.chat Commits

Chat: 1:1 call page asks only on the Turn on microphone press (#489)

OP OpenVibers committed 4 days ago · 7090e97bbc · 4 files, +92 −10

Changes

ddiff --git a/public/web/call.js b/public/web/call.js
iindex be0f024..1143416 100644
---- a/public/web/call.js
++++ b/public/web/call.js
@@@ -3,7 +3,8 @@
server/calls/call-server.js: the newcomer offers to everyone already in, the others answer. The room's
roles decide who talks (the server force-mutes listeners; this client never sends a microphone it may
not use). Names are set with textContent. The room's chat works without this file.
-- A 1:1 call's page (/call/<channel>, cfg.call.auto) joins as it opens; while its caller waits (cfg.call.ring) it says
++ A 1:1 call's page (/call/<channel>) joins only when the person presses "Turn on microphone":
++ opening or refreshing the page asks for nothing. While its caller waits (cfg.call.ring) it says
ringing and listens on a /ws/chat socket for the callee's vc-call-response: accepted (they are on their way),
declined, busy or no-answer (then the call is left). */
(function () {
@@@ -162,7 +163,7 @@
if (m.type !== 'vc-call-response' || !ringing || String(m.channelId) !== String(cfg.call.channel)) return;
if (Number(m.fromUserId) !== Number(ringing.id)) return;
var who = ringing.name, st = String(m.status || '').toLowerCase();
-- if (st === 'accepted') { status(who + ' answered. Connecting…'); return; }
++ if (st === 'accepted') { status(joined ? who + ' answered. Connecting…' : who + ' answered. Press "Turn on microphone" to join.'); return; }
stopRinging();
teardown(st === 'busy' ? who + ' is in another call.' : st === 'no-answer' ? 'No answer from ' + who + '.' : who + ' declined the call.');
};
@@@ -224,5 +225,5 @@
});
window.addEventListener('pagehide', function () { stopRinging(); if (joined) teardown(null); });
if (ringing) listenForAnswer();
-- if (cfg.call.auto) join();
++ // The microphone opens only from the "Turn on microphone" press above — no auto-join on load.
})();
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 66b2832..8b4486e 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -12,7 +12,8 @@
* POST /messages/:id/block blocks the other person of a 1:1 conversation;
* POST /messages/:id/call rings them (the call-user route) and opens /call/<channel>
* GET /call/:channel a 1:1 call's page (its caller and the people it invited): public/web/call.js joins
-- * it, and while the caller waits it says ringing until the answer comes
++ * it only when the person presses "Turn on microphone", and while the caller waits
++ * it says ringing until the answer comes
* GET /rooms, /r/:slug rooms: community, call (a voice/video call in the page, public/web/call.js)
* and system (staff announcements); /r/:slug/settings for moderators
* GET /settings your portable chat settings (Network module chat.preferences); POST saves
@@@ -668,13 +669,14 @@ ${composer}`,
: '<li class="oc-muted">Nobody is in the call yet.</li>';
await page(req, res, 200, {
actor, title, path: `/call/${ch.id}`, active: 'messages', robots: 'noindex, nofollow', callScript: true,
-- page: { view: 'call', me: me.id, call: { channel: ch.id, join: true, talk: true, auto: true, ring: ringing ? { id: Number(ringing.id), name: nameOf(ringing) } : null } },
++ page: { view: 'call', me: me.id, call: { channel: ch.id, join: true, talk: true, ring: ringing ? { id: Number(ringing.id), name: nameOf(ringing) } : null } },
body: `<p><a href="${backLink}">← Back to the conversation</a></p>
<section class="oc-call oc-call-page" aria-labelledby="oc-call-h" id="oc-call">
<h1 id="oc-call-h">${esc(title)} <span class="oc-muted oc-call-count" id="oc-call-count">${ch.participantCount ? `· ${ch.participantCount} in` : ''}</span></h1>
<p class="oc-muted" id="oc-call-how">A voice call, peer to peer: your microphone goes only to the people in it.</p>
<ul class="oc-call-people" id="oc-call-people">${people}</ul>
--<p class="oc-call-controls"><button type="button" id="oc-call-join" hidden>Join the call</button> <button type="button" id="oc-call-mute" hidden aria-pressed="false">Mute</button> <button type="button" id="oc-call-leave" hidden>Leave the call</button></p>
++<p class="oc-call-controls"><button type="button" id="oc-call-join" hidden>Turn on microphone</button> <button type="button" id="oc-call-mute" hidden aria-pressed="false">Mute</button> <button type="button" id="oc-call-leave" hidden>Leave the call</button></p>
++<p class="oc-muted oc-call-join-note">Join the voice call with the people listed above. Nothing is asked until you press it.</p>
<p class="oc-muted oc-call-status" id="oc-call-status" role="status" aria-live="polite"></p>
<noscript><p class="oc-muted">A call needs JavaScript.</p></noscript>
</section>`,
ddiff --git a/test/capture-on-load.test.js b/test/capture-on-load.test.js
nnew file mode 100644
iindex 0000000..df730c0
---- /dev/null
++++ b/test/capture-on-load.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * No capture until a press: the 1:1 call page (public/web/call.js) must not ask for
++ * the microphone when it opens. Only pressing "Turn on microphone" (#oc-call-join)
++ * may call getUserMedia. A browser test with a getUserMedia spy in the style of
++ * dm-calls.test.js runScript, plus source checks.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++function runCallPage(page) {
++ const byId = {}, created = [], sockets = [], fetches = [];
++ let gumCalls = 0;
++ const node = (id = null) => ({
++ id, hidden: false, disabled: false, textContent: '', className: '', children: [], attrs: {}, handlers: {},
++ appendChild(c) { this.children.push(c); return c; }, setAttribute(k, v) { this.attrs[k] = v; },
++ removeAttribute(k) { delete this.attrs[k]; },
++ addEventListener(ev, fn) { this.handlers[ev] = fn; }, remove() { this.removed = true; }, focus() {},
++ querySelector: () => null,
++ });
++ function WebSocket(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }
++ WebSocket.prototype.send = function (d) { this.sent.push(JSON.parse(d)); };
++ WebSocket.prototype.close = function () { this.closed = true; };
++ function RTCPeerConnection() { this.connectionState = 'new'; }
++ RTCPeerConnection.prototype.close = function () {};
++ const document = {
++ cookie: '', hidden: false, title: 'Call',
++ getElementById: (id) => (byId[id] = byId[id] || node(id)),
++ createElement: (tag) => { const n = node(); n.tag = tag; created.push(n); return n; },
++ createTextNode: (text) => ({ textContent: text }),
++ querySelector: () => null, addEventListener() {}, body: node('body'),
++ };
++ const ctx = {
++ window: { __OV_PAGE: { chat: page }, RTCPeerConnection, WebSocket, addEventListener() {} },
++ WebSocket, RTCPeerConnection,
++ navigator: { mediaDevices: { getUserMedia: async () => { gumCalls++; return { getTracks: () => [] }; } } },
++ location: { protocol: 'https:', host: 'openvibe.chat', href: '' },
++ document, JSON, Number, String, Map, Promise,
++ fetch: async (url) => { fetches.push(url); return { ok: true, json: async () => ({}) }; },
++ setTimeout: (fn) => 0, clearTimeout: () => {}, setInterval: () => 0,
++ };
++ vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'call.js'), 'utf8'), ctx);
++ return { byId, sockets, fetches, gumCalls: () => gumCalls };
++}
++
++// Loading the call page asks for nothing — even with the legacy auto flag present,
++// the client must ignore it and wait for the press.
++for (const page of [{ view: 'call', call: { channel: 'user-1-abc', join: true, talk: true } }, { view: 'call', call: { channel: 'user-1-abc', join: true, talk: true, auto: true } }]) {
++ const s = runCallPage(page);
++ assert.strictEqual(s.gumCalls(), 0, 'page load must not call getUserMedia');
++ assert.ok(!s.sockets.some((x) => /\/ws\/call/.test(x.url)), 'page load must not open the call socket');
++ assert.strictEqual(s.byId['oc-call-join'].hidden, false, 'the join button is shown');
++ assert.ok(s.byId['oc-call-join'].handlers.click, 'the join button has a click handler');
++
++ // The press is what opens the microphone.
++ s.byId['oc-call-join'].handlers.click();
++ assert.strictEqual(s.gumCalls(), 1, 'pressing Turn on microphone calls getUserMedia once');
++ console.log('OK capture-on-load: no getUserMedia until the Turn on microphone click');
++}
++
++// Source checks: the server must not re-arm auto-join, and the button must be the press.
++{
++ const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'web', 'pages.js'), 'utf8');
++ assert.ok(!/auto:\s*true/.test(pages), 'GET /call/:channel must not set auto:true');
++ const callJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'call.js'), 'utf8');
++ assert.ok(!/cfg\.call\.auto/.test(callJs), 'call.js must not auto-join on load');
++ assert.ok(pages.includes('>Turn on microphone</button>'), 'the call page button says Turn on microphone');
++ assert.ok(pages.includes('oc-call-join-note'), 'one line under the button says what it is for');
++ console.log('OK capture-on-load: no auto-join in server or client source');
++}
++
++console.log('✅ capture on load test passed');
ddiff --git a/test/dm-calls.test.js b/test/dm-calls.test.js
iindex f792099..ed450c7 100644
---- a/test/dm-calls.test.js
++++ b/test/dm-calls.test.js
@@@ -105,7 +105,9 @@ t('Call rings the other person (a direct row) and opens the call page with the r
assert.match(mine.text, /<script src="\/web\/call\.js\?v=[^"]+" defer><\/script>/);
assert.match(mine.text, new RegExp(`href="/messages/${conv}"`), 'back to the conversation');
assert.match(mine.text, /<meta name="robots" content="noindex, nofollow">/);
-- assert.deepStrictEqual(pageJson(mine.text).call, { channel: channelId, join: true, talk: true, auto: true, ring: { id: Number(bob.id), name: 'Bob' } });
++ assert.deepStrictEqual(pageJson(mine.text).call, { channel: channelId, join: true, talk: true, ring: { id: Number(bob.id), name: 'Bob' } });
++ assert.match(mine.text, /<button type="button" id="oc-call-join" hidden>Turn on microphone<\/button>/);
++ assert.match(mine.text, /oc-call-join-note/);
const his = await req('GET', `/call/${encodeURIComponent(channelId)}`, { headers: cookie(bob) });
assert.strictEqual(his.status, 200);
@@@ -166,14 +168,16 @@ t('ring.js: a ring shows Answer and Decline; answering tells the caller and open
t('call.js on the caller\'s page: ringing until the answer; a decline, busy or no answer leaves the call', async () => {
const signed = `ov_token=${encodeURIComponent(alice.token)}`;
for (const [status, text] of [['declined', 'Bob declined the call.'], ['busy', 'Bob is in another call.'], ['no-answer', 'No answer from Bob.']]) {
-- const s = runScript('call.js', { view: 'call', call: { channel: 'user-1-abc', join: true, talk: true, auto: true, ring: { id: 2, name: 'Bob' } } }, { docCookie: signed });
++ const s = runScript('call.js', { view: 'call', call: { channel: 'user-1-abc', join: true, talk: true, ring: { id: 2, name: 'Bob' } } }, { docCookie: signed });
const chat = s.sockets.find((x) => /\/ws\/chat$/.test(x.url));
assert.ok(chat, 'the caller listens for the answer on /ws/chat');
chat.onopen();
assert.deepStrictEqual(chat.sent[0], { type: 'join', token: alice.token });
++ assert.ok(!s.sockets.some((x) => /\/ws\/call/.test(x.url)), 'the page open asks for nothing: no call socket yet');
++ s.byId['oc-call-join'].handlers.click();
await tick(); await tick();
const call = s.sockets.find((x) => /\/ws\/call\?channelId=user-1-abc$/.test(x.url));
-- assert.ok(call, 'and joins its call as the page opens');
++ assert.ok(call, 'and joins its call on the Turn on microphone press');
call.onopen();
call.onmessage({ data: JSON.stringify({ type: 'welcome', peerId: 'p1', participants: [{ peerId: 'p1', displayName: 'Alice' }] }) });
assert.strictEqual(s.byId['oc-call-status'].textContent, 'Ringing Bob…');
@@@ -184,8 +188,9 @@ t('call.js on the caller\'s page: ringing until the answer; a decline, busy or n
assert.strictEqual(s.byId['oc-call-status'].textContent, text);
assert.ok(chat.closed && call.closed, `${status}: the call is left and the ring socket closed`);
}
-- const s = runScript('call.js', { view: 'call', call: { channel: 'user-1-abc', join: true, talk: true, auto: true, ring: { id: 2, name: 'Bob' } } }, { docCookie: signed });
++ const s = runScript('call.js', { view: 'call', call: { channel: 'user-1-abc', join: true, talk: true, ring: { id: 2, name: 'Bob' } } }, { docCookie: signed });
const chat = s.sockets[0];
++ s.byId['oc-call-join'].handlers.click();
await tick(); await tick();
const call = s.sockets.find((x) => /\/ws\/call/.test(x.url));
call.onmessage({ data: JSON.stringify({ type: 'welcome', peerId: 'p1', participants: [{ peerId: 'p1' }] }) });