openvibers/openvibe.chat Commits
Add stage channels on voice rooms (#454)
Changes
nnew file mode 100644@@@ -0,0 +1,20 @@++-- phase: expand++-- Stage channels are call rooms with per-person speaking permission and stage session metadata.++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS stage_channel boolean NOT NULL DEFAULT false;++++CREATE TABLE IF NOT EXISTS chat_stage_sessions (++ room_id bigint PRIMARY KEY,++ topic text COLLATE "C",++ started_at text COLLATE "C",++ ended_at text COLLATE "C",++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE IF NOT EXISTS chat_stage_speakers (++ room_id bigint NOT NULL,++ user_id bigint NOT NULL,++ hand_raised boolean NOT NULL DEFAULT false,++ invited_by bigint,++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (room_id, user_id)++);@@@ -51,6 +51,7 @@ const EXPORTS = [['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.roles, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],++ ['stage_speaker_actions.json', `SELECT room_id, user_id, hand_raised, invited_by, updated_at FROM chat_stage_speakers WHERE user_id = ANY(@ids) OR invited_by = ANY(@ids) ORDER BY updated_at DESC LIMIT ${LIMIT}`],['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],@@@ -123,6 +124,8 @@ const ERASE_FIRST = [['room_attachments', 'DELETE FROM room_attachments WHERE attached_by_subject = ANY(@s)'],];const ERASE_AFTER_ROOMS = [++ ['stage_speaker_roles', 'DELETE FROM chat_stage_speakers WHERE user_id = ANY(@ids)'],++ [null, 'UPDATE chat_stage_speakers SET invited_by = NULL WHERE invited_by = ANY(@ids)'],['calls', 'DELETE FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids)'],['blocks', 'DELETE FROM network_blocks WHERE blocker_subject = ANY(@s) OR blocked_subject = ANY(@s)'],['blocks', 'DELETE FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) OR blocked_id = ANY(@ids)'],@@@ -12,7 +12,7 @@* POST /:slug/leave* GET /:slug/members (moderators also see who is banned)* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }-- * POST /:slug/channels owner/admins: { name, kind: text | voice, topic? }++ * POST /:slug/channels owner/admins: { name, kind: text | voice | stage, topic? }* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)* GET /:slug/invites owner/admins: the live invites@@@ -61,6 +61,26 @@ async function channelOf(req, res, server) {if (!room) { res.status(404).json({ error: 'No such channel', code: 'servers.no_channel' }); return null; }return room;}++async function stageChannel(req, res, server) {++ const room = await channelOf(req, res, server);++ if (!room) return null;++ if (!room.stage_channel) { res.status(404).json({ error: 'No such stage channel', code: 'stage.not_found' }); return null; }++ return room;++}++async function stageState(room) {++ const session = await require('../db/database').get('SELECT topic, started_at, ended_at FROM chat_stage_sessions WHERE room_id = ?', [room.id]);++ const speakers = await require('../db/database').all('SELECT user_id, hand_raised FROM chat_stage_speakers WHERE room_id = ? ORDER BY hand_raised DESC, updated_at', [room.id]);++ const people = await Promise.all(speakers.map(async (p) => {++ const user = await ctx.getUserById(Number(p.user_id));++ return { user_id: Number(p.user_id), username: user && user.username || null, display_name: user && user.display_name || null, hand_raised: p.hand_raised };++ }));++ return { topic: session && session.topic || null, started_at: session && session.started_at || null, ended_at: session && session.ended_at || null,++ speakers: people.filter((p) => !p.hand_raised), raised_hands: people.filter((p) => p.hand_raised) };++}++async function updateStageAccess(room) {++ await roomRoutes().applyRoomChange(room);++ try { chatServer().broadcastToRoom(room.id, { type: 'stage', room: room.slug, stage: await stageState(room) }); } catch { /* clients can refetch */ }++}const canOf = (a) => ({ read: a.read, member: a.member, moderate: a.moderate, manage: a.manage, owner: a.owner });router.get('/', optionalAuth, async (req, res) => {@@@ -141,6 +161,69 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {} catch (err) { send(res, err); }});++router.get('/:slug/:channel/stage', optionalAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await stageChannel(req, res, server); if (!room) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ stage: await stageState(room), can: canOf(await servers.channelAccess(room, req.user || null)) });++});++router.post('/:slug/:channel/stage/start', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await stageChannel(req, res, server); if (!room) return;++ const can = await servers.access(server, req.user);++ if (!can.moderate) return res.status(403).json({ error: 'Only moderators can start a stage', code: 'stage.not_mod' });++ const db = require('../db/database');++ const topic = String((req.body || {}).topic ?? room.topic ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || null;++ await db.run('UPDATE rooms SET topic = ? WHERE id = ?', [topic, room.id]);++ await db.run('UPDATE chat_stage_sessions SET topic = ?, started_at = ov_now(), ended_at = NULL, updated_at = ov_now() WHERE room_id = ?', [topic, room.id]);++ const fresh = await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]);++ await updateStageAccess(fresh);++ res.json({ stage: await stageState(fresh) });++});++router.post('/:slug/:channel/stage/end', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await stageChannel(req, res, server); if (!room) return;++ if (!(await servers.access(server, req.user)).moderate) return res.status(403).json({ error: 'Only moderators can end a stage', code: 'stage.not_mod' });++ const db = require('../db/database');++ await db.run('UPDATE chat_stage_sessions SET ended_at = ov_now(), updated_at = ov_now() WHERE room_id = ?', [room.id]);++ await db.run('DELETE FROM chat_stage_speakers WHERE room_id = ?', [room.id]);++ await updateStageAccess(room);++ res.json({ stage: await stageState(room) });++});++router.post('/:slug/:channel/stage/hand', requireAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await stageChannel(req, res, server); if (!room) return;++ const role = await servers.roleIn(server.id, req.user.id);++ if (!role || role === 'blocked') return res.status(403).json({ error: 'Join the server to raise your hand', code: 'stage.not_member' });++ const db = require('../db/database');++ const row = await db.get('SELECT started_at, ended_at FROM chat_stage_sessions WHERE room_id = ?', [room.id]);++ if (!row || !row.started_at || row.ended_at) return res.status(409).json({ error: 'The stage is not live', code: 'stage.not_live' });++ await db.run('INSERT INTO chat_stage_speakers (room_id, user_id, hand_raised) VALUES (?, ?, TRUE) ON CONFLICT (room_id, user_id) DO UPDATE SET hand_raised = TRUE, updated_at = ov_now()', [room.id, req.user.id]);++ res.json({ stage: await stageState(room) });++});++router.post('/:slug/:channel/stage/invite', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await stageChannel(req, res, server); if (!room) return;++ if (!(await servers.access(server, req.user)).moderate) return res.status(403).json({ error: 'Only moderators can invite speakers', code: 'stage.not_mod' });++ const userId = Number((req.body || {}).user_id);++ const role = await servers.roleIn(server.id, userId);++ if (!Number.isSafeInteger(userId) || !role || role === 'blocked') return res.status(404).json({ error: 'No such server member', code: 'stage.no_member' });++ const db = require('../db/database');++ await db.run('INSERT INTO chat_stage_speakers (room_id, user_id, invited_by, hand_raised) VALUES (?, ?, ?, FALSE) ON CONFLICT (room_id, user_id) DO UPDATE SET invited_by = EXCLUDED.invited_by, hand_raised = FALSE, updated_at = ov_now()', [room.id, userId, req.user.id]);++ await updateStageAccess(room);++ res.json({ stage: await stageState(room) });++});++router.post('/:slug/:channel/stage/move-back', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await stageChannel(req, res, server); if (!room) return;++ if (!(await servers.access(server, req.user)).moderate) return res.status(403).json({ error: 'Only moderators can move speakers back', code: 'stage.not_mod' });++ const userId = Number((req.body || {}).user_id);++ const db = require('../db/database');++ const result = await db.run('DELETE FROM chat_stage_speakers WHERE room_id = ? AND user_id = ?', [room.id, userId]);++ if (!result.changes) return res.status(404).json({ error: 'No such stage speaker', code: 'stage.no_speaker' });++ await updateStageAccess(room);++ res.json({ stage: await stageState(room) });++});++router.patch('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {const server = await readable(req, res); if (!server) return;const room = await channelOf(req, res, server); if (!room) return;@@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invitconst OFFICIAL_SLUG = 'openvibe';const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };--const CHANNEL_KINDS = { text: 'community', voice: 'call' };++const CHANNEL_KINDS = { text: 'community', voice: 'call', stage: 'call' };/** Channel names the server's own pages use (/s/<server>/settings …). */const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);const MAX_SERVERS_PER_OWNER = 5;@@@ -110,7 +110,9 @@ async function channelAccess(room, user) {const a = await access(server, user);const srole = a.role;// Server owner → room owner; admins and moderators → room moderators; members → the channel's member role.-- const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? rooms.joinRoleOf(room) : 'member';++ let stageSpeaker = false;++ if (room.stage_channel && user && a.member) stageSpeaker = !!await db.get('SELECT 1 FROM chat_stage_speakers WHERE room_id = ? AND user_id = ?', [room.id, user.id]);++ const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? (room.stage_channel ? (stageSpeaker ? 'speaker' : 'participant') : rooms.joinRoleOf(room)) : 'member';const banned = rooms.siteBanned(user);const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);const join = kind === 'call' && a.read;@@@ -132,10 +134,10 @@ function publicServer(s, extra = {}) {/** A channel as the server shows it. */function publicChannel(r, extra = {}) {return {-- slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',++ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,category: r.category || null,-- ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,++ ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: r.stage_channel ? 'participant' : rooms.joinRoleOf(r) } : {}), ...extra,};}@@@ -164,6 +166,7 @@ async function insertChannel(server, user, { name, kind, topic = null, positionif (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');const roomKind = CHANNEL_KINDS[kind];if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');++ if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');@@@ -174,9 +177,10 @@ async function insertChannel(server, user, { name, kind, topic = null, positionconst pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.-- const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position)-- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)`,-- [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? 'speaker' : null, server.id, cs, pos]);++ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, stage_channel)++ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,++ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'stage']);++ if (kind === 'stage') await db.run('INSERT INTO chat_stage_sessions (room_id, topic) VALUES (?, ?)', [r.lastInsertRowid, t]);return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);}@@@ -33,11 +33,17 @@ const REGIONS = {const SCHEMA = Object.freeze({$schema: 'https://json-schema.org/draft/2020-12/schema',-- title: 'Chat voices',-- description: 'The accounts Chat speaks with: Amazon Polly and Google Cloud text-to-speech. A voice whose provider has no key stays unavailable; the free local voice always works.',++ title: 'Chat settings',++ description: 'Owner controlled Chat features and voice providers.',type: 'object',additionalProperties: false,properties: {++ stage_channels_enabled: {++ type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,++ description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',++ 'x-required-for': 'Stage channels',++ 'x-owner-input': { group: 'voices', unlocks: 'Stage channel controls in Chat servers using existing voice rooms.', get_it_at: 'https://openvibe.chat', get_it_how: 'This is a Chat feature switch. Enable it to let server owners create stage channels.' },++ },amazon_polly_access_key_id: {type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',@@@ -202,16 +202,17 @@ ${sheet}`,// The people's presence (Network, cached 30 s) and their mark.await require('../presence').attachTo(me ? me.subject_id : null, people.map((p) => ({ ...p, id: p.user_id })));const isVoice = room.kind === 'call';++ const isStage = !!room.stage_channel;const callReady = isVoice && !!require('../config').calls.enabled;const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };// The people in each voice channel (the plan: "voice channels with who is in them").const occupants = {};-- for (const c of chans) if (c.kind === 'voice') occupants[c.slug] = ((callOf(c) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name }));++ for (const c of chans) if (c.kind === 'voice' || c.kind === 'stage') occupants[c.slug] = ((callOf(c) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name }));// The channel categories: a channel's own category, or the kind's default.const categories = [];const byCat = new Map();for (const c of chans) {-- const name = c.category || (c.kind === 'voice' ? 'Voice channels' : 'Text channels');++ const name = c.category || (c.kind === 'stage' ? 'Stage channels' : c.kind === 'voice' ? 'Voice channels' : 'Text channels');if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }byCat.get(name).push(c);}@@@ -251,7 +252,7 @@ ${sheet}`,// and the sign-in link post to (POST /s/:slug/:channel).const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;await page(req, res, 200, {-- actor, shell: true, footer: 'none', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],++ actor, shell: true, footer: 'none', title: `${isVoice ? (isStage ? 'Stage · ' : '') : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,page: {@@@ -277,14 +278,15 @@ ${sheet}`,you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},}),sideLabel: server.name,-- main: `${shell.channelHeader({ name: room.name, kind: isVoice ? 'voice' : 'text', topic: room.topic, members: true })}++ main: `${shell.channelHeader({ name: room.name, kind: isStage ? 'stage' : isVoice ? 'voice' : 'text', topic: room.topic, members: true })}${invitePanel}<span class="oc-live" id="oc-live" hidden>● live</span>++${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}${aiHere}${errorOf(req)}${isVoice ? callPanel(room, rcan, me) : ''}<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>${composer}`,-- mainLabel: `${isVoice ? '' : '#'}${room.name}`,++ mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,members: shell.membersColumn({ groups: roleGroups }),membersLabel: `People in ${server.name}`,}),@@@ -319,13 +321,13 @@ ${composer}`,const categories = [];const byCat = new Map();for (const c of chans) {-- const name = c.category || (c.kind === 'voice' ? 'Voice channels' : 'Text channels');++ const name = c.category || (c.kind === 'stage' ? 'Stage channels' : c.kind === 'voice' ? 'Voice channels' : 'Text channels');if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }byCat.get(name).push(c);}const occupants = {};for (const c of chans) {-- if (c.kind !== 'voice') continue;++ if (!['voice', 'stage'].includes(c.kind)) continue;try { occupants[c.slug] = ((require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name })); } catch { occupants[c.slug] = []; }}const mine = me ? (await servers.list(me)).mine : [];@@@ -469,7 +471,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro};const active = people.filter((p) => p.role !== 'blocked');const banned = people.filter((p) => p.role === 'blocked');-- const chanRow = (c) => `<li><span>${c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>++ const chanRow = (c) => `<li><span>${c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span><form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form><details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>@@@ -497,7 +499,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label><p><button type="submit">Save</button></p></form><h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>--<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></select> <button type="submit">Add</button></form>++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select> <button type="submit">Add</button></form><section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2><p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p><form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">@@@ -542,7 +544,7 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth}));router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {const b = req.body || {};-- const room = await servers.createChannel(server, user, { name: b.name, kind: b.kind === 'voice' ? 'voice' : 'text' });++ const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'stage'].includes(b.kind) ? b.kind : 'text' });res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);}));router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {@@@ -34,6 +34,7 @@ t('export, then erase and confirm', async () => {{const missing = await uncovered(d, {taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',++ 'chat_stage_speakers','ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions','inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions','user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],@@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);}-- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json']);++ assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json', 'stage_channels_enabled']);const region = props.amazon_polly_region;assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);for (const v of region.enum) {@@@ -32,7 +32,7 @@ const TABLES = ['network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples','dm_contacts', 'dm_view_defaults', 'dm_view_preferences','chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',-- 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',++ 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties', 'chat_stage_sessions', 'chat_stage_speakers','room_sync_clocks', 'room_control_leases','room_message_reactions', 'room_member_timeouts', 'config_snapshots', 'config_keys','agent_play_requests', 'agent_play_votes', 'agent_play_log',@@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban met('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });@@@ -126,6 +127,26 @@ t('channels: admins add, rename, move and delete them; a server keeps one text cassert.strictEqual((await roomApi('PATCH', `/${general}`, ann, { visibility: 'private' })).body.code, 'rooms.server_channel', 'a channel is as public as its server');});++t('stage channels reuse call rooms, hands and moderator invitations change who can talk', async () => {++ const settings = require('../server/settings');++ const previous = settings.current();++ settings.activate({ value: (key, _env, fallback) => key === 'stage_channels_enabled' ? true : fallback });++ try {++ const made = await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage', topic: 'Updates' });++ assert.deepStrictEqual([made.status, made.body.channel.kind], [201, 'stage']);++ const path = `/saturday-raiders/${made.body.channel.slug}/stage`;++ assert.strictEqual((await api('POST', `${path}/start`, bob, { topic: 'Weekly news' })).body.stage.topic, 'Weekly news');++ assert.strictEqual((await roomApi('GET', `/${made.body.channel.room}`, dee)).body.can.talk, false, 'audience starts muted');++ const hand = await api('POST', `${path}/hand`, dee);++ assert.strictEqual(hand.body.stage?.raised_hands?.[0]?.user_id, dee.id, `${hand.status} ${hand.text}`);++ assert.strictEqual((await api('POST', `${path}/invite`, bob, { user_id: dee.id })).body.stage.speakers[0].user_id, dee.id);++ assert.strictEqual((await roomApi('GET', `/${made.body.channel.room}`, dee)).body.can.talk, true, 'invited speaker can talk');++ assert.strictEqual((await api('POST', `${path}/move-back`, bob, { user_id: dee.id })).status, 200);++ assert.strictEqual((await roomApi('GET', `/${made.body.channel.room}`, dee)).body.can.talk, false, 'moving a speaker back mutes them');++ assert.strictEqual((await api('POST', `${path}/end`, bob)).body.stage.ended_at != null, true);++ } finally { settings.activate(previous); }++});++t('private servers: invisible to outsiders, joined through an invite with uses and expiry, revocable', async () => {const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });assert.strictEqual(r.status, 201);
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+-- phase: expand
+-- Stage channels are call rooms with per-person speaking permission and stage session metadata.
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS stage_channel boolean NOT NULL DEFAULT false;
+
+CREATE TABLE IF NOT EXISTS chat_stage_sessions (
+ room_id bigint PRIMARY KEY,
+ topic text COLLATE "C",
+ started_at text COLLATE "C",
+ ended_at text COLLATE "C",
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE IF NOT EXISTS chat_stage_speakers (
+ room_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ hand_raised boolean NOT NULL DEFAULT false,
+ invited_by bigint,
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (room_id, user_id)
+);
@@ -51,6 +51,7 @@ const EXPORTS = [
@@ -51,6 +51,7 @@ const EXPORTS = [
['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],
['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],
['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.roles, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],
['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.roles, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],
+ ['stage_speaker_actions.json', `SELECT room_id, user_id, hand_raised, invited_by, updated_at FROM chat_stage_speakers WHERE user_id = ANY(@ids) OR invited_by = ANY(@ids) ORDER BY updated_at DESC LIMIT ${LIMIT}`],
['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
@@ -123,6 +124,8 @@ const ERASE_FIRST = [
@@ -123,6 +124,8 @@ const ERASE_FIRST = [
['room_attachments', 'DELETE FROM room_attachments WHERE attached_by_subject = ANY(@s)'],
['room_attachments', 'DELETE FROM room_attachments WHERE attached_by_subject = ANY(@s)'],
];
];
const ERASE_AFTER_ROOMS = [
const ERASE_AFTER_ROOMS = [
+ ['stage_speaker_roles', 'DELETE FROM chat_stage_speakers WHERE user_id = ANY(@ids)'],
+ [null, 'UPDATE chat_stage_speakers SET invited_by = NULL WHERE invited_by = ANY(@ids)'],
['calls', 'DELETE FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids)'],
['calls', 'DELETE FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids)'],
['blocks', 'DELETE FROM network_blocks WHERE blocker_subject = ANY(@s) OR blocked_subject = ANY(@s)'],
['blocks', 'DELETE FROM network_blocks WHERE blocker_subject = ANY(@s) OR blocked_subject = ANY(@s)'],
['blocks', 'DELETE FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) OR blocked_id = ANY(@ids)'],
['blocks', 'DELETE FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) OR blocked_id = ANY(@ids)'],
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* POST /:slug/leave
* POST /:slug/leave
* GET /:slug/members (moderators also see who is banned)
* GET /:slug/members (moderators also see who is banned)
* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
- * POST /:slug/channels owner/admins: { name, kind: text | voice, topic? }
+ * POST /:slug/channels owner/admins: { name, kind: text | voice | stage, topic? }
* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
* GET /:slug/invites owner/admins: the live invites
* GET /:slug/invites owner/admins: the live invites
@@ -61,6 +61,26 @@ async function channelOf(req, res, server) {
@@ -61,6 +61,26 @@ async function channelOf(req, res, server) {
if (!room) { res.status(404).json({ error: 'No such channel', code: 'servers.no_channel' }); return null; }
if (!room) { res.status(404).json({ error: 'No such channel', code: 'servers.no_channel' }); return null; }
return room;
return room;
}
}
+async function stageChannel(req, res, server) {
+ const room = await channelOf(req, res, server);
+ if (!room) return null;
+ if (!room.stage_channel) { res.status(404).json({ error: 'No such stage channel', code: 'stage.not_found' }); return null; }
+ return room;
+}
+async function stageState(room) {
+ const session = await require('../db/database').get('SELECT topic, started_at, ended_at FROM chat_stage_sessions WHERE room_id = ?', [room.id]);
+ const speakers = await require('../db/database').all('SELECT user_id, hand_raised FROM chat_stage_speakers WHERE room_id = ? ORDER BY hand_raised DESC, updated_at', [room.id]);
+ const people = await Promise.all(speakers.map(async (p) => {
+ const user = await ctx.getUserById(Number(p.user_id));
+ return { user_id: Number(p.user_id), username: user && user.username || null, display_name: user && user.display_name || null, hand_raised: p.hand_raised };
+ }));
+ return { topic: session && session.topic || null, started_at: session && session.started_at || null, ended_at: session && session.ended_at || null,
+ speakers: people.filter((p) => !p.hand_raised), raised_hands: people.filter((p) => p.hand_raised) };
+}
+async function updateStageAccess(room) {
+ await roomRoutes().applyRoomChange(room);
+ try { chatServer().broadcastToRoom(room.id, { type: 'stage', room: room.slug, stage: await stageState(room) }); } catch { /* clients can refetch */ }
+}
const canOf = (a) => ({ read: a.read, member: a.member, moderate: a.moderate, manage: a.manage, owner: a.owner });
const canOf = (a) => ({ read: a.read, member: a.member, moderate: a.moderate, manage: a.manage, owner: a.owner });
router.get('/', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
@@ -141,6 +161,69 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
@@ -141,6 +161,69 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+router.get('/:slug/:channel/stage', optionalAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await stageChannel(req, res, server); if (!room) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ stage: await stageState(room), can: canOf(await servers.channelAccess(room, req.user || null)) });
+});
+router.post('/:slug/:channel/stage/start', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await stageChannel(req, res, server); if (!room) return;
+ const can = await servers.access(server, req.user);
+ if (!can.moderate) return res.status(403).json({ error: 'Only moderators can start a stage', code: 'stage.not_mod' });
+ const db = require('../db/database');
+ const topic = String((req.body || {}).topic ?? room.topic ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || null;
+ await db.run('UPDATE rooms SET topic = ? WHERE id = ?', [topic, room.id]);
+ await db.run('UPDATE chat_stage_sessions SET topic = ?, started_at = ov_now(), ended_at = NULL, updated_at = ov_now() WHERE room_id = ?', [topic, room.id]);
+ const fresh = await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]);
+ await updateStageAccess(fresh);
+ res.json({ stage: await stageState(fresh) });
+});
+router.post('/:slug/:channel/stage/end', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await stageChannel(req, res, server); if (!room) return;
+ if (!(await servers.access(server, req.user)).moderate) return res.status(403).json({ error: 'Only moderators can end a stage', code: 'stage.not_mod' });
+ const db = require('../db/database');
+ await db.run('UPDATE chat_stage_sessions SET ended_at = ov_now(), updated_at = ov_now() WHERE room_id = ?', [room.id]);
+ await db.run('DELETE FROM chat_stage_speakers WHERE room_id = ?', [room.id]);
+ await updateStageAccess(room);
+ res.json({ stage: await stageState(room) });
+});
+router.post('/:slug/:channel/stage/hand', requireAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await stageChannel(req, res, server); if (!room) return;
+ const role = await servers.roleIn(server.id, req.user.id);
+ if (!role || role === 'blocked') return res.status(403).json({ error: 'Join the server to raise your hand', code: 'stage.not_member' });
+ const db = require('../db/database');
+ const row = await db.get('SELECT started_at, ended_at FROM chat_stage_sessions WHERE room_id = ?', [room.id]);
+ if (!row || !row.started_at || row.ended_at) return res.status(409).json({ error: 'The stage is not live', code: 'stage.not_live' });
+ await db.run('INSERT INTO chat_stage_speakers (room_id, user_id, hand_raised) VALUES (?, ?, TRUE) ON CONFLICT (room_id, user_id) DO UPDATE SET hand_raised = TRUE, updated_at = ov_now()', [room.id, req.user.id]);
+ res.json({ stage: await stageState(room) });
+});
+router.post('/:slug/:channel/stage/invite', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await stageChannel(req, res, server); if (!room) return;
+ if (!(await servers.access(server, req.user)).moderate) return res.status(403).json({ error: 'Only moderators can invite speakers', code: 'stage.not_mod' });
+ const userId = Number((req.body || {}).user_id);
+ const role = await servers.roleIn(server.id, userId);
+ if (!Number.isSafeInteger(userId) || !role || role === 'blocked') return res.status(404).json({ error: 'No such server member', code: 'stage.no_member' });
+ const db = require('../db/database');
+ await db.run('INSERT INTO chat_stage_speakers (room_id, user_id, invited_by, hand_raised) VALUES (?, ?, ?, FALSE) ON CONFLICT (room_id, user_id) DO UPDATE SET invited_by = EXCLUDED.invited_by, hand_raised = FALSE, updated_at = ov_now()', [room.id, userId, req.user.id]);
+ await updateStageAccess(room);
+ res.json({ stage: await stageState(room) });
+});
+router.post('/:slug/:channel/stage/move-back', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await stageChannel(req, res, server); if (!room) return;
+ if (!(await servers.access(server, req.user)).moderate) return res.status(403).json({ error: 'Only moderators can move speakers back', code: 'stage.not_mod' });
+ const userId = Number((req.body || {}).user_id);
+ const db = require('../db/database');
+ const result = await db.run('DELETE FROM chat_stage_speakers WHERE room_id = ? AND user_id = ?', [room.id, userId]);
+ if (!result.changes) return res.status(404).json({ error: 'No such stage speaker', code: 'stage.no_speaker' });
+ await updateStageAccess(room);
+ res.json({ stage: await stageState(room) });
+});
+
router.patch('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {
router.patch('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {
const server = await readable(req, res); if (!server) return;
const server = await readable(req, res); if (!server) return;
const room = await channelOf(req, res, server); if (!room) return;
const room = await channelOf(req, res, server); if (!room) return;
@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invit
@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invit
const OFFICIAL_SLUG = 'openvibe';
const OFFICIAL_SLUG = 'openvibe';
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
-const CHANNEL_KINDS = { text: 'community', voice: 'call' };
+const CHANNEL_KINDS = { text: 'community', voice: 'call', stage: 'call' };
/** Channel names the server's own pages use (/s/<server>/settings …). */
/** Channel names the server's own pages use (/s/<server>/settings …). */
const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
const MAX_SERVERS_PER_OWNER = 5;
const MAX_SERVERS_PER_OWNER = 5;
@@ -110,7 +110,9 @@ async function channelAccess(room, user) {
@@ -110,7 +110,9 @@ async function channelAccess(room, user) {
const a = await access(server, user);
const a = await access(server, user);
const srole = a.role;
const srole = a.role;
// Server owner → room owner; admins and moderators → room moderators; members → the channel's member role.
// Server owner → room owner; admins and moderators → room moderators; members → the channel's member role.
- const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? rooms.joinRoleOf(room) : 'member';
+ let stageSpeaker = false;
+ if (room.stage_channel && user && a.member) stageSpeaker = !!await db.get('SELECT 1 FROM chat_stage_speakers WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
+ const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? (room.stage_channel ? (stageSpeaker ? 'speaker' : 'participant') : rooms.joinRoleOf(room)) : 'member';
const banned = rooms.siteBanned(user);
const banned = rooms.siteBanned(user);
const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);
const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);
const join = kind === 'call' && a.read;
const join = kind === 'call' && a.read;
@@ -132,10 +134,10 @@ function publicServer(s, extra = {}) {
@@ -132,10 +134,10 @@ function publicServer(s, extra = {}) {
/** A channel as the server shows it. */
/** A channel as the server shows it. */
function publicChannel(r, extra = {}) {
function publicChannel(r, extra = {}) {
return {
return {
- slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',
+ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
category: r.category || null,
category: r.category || null,
- ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,
+ ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: r.stage_channel ? 'participant' : rooms.joinRoleOf(r) } : {}), ...extra,
};
};
}
}
@@ -164,6 +166,7 @@ async function insertChannel(server, user, { name, kind, topic = null, position
@@ -164,6 +166,7 @@ async function insertChannel(server, user, { name, kind, topic = null, position
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
const roomKind = CHANNEL_KINDS[kind];
const roomKind = CHANNEL_KINDS[kind];
if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');
if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');
+ if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
@@ -174,9 +177,10 @@ async function insertChannel(server, user, { name, kind, topic = null, position
@@ -174,9 +177,10 @@ async function insertChannel(server, user, { name, kind, topic = null, position
const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
- const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position)
- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)`,
- [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? 'speaker' : null, server.id, cs, pos]);
+ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, stage_channel)
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,
+ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'stage']);
+ if (kind === 'stage') await db.run('INSERT INTO chat_stage_sessions (room_id, topic) VALUES (?, ?)', [r.lastInsertRowid, t]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
}
}
@@ -33,11 +33,17 @@ const REGIONS = {
@@ -33,11 +33,17 @@ const REGIONS = {
const SCHEMA = Object.freeze({
const SCHEMA = Object.freeze({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$schema: 'https://json-schema.org/draft/2020-12/schema',
- title: 'Chat voices',
- description: 'The accounts Chat speaks with: Amazon Polly and Google Cloud text-to-speech. A voice whose provider has no key stays unavailable; the free local voice always works.',
+ title: 'Chat settings',
+ description: 'Owner controlled Chat features and voice providers.',
type: 'object',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
+ stage_channels_enabled: {
+ type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,
+ description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
+ 'x-required-for': 'Stage channels',
+ 'x-owner-input': { group: 'voices', unlocks: 'Stage channel controls in Chat servers using existing voice rooms.', get_it_at: 'https://openvibe.chat', get_it_how: 'This is a Chat feature switch. Enable it to let server owners create stage channels.' },
+ },
amazon_polly_access_key_id: {
amazon_polly_access_key_id: {
type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,
type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,
description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',
description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',
@@ -202,16 +202,17 @@ ${sheet}`,
@@ -202,16 +202,17 @@ ${sheet}`,
// The people's presence (Network, cached 30 s) and their mark.
// The people's presence (Network, cached 30 s) and their mark.
await require('../presence').attachTo(me ? me.subject_id : null, people.map((p) => ({ ...p, id: p.user_id })));
await require('../presence').attachTo(me ? me.subject_id : null, people.map((p) => ({ ...p, id: p.user_id })));
const isVoice = room.kind === 'call';
const isVoice = room.kind === 'call';
+ const isStage = !!room.stage_channel;
const callReady = isVoice && !!require('../config').calls.enabled;
const callReady = isVoice && !!require('../config').calls.enabled;
const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };
const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };
// The people in each voice channel (the plan: "voice channels with who is in them").
// The people in each voice channel (the plan: "voice channels with who is in them").
const occupants = {};
const occupants = {};
- for (const c of chans) if (c.kind === 'voice') occupants[c.slug] = ((callOf(c) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name }));
+ for (const c of chans) if (c.kind === 'voice' || c.kind === 'stage') occupants[c.slug] = ((callOf(c) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name }));
// The channel categories: a channel's own category, or the kind's default.
// The channel categories: a channel's own category, or the kind's default.
const categories = [];
const categories = [];
const byCat = new Map();
const byCat = new Map();
for (const c of chans) {
for (const c of chans) {
- const name = c.category || (c.kind === 'voice' ? 'Voice channels' : 'Text channels');
+ const name = c.category || (c.kind === 'stage' ? 'Stage channels' : c.kind === 'voice' ? 'Voice channels' : 'Text channels');
if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }
if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }
byCat.get(name).push(c);
byCat.get(name).push(c);
}
}
@@ -251,7 +252,7 @@ ${sheet}`,
@@ -251,7 +252,7 @@ ${sheet}`,
// and the sign-in link post to (POST /s/:slug/:channel).
// and the sign-in link post to (POST /s/:slug/:channel).
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, shell: true, footer: 'none', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
+ actor, shell: true, footer: 'none', title: `${isVoice ? (isStage ? 'Stage · ' : '') : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
page: {
@@ -277,14 +278,15 @@ ${sheet}`,
@@ -277,14 +278,15 @@ ${sheet}`,
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
}),
}),
sideLabel: server.name,
sideLabel: server.name,
- main: `${shell.channelHeader({ name: room.name, kind: isVoice ? 'voice' : 'text', topic: room.topic, members: true })}
+ main: `${shell.channelHeader({ name: room.name, kind: isStage ? 'stage' : isVoice ? 'voice' : 'text', topic: room.topic, members: true })}
${invitePanel}
${invitePanel}
<span class="oc-live" id="oc-live" hidden>● live</span>
<span class="oc-live" id="oc-live" hidden>● live</span>
+${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}
${aiHere}${errorOf(req)}
${aiHere}${errorOf(req)}
${isVoice ? callPanel(room, rcan, me) : ''}
${isVoice ? callPanel(room, rcan, me) : ''}
<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
${composer}`,
${composer}`,
- mainLabel: `${isVoice ? '' : '#'}${room.name}`,
+ mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,
members: shell.membersColumn({ groups: roleGroups }),
members: shell.membersColumn({ groups: roleGroups }),
membersLabel: `People in ${server.name}`,
membersLabel: `People in ${server.name}`,
}),
}),
@@ -319,13 +321,13 @@ ${composer}`,
@@ -319,13 +321,13 @@ ${composer}`,
const categories = [];
const categories = [];
const byCat = new Map();
const byCat = new Map();
for (const c of chans) {
for (const c of chans) {
- const name = c.category || (c.kind === 'voice' ? 'Voice channels' : 'Text channels');
+ const name = c.category || (c.kind === 'stage' ? 'Stage channels' : c.kind === 'voice' ? 'Voice channels' : 'Text channels');
if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }
if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }
byCat.get(name).push(c);
byCat.get(name).push(c);
}
}
const occupants = {};
const occupants = {};
for (const c of chans) {
for (const c of chans) {
- if (c.kind !== 'voice') continue;
+ if (!['voice', 'stage'].includes(c.kind)) continue;
try { occupants[c.slug] = ((require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name })); } catch { occupants[c.slug] = []; }
try { occupants[c.slug] = ((require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name })); } catch { occupants[c.slug] = []; }
}
}
const mine = me ? (await servers.list(me)).mine : [];
const mine = me ? (await servers.list(me)).mine : [];
@@ -469,7 +471,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
@@ -469,7 +471,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
};
};
const active = people.filter((p) => p.role !== 'blocked');
const active = people.filter((p) => p.role !== 'blocked');
const banned = people.filter((p) => p.role === 'blocked');
const banned = people.filter((p) => p.role === 'blocked');
- const chanRow = (c) => `<li><span>${c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
+ const chanRow = (c) => `<li><span>${c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
@@ -497,7 +499,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
@@ -497,7 +499,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
-<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></select> <button type="submit">Add</button></form>
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select> <button type="submit">Add</button></form>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
@@ -542,7 +544,7 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
@@ -542,7 +544,7 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
}));
}));
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
const b = req.body || {};
const b = req.body || {};
- const room = await servers.createChannel(server, user, { name: b.name, kind: b.kind === 'voice' ? 'voice' : 'text' });
+ const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'stage'].includes(b.kind) ? b.kind : 'text' });
res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
}));
}));
router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
@@ -34,6 +34,7 @@ t('export, then erase and confirm', async () => {
@@ -34,6 +34,7 @@ t('export, then erase and confirm', async () => {
{
{
const missing = await uncovered(d, {
const missing = await uncovered(d, {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
+ 'chat_stage_speakers',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
}
}
- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json']);
+ assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json', 'stage_channels_enabled']);
const region = props.amazon_polly_region;
const region = props.amazon_polly_region;
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
for (const v of region.enum) {
for (const v of region.enum) {
@@ -32,7 +32,7 @@ const TABLES = [
@@ -32,7 +32,7 @@ const TABLES = [
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'dm_contacts', 'dm_view_defaults', 'dm_view_preferences',
'dm_contacts', 'dm_view_defaults', 'dm_view_preferences',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
- 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',
+ 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties', 'chat_stage_sessions', 'chat_stage_speakers',
'room_sync_clocks', 'room_control_leases',
'room_sync_clocks', 'room_control_leases',
'room_message_reactions', 'room_member_timeouts', 'config_snapshots', 'config_keys',
'room_message_reactions', 'room_member_timeouts', 'config_snapshots', 'config_keys',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
+ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });
r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });
@@ -126,6 +127,26 @@ t('channels: admins add, rename, move and delete them; a server keeps one text c
@@ -126,6 +127,26 @@ t('channels: admins add, rename, move and delete them; a server keeps one text c
assert.strictEqual((await roomApi('PATCH', `/${general}`, ann, { visibility: 'private' })).body.code, 'rooms.server_channel', 'a channel is as public as its server');
assert.strictEqual((await roomApi('PATCH', `/${general}`, ann, { visibility: 'private' })).body.code, 'rooms.server_channel', 'a channel is as public as its server');
});
});
+t('stage channels reuse call rooms, hands and moderator invitations change who can talk', async () => {
+ const settings = require('../server/settings');
+ const previous = settings.current();
+ settings.activate({ value: (key, _env, fallback) => key === 'stage_channels_enabled' ? true : fallback });
+ try {
+ const made = await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage', topic: 'Updates' });
+ assert.deepStrictEqual([made.status, made.body.channel.kind], [201, 'stage']);
+ const path = `/saturday-raiders/${made.body.channel.slug}/stage`;
+ assert.strictEqual((await api('POST', `${path}/start`, bob, { topic: 'Weekly news' })).body.stage.topic, 'Weekly news');
+ assert.strictEqual((await roomApi('GET', `/${made.body.channel.room}`, dee)).body.can.talk, false, 'audience starts muted');
+ const hand = await api('POST', `${path}/hand`, dee);
+ assert.strictEqual(hand.body.stage?.raised_hands?.[0]?.user_id, dee.id, `${hand.status} ${hand.text}`);
+ assert.strictEqual((await api('POST', `${path}/invite`, bob, { user_id: dee.id })).body.stage.speakers[0].user_id, dee.id);
+ assert.strictEqual((await roomApi('GET', `/${made.body.channel.room}`, dee)).body.can.talk, true, 'invited speaker can talk');
+ assert.strictEqual((await api('POST', `${path}/move-back`, bob, { user_id: dee.id })).status, 200);
+ assert.strictEqual((await roomApi('GET', `/${made.body.channel.room}`, dee)).body.can.talk, false, 'moving a speaker back mutes them');
+ assert.strictEqual((await api('POST', `${path}/end`, bob)).body.stage.ended_at != null, true);
+ } finally { settings.activate(previous); }
+});
+
t('private servers: invisible to outsiders, joined through an invite with uses and expiry, revocable', async () => {
t('private servers: invisible to outsiders, joined through an invite with uses and expiry, revocable', async () => {
const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });
const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);