openvibers/openvibe.chat Commits

Calls from a conversation on openvibe.chat: Call <name> on a 1:1 conversation rings them and opens the call's page (ringing until they answer, decline, are busy or do not answer); every signed-in page answers a ring with Answer or Decline, and a call started on Live or here can be answered on either (#120)

OP OpenVibers committed 25 days ago · 2a0ec06aff · 7 files, +406 −10

Changes

ddiff --git a/README.md b/README.md
iindex 97f208d..0fa6936 100644
---- a/README.md
++++ b/README.md
@@@ -211,8 +211,8 @@ which path delivered each deploy first and when the other one arrived.
### Calls
--Live's voice/video calls moved here (roadmap WS-I task 1, `server/calls/`), **off until the calls
--cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
++Live's voice/video calls moved here (roadmap WS-I task 1, `server/calls/`). They are served with `CHAT_CALLS=1`,
++which production has run since the calls cutover of 2026-10-10 (`docs/calls-cutover.md`):
- **`/ws/call`** — Live's call server with the same protocol: `?channelId=` (or a stream number),
signalling only (full-mesh WebRTC; `offer`/`answer`/`ice-candidate` relayed, `mute`, `camera-off`,
@@@ -228,6 +228,14 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
`POST /internal/calls/stream-channel { stream_id, mode, user_id }` and
`DELETE /internal/calls/stream-channel/:streamId` — a Live service token with capability
`chat.live_bridge.write` (the name is from the retired chat bridge; Live holds the grant), loopback only.
++- **From a conversation on openvibe.chat** (`server/web/pages.js`, `public/web/ring.js`, `public/web/call.js`): a 1:1
++ conversation nobody blocked has **Call <name>**. It rings through `call-user` (its limits, block check and ring row)
++ and opens `/call/<channel>`, the call's page. The page is for its caller and the people it invited (404 to anyone
++ else), and it joins as it opens. While the caller waits it says *Ringing …* and listens on `/ws/chat` for the answer;
++ declined, busy or no answer leaves the call. Every signed-in page has `ring.js`: a ring shows who is calling with
++ **Answer** (tells the caller, opens the call page) and **Decline**. After 30 s it gives up with `no-answer`, and a
++ person already ringing or in a call answers `busy`. The invite reaches Live's chat sockets too, so a call can start
++ on one site and be answered on the other.
- **Lifecycle** (`server/calls/lifecycle.js`, table `calls`): a ring is `direct`, `pending → ringing →
active → ended`, or `missed` (no answer within `CALL_RING_TIMEOUT_MS`, 45 s; the caller is told),
`declined` (declined or busy) or `failed` (the invite could not be delivered, with the reason); a
ddiff --git a/public/web/call.js b/public/web/call.js
iindex 7033799..be0f024 100644
---- a/public/web/call.js
++++ b/public/web/call.js
@@@ -2,7 +2,10 @@
Live's call client), signalled over /ws/call?channelId=room-<slug> with the protocol of
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. */
++ 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
++ 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 () {
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
@@@ -16,6 +19,8 @@
var ws = null, myPeerId = null, localStream = null, muted = false, forceMuted = !canTalk, joined = false, connecting = false;
var attempt = 0; // each join; a teardown makes an attempt still starting stale
var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
++ var ringing = cfg.call.ring && cfg.call.ring.id ? cfg.call.ring : null; // the callee the caller waits for
++ var ringSock = null;
var peers = new Map(); // peerId → { pc, audio, pending: [candidates] }
var people = new Map(); // peerId → participant info from the server
@@@ -44,6 +49,7 @@
joinBtn.hidden = joined;
joinBtn.disabled = connecting;
leaveBtn.hidden = !joined;
++ leaveBtn.textContent = ringing ? 'Cancel' : 'Leave the call';
muteBtn.hidden = !joined || !localStream;
muteBtn.disabled = forceMuted;
muteBtn.textContent = muted || forceMuted ? 'Unmute' : 'Mute';
@@@ -91,11 +97,12 @@
(m.participants || []).forEach(function (p) { people.set(p.peerId, p); });
if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) { rejoin(m.canTalk); return; }
(m.participants || []).forEach(function (p) { if (p.peerId !== myPeerId) createPeer(p.peerId, true); });
-- status(canTalk ? 'You are in the call.' : 'You are listening.');
++ status(ringing && people.size <= 1 ? 'Ringing ' + ringing.name + '…' : canTalk ? 'You are in the call.' : 'You are listening.');
render(); applyMute();
break;
case 'peer-joined':
people.set(m.peerId, m); createPeer(m.peerId, false); render();
++ if (ringing) { stopRinging(); status('You are in the call.'); }
break;
case 'peer-left':
people.delete(m.peerId); closePeer(m.peerId); render();
@@@ -145,8 +152,30 @@
}
}
++ // The callee's answer reaches the caller's chat sockets (call-user/respond → vc-call-response).
++ function listenForAnswer() {
++ if (!ringing || ringSock) return;
++ var sock = ringSock = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
++ sock.onopen = function () { sock.send(JSON.stringify({ type: 'join', token: sessionToken() || undefined })); };
++ sock.onmessage = function (ev) {
++ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
++ 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; }
++ stopRinging();
++ teardown(st === 'busy' ? who + ' is in another call.' : st === 'no-answer' ? 'No answer from ' + who + '.' : who + ' declined the call.');
++ };
++ }
++ function stopRinging() {
++ ringing = null;
++ if (ringSock) { try { ringSock.close(); } catch (e) { /* */ } ringSock = null; }
++ setButtons();
++ }
++
function teardown(text) {
attempt++;
++ stopRinging();
connecting = false;
peers.forEach(function (p, id) { closePeer(id); });
people.clear();
@@@ -187,11 +216,13 @@
joinBtn.hidden = false;
joinBtn.addEventListener('click', join);
-- leaveBtn.addEventListener('click', function () { teardown('You left the call.'); });
++ leaveBtn.addEventListener('click', function () { teardown(ringing ? 'Call canceled.' : 'You left the call.'); });
muteBtn.addEventListener('click', function () {
if (forceMuted) return;
muted = !muted; applyMute();
send({ type: 'mute', muted: muted });
});
-- window.addEventListener('pagehide', function () { if (joined) teardown(null); });
++ window.addEventListener('pagehide', function () { stopRinging(); if (joined) teardown(null); });
++ if (ringing) listenForAnswer();
++ if (cfg.call.auto) join();
})();
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 5456393..29e517d 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -102,6 +102,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-call-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }
.oc-call-controls button[hidden] { display: none; }
.oc-call-status:empty { display: none; }
++.oc-call-page h1 { margin: 0 0 4px; font-size: 1.35rem; }
++.oc-dm-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
++.oc-dm-head h1 { margin: 0; }
++.oc-dm-call button { padding: 7px 14px; }
++/* A call coming in (public/web/ring.js): above everything, bottom right; full width on phones. */
++.oc-ring { position: fixed; right: 16px; bottom: 16px; z-index: 50; max-width: min(360px, calc(100vw - 32px)); margin: 0; padding: 14px 16px; background: var(--oc-panel); border: 1px solid var(--oc-accent); border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, .45); }
++.oc-ring-text { margin: 0 0 10px; }
++.oc-ring-actions { display: flex; gap: 8px; margin: 0; }
++.oc-ring-answer { background: var(--oc-ok); color: #06281c; }
++.oc-ring-gone { color: var(--oc-muted); }
++@media (max-width: 520px) { .oc-ring { left: 16px; right: 16px; max-width: none; } }
++@media (prefers-reduced-motion: no-preference) { .oc-ring:not(.oc-ring-gone) { animation: oc-ring-in .25s ease-out; } @keyframes oc-ring-in { from { transform: translateY(12px); opacity: 0; } } }
.oc-tools { display: block; margin-top: 8px; }
.oc-blocked { margin-top: 20px; }
.oc-blocked summary { cursor: pointer; color: var(--oc-muted); }
ddiff --git a/public/web/ring.js b/public/web/ring.js
nnew file mode 100644
iindex 0000000..ec73d3d
---- /dev/null
++++ b/public/web/ring.js
@@@ -0,0 +1,83 @@
++/* openvibe.chat incoming calls: on every page of a signed-in person, a /ws/chat socket waits for the vc-call-invite
++ that POST /api/streams/voice-channels/call-user pushes to the callee's sockets (Live's chat sockets get the same
++ frame). It shows who is calling with Answer and Decline; Answer tells the caller (call-user/respond) and opens the
++ call's page, Decline tells them too, and a ring nobody answers gives up after 30 seconds with "no-answer", as
++ Live's client does. Already in a call on this page: "busy". Names are set with textContent. */
++(function () {
++ 'use strict';
++ if (!window.WebSocket || !window.fetch) return;
++ var RING_MS = 30000;
++ var page = (window.__OV_PAGE && window.__OV_PAGE.chat) || {};
++ var ring = null; // { msg, el, timer }
++
++ function sessionToken() {
++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
++ return m ? decodeURIComponent(m[1]) : null;
++ }
++ if (!sessionToken()) return;
++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
++
++ function respond(msg, status) {
++ return fetch('/api/streams/voice-channels/call-user/respond', {
++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ caller_user_id: Number(msg.fromUserId), channel_id: String(msg.channelId), channel_name: String(msg.channelName || 'Voice Channel'), status: status }),
++ }).then(function (r) { return r.ok; }, function () { return false; });
++ }
++ function dismiss() {
++ if (!ring) return;
++ clearTimeout(ring.timer);
++ if (ring.el) ring.el.remove();
++ document.title = ring.title;
++ ring = null;
++ }
++ // The call page's own call: a person in it is busy for another ring.
++ function inCall() { return page.view === 'call' && !!document.querySelector('#oc-call-leave:not([hidden])'); }
++
++ function show(msg) {
++ if (!msg.fromUserId || !msg.channelId) return;
++ if (ring && ring.msg.channelId === msg.channelId) return;
++ if (ring || inCall()) { respond(msg, 'busy'); return; }
++ var who = msg.fromDisplayName || msg.fromUsername || 'Someone';
++ var box = el('div', 'oc-ring');
++ box.setAttribute('role', 'alertdialog');
++ box.setAttribute('aria-live', 'assertive');
++ box.setAttribute('aria-label', who + ' is calling you');
++ var text = el('p', 'oc-ring-text');
++ text.appendChild(el('strong', null, who));
++ text.appendChild(document.createTextNode(' is calling you'));
++ var actions = el('p', 'oc-ring-actions');
++ var answer = el('button', 'oc-ring-answer', 'Answer');
++ answer.type = 'button';
++ var decline = el('button', 'oc-button--quiet', 'Decline');
++ decline.type = 'button';
++ actions.appendChild(answer); actions.appendChild(decline);
++ box.appendChild(text); box.appendChild(actions);
++ document.body.appendChild(box);
++ ring = { msg: msg, el: box, title: document.title, timer: setTimeout(function () { var m = ring && ring.msg; dismiss(); if (m) respond(m, 'no-answer'); }, RING_MS) };
++ document.title = who + ' is calling · ' + ring.title;
++ answer.addEventListener('click', function () {
++ var m = ring && ring.msg; if (!m) return;
++ answer.disabled = decline.disabled = true;
++ respond(m, 'accepted').then(function (ok) {
++ dismiss();
++ if (ok) location.href = '/call/' + encodeURIComponent(m.channelId);
++ else { var gone = el('p', 'oc-ring oc-ring-gone', 'That call has ended.'); gone.setAttribute('role', 'status'); document.body.appendChild(gone); setTimeout(function () { gone.remove(); }, 4000); }
++ });
++ });
++ decline.addEventListener('click', function () { var m = ring && ring.msg; dismiss(); if (m) respond(m, 'declined'); });
++ answer.focus();
++ }
++
++ var delay = 2000, stopped = false;
++ function connect() {
++ var ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
++ ws.onopen = function () { delay = 2000; ws.send(JSON.stringify({ type: 'join', token: sessionToken() || undefined })); };
++ ws.onmessage = function (ev) {
++ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
++ if (m.type === 'vc-call-invite') show(m);
++ else if (m.type === 'auth_revoked') { stopped = true; try { ws.close(); } catch (e) { /* */ } }
++ };
++ ws.onclose = function () { if (!stopped && sessionToken()) setTimeout(connect, delay = Math.min(delay * 2, 60000)); };
++ }
++ connect();
++})();
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex cc19efb..7f1daea 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -93,7 +93,8 @@ function contentSecurityPolicy(nonce) {
* o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js), nonce (the
* response's CSP nonce: every inline <script> carries it),
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
-- * rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)
++ * rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
++ * coming in, on every page of a signed-in person while calls are on)
*/
function renderPage(o) {
const { config } = o;
@@@ -128,6 +129,7 @@ ${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` :
<script src="${ovServe.url('footer.js')}" defer></script>
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
++${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
</head>
<body class="${cls}"${scale}>
<a class="oc-skip" href="#main">Skip to content</a>
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 95e9be9..f50e201 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -9,7 +9,10 @@
* GET /messages your inbox: conversations with unread counts, people you blocked;
* POST /messages/new starts one by username; POST /messages/unblock/:userId
* GET /messages/:id one conversation (participants only); POST /messages/:id replies;
-- * POST /messages/:id/block blocks the other person of a 1:1 conversation
++ * 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
* 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
@@@ -168,7 +171,7 @@ function createWebRoutes({ config }) {
const actor = o.actor || await viewerOf(req);
const nonce = require('crypto').randomBytes(16).toString('base64');
res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).set('Content-Security-Policy', contentSecurityPolicy(nonce)).type('html')
-- .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), nonce, ...o }));
++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), nonce, ringScript: actor.kind === 'user' && !!(config.calls && config.calls.enabled), ...o }));
};
const needUser = async (req, res) => {
const actor = await viewerOf(req);
@@@ -317,15 +320,72 @@ ${blockedList}`,
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
else if (closed) composer = notice('error', 'You cannot message this person.');
const tools = other && !iBlocked ? `<form class="oc-inline oc-tools" method="post" action="/messages/${id}/block"><button type="submit" class="oc-link">Block ${otherName}</button></form>` : '';
++ // A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).
++ const callForm = other && !closed && require('../config').calls.enabled
++ ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-button--quiet">Call ${otherName}</button></form>` : '';
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
-- body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}
++ body: `<p><a href="/messages">← Messages</a></p><div class="oc-dm-head"><h1>${esc(title)}</h1>${callForm}</div>${error}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
${composer}${tools}`,
});
});
++ // Ring the other person of a 1:1 conversation through the call-user route (its limits, block check and ring
++ // lifecycle), then wait for them on the call's page.
++ router.post('/messages/:id/call', formLimit, sameSite, async (req, res, next) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const me = actor.user;
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');
++ const back = (error) => res.redirect(303, `/messages/${id}?error=${encodeURIComponent(error)}`);
++ if (!require('../config').calls.enabled) return back('Calls are not available right now.');
++ const conv = await dm.getConversation(id);
++ const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);
++ if (!conv || conv.is_group || others.length !== 1) return back('Only a one-to-one conversation has someone to call.');
++ const other = others[0];
++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
++ return apiBridge(require('../calls/routes'), '/voice-channels/call-user', {
++ body: () => ({ user_id: other.id }),
++ ok: (r, s, d) => (d.channel && d.channel.id ? s.redirect(303, `/call/${encodeURIComponent(d.channel.id)}?to=${Number(other.id)}&from=${id}`) : back('The call could not start.')),
++ fail: (r, s, status, error) => back(error),
++ })(req, res, next);
++ });
++
++ router.get('/call/:channel', async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const me = actor.user;
++ const callServer = require('../calls/call-server');
++ const ch = require('../config').calls.enabled ? await callServer.getChannel(String(req.params.channel).slice(0, 80), me) : null;
++ const ended = '<h1>This call has ended</h1><p><a href="/messages">Back to your messages</a></p>';
++ // Only a person's own call channel is a call page; a room's or a stream's call lives on its own page.
++ if (!ch || ch.roomSlug || ch.streamId || ch.permanent) return await page(req, res, 404, { actor, title: 'No such call', path: req.path, robots: 'noindex', body: ended });
++ const nameOf = (u) => (u ? u.display_name || u.username : 'Someone');
++ const toId = parseInt(req.query.to, 10);
++ const ringing = ch.createdBy === me.id && toId > 0 && callServer.hasInvite(ch.id, toId) ? await ctx.getUserById(toId) : null;
++ const host = ch.createdBy !== me.id ? await ctx.getUserById(ch.createdBy) : null;
++ const title = ringing ? `Calling ${nameOf(ringing)}` : host ? `Call with ${nameOf(host)}` : ch.name;
++ const from = parseInt(req.query.from, 10);
++ const backLink = Number.isInteger(from) && await dm.isParticipant(from, me.id) ? `/messages/${from}` : '/messages';
++ const people = ch.participants.length
++ ? ch.participants.map((p) => `<li>${esc(p.displayName || p.username || 'Guest')}</li>`).join('')
++ : '<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 } },
++ 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-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>`,
++ });
++ });
++
router.post('/messages/:id/block', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
ddiff --git a/test/dm-calls.test.js b/test/dm-calls.test.js
nnew file mode 100644
iindex 0000000..d988a40
---- /dev/null
++++ b/test/dm-calls.test.js
@@@ -0,0 +1,200 @@
++'use strict';
++/**
++ * Calls from direct messages on openvibe.chat: a 1:1 conversation's Call button (not in a group, not after a block),
++ * POST /messages/:id/call ringing the other person through the call-user route (a `direct` row, then the call page),
++ * GET /call/:channel for its caller ("Calling …", with the ring) and the person it invited ("Call with …") and nobody
++ * else; ring.js on signed-in pages (Answer → accepted and the call page, Decline, no answer after 30 s, busy while
++ * already ringing) and call.js on the caller's page (ringing until the answer: declined, busy or no-answer leave it).
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('dm-calls');
++let h, alice, bob, carol, dm, callServer, lifecycle;
++const SITE = 'https://openvibe.chat';
++const cookie = (u) => ({ cookie: `ov_token=${u.token}` });
++const req = async (method, path, { headers = {}, raw } = {}) => {
++ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });
++ return { status: r.status, headers: r.headers, text: await r.text() };
++};
++const form = (u, fields = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: new URLSearchParams(fields).toString() });
++const pageJson = (html) => JSON.parse((/window\.__OV_PAGE = (\{.*?\});\n/.exec(html) || [])[1].replace(/\\u003c/g, '<')).chat;
++
++/** A page script in a small fake DOM: elements by id (kept), created nodes, sockets, fetches and timers. */
++function runScript(file, page, { docCookie = '', leaveVisible = false } = {}) {
++ const byId = {}, created = [], sockets = [], fetches = [], timers = [];
++ const node = (id = null) => {
++ const n = { id, hidden: id === 'oc-call-leave' ? !leaveVisible : 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 };
++ return n;
++ };
++ 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; };
++ const location = { protocol: 'https:', host: 'openvibe.chat', href: '' };
++ const document = {
++ cookie: docCookie, hidden: false, title: 'Messages · OpenVibe.Chat',
++ 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: (sel) => { if (sel !== '#oc-call-leave:not([hidden])') return null; const n = document.getElementById('oc-call-leave'); return n.hidden ? null : n; },
++ addEventListener() {}, body: node('body'),
++ };
++ function RTCPeerConnection() { this.connectionState = 'new'; }
++ RTCPeerConnection.prototype.close = function () {};
++ const ctx = {
++ window: { __OV_PAGE: { chat: page }, RTCPeerConnection, WebSocket, fetch: true, addEventListener() {} },
++ WebSocket, RTCPeerConnection, navigator: {}, location, document, JSON, Number, String, Map, Promise,
++ fetch: async (url, opts) => { fetches.push({ url, body: opts && opts.body ? JSON.parse(opts.body) : null }); return { ok: true, json: async () => ({}) }; },
++ setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clearTimeout: (i) => { if (timers[i - 1]) timers[i - 1].cleared = true; }, setInterval: () => 0,
++ };
++ require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', file), 'utf8'), ctx);
++ return { byId, created, sockets, fetches, timers, location, document };
++}
++const tick = () => new Promise((r) => setImmediate(r));
++
++t('boot (CHAT_CALLS=1)', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_CALLS: '1', CALL_RING_TIMEOUT_MS: '60000' } });
++ dm = require('../server/chat/dm');
++ callServer = require('../server/calls/call-server');
++ lifecycle = require('../server/calls/lifecycle');
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ carol = h.addUser('carol', { subject: ids.newId('user') });
++ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
++ assert.strictEqual(require('../server/config').calls.enabled, true);
++});
++
++let conv, channelId;
++t('a 1:1 conversation has a Call button; a group or a blocked one has none; signed-in pages load ring.js', async () => {
++ conv = await dm.getOrCreateDirect(alice.id, bob.id);
++ const page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, new RegExp(`<form class="oc-inline oc-dm-call" method="post" action="/messages/${conv}/call"><button type="submit" class="oc-button--quiet">Call Bob</button></form>`, 'i'));
++ assert.match(page.text, /<script src="\/web\/ring\.js\?v=[^"]+" defer><\/script>/);
++ assert.doesNotMatch((await req('GET', '/')).text, /ring\.js/, 'no ring listener for a guest');
++ const group = await dm.createConversation(alice.id, [alice.id, bob.id, carol.id], 'Trio');
++ assert.doesNotMatch((await req('GET', `/messages/${group}`, { headers: cookie(alice) })).text, /oc-dm-call/);
++ const withCarol = await dm.getOrCreateDirect(alice.id, carol.id);
++ await dm.blockUser(carol.id, alice.id);
++ assert.doesNotMatch((await req('GET', `/messages/${withCarol}`, { headers: cookie(alice) })).text, /oc-dm-call/, 'blocked either way: no call');
++ const r = await req('POST', `/messages/${withCarol}/call`, form(alice));
++ assert.strictEqual(r.status, 303);
++ assert.match(decodeURIComponent(r.headers.get('location')), /\?error=You cannot call this user/);
++ const g = await req('POST', `/messages/${group}/call`, form(alice));
++ assert.match(decodeURIComponent(g.headers.get('location')), /Only a one-to-one conversation/);
++});
++
++t('Call rings the other person (a direct row) and opens the call page with the ring', async () => {
++ const r = await req('POST', `/messages/${conv}/call`, form(alice));
++ assert.strictEqual(r.status, 303);
++ const loc = r.headers.get('location');
++ const m = /^\/call\/(user-[^?]+)\?to=(\d+)&from=(\d+)$/.exec(loc);
++ assert.ok(m, loc);
++ channelId = decodeURIComponent(m[1]);
++ assert.deepStrictEqual([Number(m[2]), Number(m[3])], [Number(bob.id), Number(conv)]);
++ assert.ok(callServer.hasInvite(channelId, Number(bob.id)), 'bob is invited');
++ const [row] = await lifecycle.list({ channelId, kind: 'direct' });
++ assert.ok(row && ['pending', 'ringing'].includes(row.state), JSON.stringify(row));
++
++ const mine = await req('GET', loc, { headers: cookie(alice) });
++ assert.strictEqual(mine.status, 200);
++ assert.match(mine.text, /<h1 id="oc-call-h">Calling Bob/);
++ 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' } });
++
++ const his = await req('GET', `/call/${encodeURIComponent(channelId)}`, { headers: cookie(bob) });
++ assert.strictEqual(his.status, 200);
++ assert.match(his.text, /<h1 id="oc-call-h">Call with Alice/);
++ assert.strictEqual(pageJson(his.text).call.ring, null, 'the callee is not ringing anyone');
++ assert.strictEqual((await req('GET', `/call/${encodeURIComponent(channelId)}`, { headers: cookie(carol) })).status, 404, 'a private call is nobody else\'s');
++ assert.strictEqual((await req('GET', '/call/user-1-nothing', { headers: cookie(alice) })).status, 404);
++ assert.strictEqual((await req('GET', '/call/public', { headers: cookie(alice) })).status, 404, 'the public lobby is not a 1:1 call page');
++ const anon = await req('GET', `/call/${encodeURIComponent(channelId)}`);
++ assert.strictEqual(anon.status, 303, 'sign in first');
++});
++
++t('ring.js: a ring shows Answer and Decline; answering tells the caller and opens the call page', async () => {
++ const signed = `ov_token=${encodeURIComponent(bob.token)}`;
++ const invite = { type: 'vc-call-invite', channelId: 'user-7-abc', channelName: "Alice's call", fromUserId: 7, fromDisplayName: 'Alice' };
++ let s = runScript('ring.js', { view: 'inbox' }, { docCookie: signed });
++ assert.strictEqual(s.sockets.length, 1);
++ s.sockets[0].onopen();
++ assert.deepStrictEqual(s.sockets[0].sent[0], { type: 'join', token: bob.token });
++ s.sockets[0].onmessage({ data: JSON.stringify(invite) });
++ const box = s.document.body.children[0];
++ assert.ok(box && box.className === 'oc-ring' && box.attrs.role === 'alertdialog', 'the ring is shown');
++ assert.strictEqual(box.attrs['aria-label'], 'Alice is calling you');
++ assert.match(s.document.title, /^Alice is calling · /);
++ const [answer] = box.children[1].children;
++ answer.handlers.click();
++ await tick(); await tick();
++ assert.deepStrictEqual(s.fetches[0], { url: '/api/streams/voice-channels/call-user/respond', body: { caller_user_id: 7, channel_id: 'user-7-abc', channel_name: "Alice's call", status: 'accepted' } });
++ assert.strictEqual(s.location.href, '/call/user-7-abc');
++ assert.ok(box.removed);
++
++ s = runScript('ring.js', { view: 'inbox' }, { docCookie: signed });
++ s.sockets[0].onmessage({ data: JSON.stringify(invite) });
++ s.document.body.children[0].children[1].children[1].handlers.click();
++ await tick();
++ assert.strictEqual(s.fetches[0].body.status, 'declined');
++
++ s = runScript('ring.js', { view: 'inbox' }, { docCookie: signed });
++ s.sockets[0].onmessage({ data: JSON.stringify(invite) });
++ const ringTimer = s.timers.find((x) => x.ms === 30000);
++ assert.ok(ringTimer, 'a ring gives up after 30 seconds');
++ s.sockets[0].onmessage({ data: JSON.stringify({ ...invite, channelId: 'user-9-xyz', fromUserId: 9 }) });
++ await tick();
++ assert.deepStrictEqual([s.fetches[0].body.status, s.fetches[0].body.caller_user_id], ['busy', 9], 'a second ring while ringing: busy');
++ ringTimer.fn();
++ await tick();
++ assert.strictEqual(s.fetches[1].body.status, 'no-answer');
++
++ s = runScript('ring.js', { view: 'call' }, { docCookie: signed, leaveVisible: true });
++ s.sockets[0].onmessage({ data: JSON.stringify(invite) });
++ await tick();
++ assert.strictEqual(s.fetches[0].body.status, 'busy', 'in a call on the call page: busy');
++ assert.strictEqual(s.document.body.children.length, 0);
++
++ assert.strictEqual(runScript('ring.js', { view: 'inbox' }, { docCookie: '' }).sockets.length, 0, 'signed out: no socket');
++});
++
++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 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 });
++ 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');
++ 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…');
++ assert.strictEqual(s.byId['oc-call-leave'].textContent, 'Cancel', 'while it rings, leaving is cancelling');
++ chat.onmessage({ data: JSON.stringify({ type: 'vc-call-response', status: 'accepted', channelId: 'user-1-other', fromUserId: 2 }) });
++ assert.strictEqual(s.byId['oc-call-status'].textContent, 'Ringing Bob…', 'an answer to another call is not this one');
++ chat.onmessage({ data: JSON.stringify({ type: 'vc-call-response', status, channelId: 'user-1-abc', fromUserId: 2 }) });
++ 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 chat = s.sockets[0];
++ 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' }] }) });
++ chat.onmessage({ data: JSON.stringify({ type: 'vc-call-response', status: 'accepted', channelId: 'user-1-abc', fromUserId: 2 }) });
++ assert.strictEqual(s.byId['oc-call-status'].textContent, 'Bob answered. Connecting…');
++ call.onmessage({ data: JSON.stringify({ type: 'peer-joined', peerId: 'p2', displayName: 'Bob' }) });
++ assert.strictEqual(s.byId['oc-call-status'].textContent, 'You are in the call.');
++ assert.ok(chat.closed, 'answered: the ring socket closes');
++ assert.strictEqual(s.byId['oc-call-leave'].textContent, 'Leave the call');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });