openvibers/openvibe.chat Commits

Workspace rooms show a shared screen: a Bot screen share link, live above the conversation for members only (watch, or click and type with a use link) (#164)

OP OpenVibers committed 19 days ago · 39e727f20b · 8 files, +53 −3

Changes

ddiff --git a/README.md b/README.md
iindex ad5424b..0a5908d 100644
---- a/README.md
++++ b/README.md
@@@ -403,6 +403,11 @@ member on their first visit, and nobody else reads it, whatever role they once h
(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
sends `room_task`.
- **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room.
++- **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot
++ (`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Members
++ see that computer's screen live above the conversation, and with a "use" link they can start it and click and type on
++ it (at the level the owner set on the computer). The link is a key to the screen, so only members get it (the page,
++ and `screen.url` in `GET /api/chat/rooms/:slug`). Pages allow `frame-src https://openvibe.bot`.
- **Account data:** a person's tasks are in their export (`room_tasks.json`); on deletion the room keeps the work
without who asked or who took it.
ddiff --git a/migrations/0028_room_screen.sql b/migrations/0028_room_screen.sql
nnew file mode 100644
iindex 0000000..2d61d67
---- /dev/null
++++ b/migrations/0028_room_screen.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- A workspace room's shared screen (owner 2026-10-25: a screen the group watches and uses together): a screen share
++-- link from OpenVibe.Bot (https://openvibe.bot/screen/<token>, made by the computer's owner), shown beside the
++-- conversation to the room's members only. The link is a key to that screen, so only members ever see it.
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS screen_url text;
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 4d863b0..6a551c5 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -357,3 +357,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@keyframes oc-work { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
@media (prefers-reduced-motion: reduce) { .oc-task-running .oc-task-state { animation: none; } }
@media (max-width: 900px) { .oc-ws { grid-template-columns: minmax(0, 1fr); } .oc-queue { position: static; max-height: none; order: -1; } }
++/* A workspace room's shared screen (rooms.screen_url, a screen share page from OpenVibe.Bot), above the conversation. */
++.oc-screen { margin: 10px 0 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); overflow: hidden; }
++.oc-screen summary { cursor: pointer; padding: 8px 12px; font-weight: 600; }
++.oc-screen iframe { display: block; width: 100%; aspect-ratio: 16 / 10; min-height: 260px; max-height: 78vh; border: 0; background: #000; }
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex b220ad9..232b190 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -383,7 +383,16 @@ async function setRole(room, actor, targetUserId, role) {
return role;
}
--async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}, { fromServer = false, minName = 3 } = {}) {
++/** A screen share link from OpenVibe.Bot (screen_url), or null to clear; anything else is refused. */
++const SCREEN_RE = /^https:\/\/openvibe\.bot\/screen\/[A-Za-z0-9_-]{40,60}$/;
++function screenUrlOf(v) {
++ const s = String(v == null ? '' : v).trim();
++ if (!s) return null;
++ if (!SCREEN_RE.test(s)) fail(422, 'rooms.screen_url', 'A shared screen is a link from OpenVibe.Bot: https://openvibe.bot/screen/…');
++ return s;
++}
++
++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url } = {}, { fromServer = false, minName = 3 } = {}) {
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
// A server's channel follows the server's visibility, and is changed through the server (servers.js).
if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');
@@@ -397,8 +406,12 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
next.join_role = join_role;
}
++ if (screen_url !== undefined) {
++ if (!isWorkspace(room)) fail(409, 'rooms.not_workspace', 'Only a workspace room shows a shared screen');
++ next.screen_url = screenUrlOf(screen_url);
++ }
await db.tx(async () => {
-- await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);
++ await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
await announceDeleted(room, (await db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id])).map((r) => r.id));
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 9bdf274..7261328 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -120,6 +120,8 @@ router.get('/:slug', optionalAuth, async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({
room: await rooms.publicRoom(room, { role: a.role }),
can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
++ // A workspace room's shared screen link is a key to that screen: members only.
++ ...(rooms.isWorkspace(room) && a.role && room.screen_url ? { screen: { url: room.screen_url } } : {}),
...(room.kind === 'call' ? { call: callOf(room) } : {}),
});
});
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex b7fe53d..13fdcd8 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -84,6 +84,8 @@ function contentSecurityPolicy(nonce) {
"media-src 'self' blob: https:",
"font-src 'self' data:",
"connect-src 'self' wss://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com",
++ // A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
++ "frame-src 'self' https://openvibe.bot",
"frame-ancestors 'self'",
"base-uri 'self'",
"object-src 'none'",
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex b00c99e..3010d60 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -710,6 +710,7 @@ ${people}
${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
++${work && room.screen_url && can.role ? `<details class="oc-screen" open><summary>Shared screen</summary><iframe src="${esc(room.screen_url)}" title="The room's shared screen" loading="lazy" referrerpolicy="no-referrer" allow="clipboard-write; fullscreen"></iframe></details>` : ''}
${work ? '<div class="oc-ws"><div class="oc-ws-main">' : ''}<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${tools}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
});
@@@ -835,6 +836,7 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
++${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
<h2>Members</h2>
<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
@@@ -857,7 +859,8 @@ ${attachList}`,
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
const b = req.body || {};
-- await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });
++ await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined,
++ ...(rooms.isWorkspace(room) && b.screen_url !== undefined ? { screen_url: b.screen_url } : {}) });
await applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex 5aa8150..a6950f8 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -152,4 +152,20 @@ t('the room page shows the queue beside the conversation; the task page has the
assert.strictEqual((await h.http('GET', `/r/staff/tasks/${id}`, { headers: { cookie: `ov_token=${user.token}` } })).status, 404);
});
++t('a workspace room shows its shared screen to members only; only a Bot screen link is taken', async () => {
++ const link = 'https://openvibe.bot/screen/' + 'a'.repeat(43);
++ let r = await api('PATCH', '/rooms/staff', staff, { screen_url: 'https://evil.test/screen/x' });
++ assert.strictEqual(r.status, 422);
++ r = await api('PATCH', '/rooms/staff', staff, { screen_url: link });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await api('GET', '/rooms/staff', staff2);
++ assert.strictEqual(r.body.screen.url, link);
++ const page = await h.http('GET', '/r/staff', { headers: { cookie: `ov_token=${staff.token}` } });
++ assert.match(page.text, new RegExp(`<iframe src="${link.replace(/\//g, '\\/')}"`));
++ assert.match(page.headers.get('content-security-policy'), /frame-src 'self' https:\/\/openvibe\.bot/);
++ assert.strictEqual((await api('GET', '/rooms/staff', user)).status, 404, 'others never see the link');
++ assert.strictEqual((await api('PATCH', '/rooms/staff', staff, { screen_url: '' })).status, 200);
++ assert.ok(!(await api('GET', '/rooms/staff', staff)).body.screen, 'cleared');
++});
++
t.run(async () => { if (h && h.close) await h.close(); });