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)
Changes
@@@ -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@@@ -2,7 +2,10 @@Live's call client), signalled over /ws/call?channelId=room-<slug> with the protocol ofserver/calls/call-server.js: the newcomer offers to everyone already in, the others answer. The room'sroles 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 stalevar 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();})();@@@ -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); }nnew file mode 100644@@@ -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();++})();@@@ -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>@@@ -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;nnew file mode 100644@@@ -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(); });
@@ -211,8 +211,8 @@ which path delivered each deploy first and when the other one arrived.
@@ -211,8 +211,8 @@ which path delivered each deploy first and when the other one arrived.
### Calls
### 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),
- **`/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`,
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`):
@@ -228,6 +228,14 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
`POST /internal/calls/stream-channel { stream_id, mode, user_id }` and
`POST /internal/calls/stream-channel { stream_id, mode, user_id }` and
`DELETE /internal/calls/stream-channel/:streamId` — a Live service token with capability
`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.
`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 →
- **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),
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
`declined` (declined or busy) or `failed` (the invite could not be delivered, with the reason); a
@@ -2,7 +2,10 @@
@@ -2,7 +2,10 @@
Live's call client), signalled over /ws/call?channelId=room-<slug> with the protocol of
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
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
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 () {
(function () {
'use strict';
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
@@ -16,6 +19,8 @@
@@ -16,6 +19,8 @@
var ws = null, myPeerId = null, localStream = null, muted = false, forceMuted = !canTalk, joined = false, connecting = false;
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 attempt = 0; // each join; a teardown makes an attempt still starting stale
var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
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 peers = new Map(); // peerId → { pc, audio, pending: [candidates] }
var people = new Map(); // peerId → participant info from the server
var people = new Map(); // peerId → participant info from the server
@@ -44,6 +49,7 @@
@@ -44,6 +49,7 @@
joinBtn.hidden = joined;
joinBtn.hidden = joined;
joinBtn.disabled = connecting;
joinBtn.disabled = connecting;
leaveBtn.hidden = !joined;
leaveBtn.hidden = !joined;
+ leaveBtn.textContent = ringing ? 'Cancel' : 'Leave the call';
muteBtn.hidden = !joined || !localStream;
muteBtn.hidden = !joined || !localStream;
muteBtn.disabled = forceMuted;
muteBtn.disabled = forceMuted;
muteBtn.textContent = muted || forceMuted ? 'Unmute' : 'Mute';
muteBtn.textContent = muted || forceMuted ? 'Unmute' : 'Mute';
@@ -91,11 +97,12 @@
@@ -91,11 +97,12 @@
(m.participants || []).forEach(function (p) { people.set(p.peerId, p); });
(m.participants || []).forEach(function (p) { people.set(p.peerId, p); });
if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) { rejoin(m.canTalk); return; }
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); });
(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();
render(); applyMute();
break;
break;
case 'peer-joined':
case 'peer-joined':
people.set(m.peerId, m); createPeer(m.peerId, false); render();
people.set(m.peerId, m); createPeer(m.peerId, false); render();
+ if (ringing) { stopRinging(); status('You are in the call.'); }
break;
break;
case 'peer-left':
case 'peer-left':
people.delete(m.peerId); closePeer(m.peerId); render();
people.delete(m.peerId); closePeer(m.peerId); render();
@@ -145,8 +152,30 @@
@@ -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) {
function teardown(text) {
attempt++;
attempt++;
+ stopRinging();
connecting = false;
connecting = false;
peers.forEach(function (p, id) { closePeer(id); });
peers.forEach(function (p, id) { closePeer(id); });
people.clear();
people.clear();
@@ -187,11 +216,13 @@
@@ -187,11 +216,13 @@
joinBtn.hidden = false;
joinBtn.hidden = false;
joinBtn.addEventListener('click', join);
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 () {
muteBtn.addEventListener('click', function () {
if (forceMuted) return;
if (forceMuted) return;
muted = !muted; applyMute();
muted = !muted; applyMute();
send({ type: 'mute', muted: muted });
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();
})();
})();
@@ -102,6 +102,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -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 { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }
.oc-call-controls button[hidden] { display: none; }
.oc-call-controls button[hidden] { display: none; }
.oc-call-status:empty { 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-tools { display: block; margin-top: 8px; }
.oc-blocked { margin-top: 20px; }
.oc-blocked { margin-top: 20px; }
.oc-blocked summary { cursor: pointer; color: var(--oc-muted); }
.oc-blocked summary { cursor: pointer; color: var(--oc-muted); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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();
+})();
@@ -93,7 +93,8 @@ function contentSecurityPolicy(nonce) {
@@ -93,7 +93,8 @@ function contentSecurityPolicy(nonce) {
* o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js), nonce (the
* 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),
* response's CSP nonce: every inline <script> carries it),
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* 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) {
function renderPage(o) {
const { config } = o;
const { config } = o;
@@ -128,6 +129,7 @@ ${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` :
@@ -128,6 +129,7 @@ ${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` :
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
+${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
</head>
</head>
<body class="${cls}"${scale}>
<body class="${cls}"${scale}>
<a class="oc-skip" href="#main">Skip to content</a>
<a class="oc-skip" href="#main">Skip to content</a>
@@ -9,7 +9,10 @@
@@ -9,7 +9,10 @@
* GET /messages your inbox: conversations with unread counts, people you blocked;
* GET /messages your inbox: conversations with unread counts, people you blocked;
* POST /messages/new starts one by username; POST /messages/unblock/:userId
* POST /messages/new starts one by username; POST /messages/unblock/:userId
* GET /messages/:id one conversation (participants only); POST /messages/:id replies;
* 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)
* 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
* and system (staff announcements); /r/:slug/settings for moderators
* GET /settings your portable chat settings (Network module chat.preferences); POST saves
* GET /settings your portable chat settings (Network module chat.preferences); POST saves
@@ -168,7 +171,7 @@ function createWebRoutes({ config }) {
@@ -168,7 +171,7 @@ function createWebRoutes({ config }) {
const actor = o.actor || await viewerOf(req);
const actor = o.actor || await viewerOf(req);
const nonce = require('crypto').randomBytes(16).toString('base64');
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')
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 needUser = async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
@@ -317,15 +320,72 @@ ${blockedList}`,
@@ -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>`;
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.');
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>` : '';
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, {
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
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 },
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>
<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}`,
${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) => {
router.post('/messages/:id/block', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -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(); });