openvibers/openvibe.chat Commits

Add stage channels on voice rooms (#454)

OP OpenVibers committed 4 days ago · 333d8b70db · 10 files, +162 −22

* Add stage channels on voice rooms

* Cover stage data in account workflows

* Restart queued CI checks

* Merge current main into stage channels

* Merge remote-tracking branch 'origin/main' into chat/v3-forum

* Move stage migration to next free number

* Remove superseded stage migration

Changes

ddiff --git a/migrations/0058_stage_channels.sql b/migrations/0058_stage_channels.sql
nnew file mode 100644
iindex 0000000..856e960
---- /dev/null
++++ b/migrations/0058_stage_channels.sql
@@@ -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)
++);
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 21cc3c3..571383d 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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)'],
ddiff --git a/server/rooms/server-routes.js b/server/rooms/server-routes.js
iindex b5bfb7c..e87825d 100644
---- a/server/rooms/server-routes.js
++++ b/server/rooms/server-routes.js
@@@ -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;
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex e6d7d2c..71254cb 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invit
const 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, position
if (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, 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 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]);
}
ddiff --git a/server/settings.js b/server/settings.js
iindex dd886cf..f94a471 100644
---- a/server/settings.js
++++ b/server/settings.js
@@@ -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.',
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 35c42b4..f5a5b86 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -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) => {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 4740950..e1c3342 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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'],
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
iindex d4f65b4..a8a88cb 100644
---- a/test/owner-settings.test.js
++++ b/test/owner-settings.test.js
@@@ -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) {
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex a76bcfe..b8ec7b4 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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',
ddiff --git a/test/servers.test.js b/test/servers.test.js
iindex f244b96..6813301 100644
---- a/test/servers.test.js
++++ b/test/servers.test.js
@@@ -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 () => {
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 c
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 () => {
const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });
assert.strictEqual(r.status, 201);