openvibers/openvibe.chat Commits

Chat V2, part 2: every page in the app shell, server onboarding, Explore's featured row (#441)

OP OpenVibers committed 5 days ago · 883221c6a9 · 14 files, +726 −55

* Chat pages inside the app shell: Global chat, Rooms, a room, settings and the inbox

* Onboarding when joining a server: its rules, then the roles a member picks

* Explore's featured row: staff feature a server, above the categories

* Welcome page design: the server's mark, rules numbered one a line, roles as chips in their colours

One form with Continue and Skip for now (full width on a phone); the reading column is 620 px. A leading '-', '*', '•' or '1.' on a rule is dropped so the list numbers it once.

* workspace test: a room is a shell channel now (oc-srv-feed, no footer)

Changes

ddiff --git a/migrations/0055_server_onboarding.sql b/migrations/0055_server_onboarding.sql
nnew file mode 100644
iindex 0000000..48fadf9
---- /dev/null
++++ b/migrations/0055_server_onboarding.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Onboarding (the plan, 2026-11-09, stage V3): a server's rules, the roles a new member may pick, and the roles
++-- each member picked. Rules and role definitions are the server's; a member's picks are their own and go with their
++-- membership (account export and deletion already cover chat_server_members).
++ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS rules text;
++ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS self_roles text;
++ALTER TABLE chat_server_members ADD COLUMN IF NOT EXISTS roles text;
ddiff --git a/migrations/0056_server_featured.sql b/migrations/0056_server_featured.sql
nnew file mode 100644
iindex 0000000..10ddd8f
---- /dev/null
++++ b/migrations/0056_server_featured.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- Explore's featured row (the plan, 2026-11-09, stage V3): staff mark a public server to sit above the categories.
++ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS featured_at text;
ddiff --git a/public/web/chat-shell.css b/public/web/chat-shell.css
iindex 974c9b0..0f4ee03 100644
---- a/public/web/chat-shell.css
++++ b/public/web/chat-shell.css
@@@ -33,6 +33,7 @@
.oc-side-top { padding: 0 0 10px; }
.oc-side-find { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 10px; border-radius: 10px; background: var(--oc-panel-2); color: var(--oc-text); text-decoration: none; font-size: .92rem; }
.oc-side-find:hover { background: var(--oc-panel); }
++.oc-side-top .oc-side-find + .oc-side-find { margin-top: 6px; }
.oc-side-find .oc-i { width: 18px; height: 18px; color: var(--oc-muted); }
.oc-side-sec { margin-bottom: 12px; }
.oc-side-group { display: flex; align-items: center; gap: 6px; margin: 0; padding: 8px 8px 4px; font-size: .72rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--oc-muted); }
@@@ -156,6 +157,10 @@
.oc-card > .oc-button { text-align: center; }
.oc-server-card--new { justify-content: center; border-style: dashed; }
++/* Explore's featured row, above the categories; with none featured the row is not in the page. */
++.oc-featured { margin: 4px 0 16px; }
++.oc-featured-cards { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
++
/* The sheet: a template, a name and a picture, public or private. */
.oc-sheet { margin: 18px 0 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }
.oc-sheet > summary { display: block; padding: 14px 18px; cursor: pointer; font-weight: 700; list-style: none; }
@@@ -202,3 +207,50 @@
/* Absolutely placed inputs (the template radios) stay inside the page that scrolls, not the document. */
.oc-page, .oc-template { position: relative; }
++
++/* Onboarding (/s/:slug/welcome): the server's mark and name, its rules numbered one a line, the roles a member may
++ pick as chips in their own colours, and Continue. One column of reading width; on a phone the buttons fill it. */
++.oc-welcome { max-width: 620px; margin: 0 auto; }
++.oc-welcome-head { display: flex; align-items: center; gap: 16px; margin: 6px 0 18px; }
++.oc-welcome-head .oc-srv-icon--big { margin: 0; flex: none; }
++.oc-welcome-head h1 { margin: 0; font-size: clamp(1.35rem, 4.6vw, 1.75rem); line-height: 1.15; overflow-wrap: anywhere; }
++.oc-welcome-head .oc-muted { margin: 4px 0 0; }
++.oc-welcome-kicker { margin: 0 0 2px; font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }
++.oc-welcome-form { display: grid; gap: 12px; }
++.oc-welcome-form .oc-set { margin: 0; }
++.oc-rules { list-style: none; counter-reset: oc-rule; margin: 0; padding: 4px 14px 12px; display: grid; gap: 2px; }
++.oc-rules li { counter-increment: oc-rule; display: grid; grid-template-columns: 26px minmax(0, 1fr); gap: 10px; align-items: start; padding: 7px 0; overflow-wrap: anywhere; }
++.oc-rules li + li { border-top: 1px solid var(--oc-line); }
++.oc-rules li::before { content: counter(oc-rule); display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: color-mix(in srgb, var(--oc-accent) 16%, transparent); color: var(--oc-text); font-size: .8rem; font-weight: 800; }
++.oc-rules-text { margin: 0; padding: 4px 14px 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
++fieldset.oc-role-set { min-width: 0; padding: 0 0 14px; }
++.oc-role-set > legend { float: left; width: 100%; margin: 0; padding: 12px 14px 4px; font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }
++.oc-role-set > legend + * { clear: left; }
++.oc-set-note { margin: 0; padding: 0 14px 10px; color: var(--oc-muted); font-size: .88rem; }
++.oc-role-picks { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 14px; }
++.oc-role-pick { --oc-role: var(--oc-accent); position: relative; }
++.oc-role-pick input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
++.oc-role-pick span { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 14px 0 12px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-text); font-weight: 600; }
++.oc-role-pick i { width: 10px; height: 10px; border-radius: 50%; background: var(--oc-role); flex: none; }
++.oc-role-pick input:checked + span { border-color: var(--oc-role); background: color-mix(in srgb, var(--oc-role) 18%, transparent); }
++.oc-role-pick input:checked + span::after { content: ''; width: 12px; height: 7px; margin: -3px 0 0 2px; border: solid currentColor; border-width: 0 0 2px 2px; transform: rotate(-45deg); }
++.oc-role-pick input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++.oc-role-pick:hover span { border-color: var(--oc-role); }
++.oc-welcome-go { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
++.oc-welcome-go button { min-height: 44px; padding: 0 22px; }
++.oc-welcome-skip { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 10px; color: var(--oc-muted); text-decoration: none; font-weight: 600; }
++.oc-welcome-skip:hover { color: var(--oc-text); background: color-mix(in srgb, var(--oc-text) 7%, transparent); }
++@media (max-width: 560px) {
++ .oc-welcome-head { gap: 12px; }
++ .oc-welcome-head .oc-srv-icon--big { width: 56px; height: 56px; border-radius: 18px; font-size: 1.2rem; }
++ .oc-welcome-go { flex-direction: column; align-items: stretch; }
++ .oc-welcome-go button, .oc-welcome-skip { width: 100%; justify-content: center; }
++}
++
++/* The inbox's conversation on the network's kit: its conversation list is the shell's side column and its thread fills
++ the middle, so the kit's own sidebar and sticky chrome give way to the shell's. */
++.oc-app .ovk-sidebar { position: static; height: auto; flex: 1; min-height: 0; border-right: 0; background: transparent; }
++.oc-app .ovt { height: 100%; min-height: 0; grid-template-columns: minmax(0, 1fr); }
++.oc-app .ovt-main { min-height: 0; height: 100%; }
++.oc-app .ovt-scroll { overflow-y: auto; min-height: 0; }
++.oc-app .ovt-head, .oc-app .ovt-dock { position: static; }
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 79267b9..21cc3c3 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -50,7 +50,7 @@ const EXPORTS = [
['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) 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}`],
-- ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, 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}`],
['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}`],
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex 413d1d4..e6d7d2c 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -46,10 +46,28 @@ const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invi
const MAX_SERVERS_PER_OWNER = 5;
const MAX_CHANNELS = 50;
const MAX_INVITES = 25;
++const MAX_RULES = 4000;
++const MAX_SELF_ROLES = 10;
const INVITE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
const isStaff = (user) => rooms.isStaff(user);
++/** A JSON text column of names ({ name, color } for a server's roles, plain names for a member's picks). */
++function parseList(v) {
++ try { const a = JSON.parse(v || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
++}
++/** The roles a server offers its members: [{ name, color }], names unique, at most MAX_SELF_ROLES. */
++function selfRolesOf(server) {
++ return parseList(server && server.self_roles)
++ .map((r) => ({ name: String((r && r.name) || '').slice(0, 24), color: /^#[0-9a-f]{3,8}$/i.test(String((r && r.color) || '')) ? r.color : null }))
++ .filter((r) => r.name)
++ .slice(0, MAX_SELF_ROLES);
++}
++/** Does a server ask anyone joining to read its rules or pick roles? */
++function hasOnboarding(server) {
++ return !!(server && typeof server.rules === 'string' && server.rules.trim()) || selfRolesOf(server).length > 0;
++}
++
function slugify(name) {
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
}
@@@ -106,6 +124,8 @@ function publicServer(s, extra = {}) {
id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,
official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at,
category: s.category || null, picture_url: s.picture_url || null, template: s.template || null, ...extra,
++ rules: typeof s.rules === 'string' && s.rules.trim() ? s.rules : null, self_roles: selfRolesOf(s),
++ featured: !!s.featured_at,
};
}
@@@ -307,21 +327,74 @@ async function setRole(server, actor, targetUserId, role) {
return role === 'none' ? null : role;
}
--/** The server's people by rank (blocked last, only for moderators). */
++/** The public servers staff have featured, newest first (Explore's row above the categories). */
++async function featured({ limit = 12 } = {}) {
++ const lim = Math.min(50, Math.max(1, Number(limit) || 12));
++ return (await db.all(`SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL AND featured_at IS NOT NULL
++ ORDER BY featured_at DESC, member_count DESC, id DESC LIMIT ?`, [lim])).map((s) => publicServer(s));
++}
++
++/** Feature or unfeature a server on Explore. OpenVibe staff only (the plan, 2026-11-09, stage V3). */
++async function setFeatured(server, actor, on) {
++ if (!isStaff(actor)) fail(403, 'servers.staff', 'Only OpenVibe staff feature a server');
++ if (on) await db.run('UPDATE chat_servers SET featured_at = COALESCE(featured_at, ov_now()), updated_at = ov_now() WHERE id = ?', [server.id]);
++ else await db.run('UPDATE chat_servers SET featured_at = NULL, updated_at = ov_now() WHERE id = ?', [server.id]);
++ return await byId(server.id);
++}
++
++/** The server's people by rank (blocked last, only for moderators). A member's picked roles ride along. */
async function members(server, { blocked = false, limit = 1000, subjects = false } = {}) {
const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
-- return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color, u.subject_id, u.avatar_url FROM chat_server_members m
++ const rows = await db.all(`SELECT m.user_id, m.role, m.roles, m.joined_at, u.username, u.display_name, u.profile_color, u.subject_id, u.avatar_url FROM chat_server_members m
LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
++ return rows.map((p) => ({ ...p, roles: parseList(p.roles).map(String).filter(Boolean) }));
}
--async function update(server, actor, { name, description, visibility } = {}) {
++/** The roles one member picked in this server (empty when none). */
++async function memberRoles(server, userId) {
++ const row = await db.get('SELECT roles FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, Number(userId)]);
++ return row ? parseList(row.roles).map(String).filter(Boolean) : [];
++}
++
++/** A member picks their own roles: only names the server offers, and only while they are in it. */
++async function setMemberRoles(server, user, names) {
++ if (!user) fail(401, 'servers.sign_in', 'Sign in to pick your roles');
++ const role = await roleIn(server.id, user.id);
++ if (!role || role === 'blocked') fail(403, 'servers.member', 'Join the server first');
++ const offered = new Set(selfRolesOf(server).map((r) => r.name));
++ const picked = [...new Set((Array.isArray(names) ? names : [names]).map((n) => String(n || '').slice(0, 24)).filter((n) => offered.has(n)))];
++ await db.run('UPDATE chat_server_members SET roles = ? WHERE server_id = ? AND user_id = ?', [JSON.stringify(picked), server.id, user.id]);
++ return picked;
++}
++
++async function update(server, actor, { name, description, visibility, rules, self_roles } = {}) {
if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change it');
-- const next = { name: server.name, description: server.description, visibility: server.visibility };
++ const next = { name: server.name, description: server.description, visibility: server.visibility, rules: server.rules || null, self_roles: selfRolesOf(server) };
if (name != null) { const n = cleanName(name, 2, 40); if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.name', 'Server names are 2 to 40 characters'); next.name = n; }
if (description != null) next.description = String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null;
if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private'); next.visibility = visibility; }
-- await db.run('UPDATE chat_servers SET name = ?, description = ?, visibility = ?, updated_at = ov_now() WHERE id = ?', [next.name, next.description, next.visibility, server.id]);
++ if (rules != null) {
++ const r = String(rules).replace(/\r\n?/g, '\n').trim();
++ if (r.length > MAX_RULES) fail(422, 'servers.rules', `The rules are up to ${MAX_RULES} characters`);
++ next.rules = r || null;
++ }
++ if (self_roles != null) {
++ if (!Array.isArray(self_roles)) fail(422, 'servers.roles', 'The roles are a list');
++ if (self_roles.length > MAX_SELF_ROLES) fail(422, 'servers.roles', `A server offers up to ${MAX_SELF_ROLES} roles`);
++ const seen = new Set();
++ next.self_roles = [];
++ for (const raw of self_roles) {
++ const n = String((raw && raw.name) || '').replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 24);
++ if (!n || seen.has(n.toLowerCase())) continue;
++ if (ROLES.includes(n.toLowerCase())) fail(422, 'servers.roles', `“${n}” is a server permission, not a role to pick`);
++ if (wordFilter.check(n).safe === false) fail(422, 'servers.roles', `“${n}” is not allowed`);
++ seen.add(n.toLowerCase());
++ next.self_roles.push({ name: n, color: /^#[0-9a-f]{3,8}$/i.test(String((raw && raw.color) || '')) ? raw.color : null });
++ }
++ }
++ await db.run('UPDATE chat_servers SET name = ?, description = ?, visibility = ?, rules = ?, self_roles = ?, updated_at = ov_now() WHERE id = ?',
++ [next.name, next.description, next.visibility, next.rules, JSON.stringify(next.self_roles), server.id]);
// The channels follow the server: going private redacts what public channels had published (rooms.update).
if (next.visibility !== server.visibility) {
for (const r of await db.all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {
@@@ -423,5 +496,7 @@ module.exports = {
bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
createInvite, invites, revokeInvite, inviteByCode, archive,
-- SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG,
++ selfRolesOf, hasOnboarding, memberRoles, setMemberRoles,
++ featured, setFeatured,
++ SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG, MAX_RULES, MAX_SELF_ROLES,
};
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 0029a0a..406047d 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -24,6 +24,7 @@ const { embedOf, sendExport } = require('../inbox/routes');
const records = require('../inbox/records');
const { esc, asset } = require('./layout');
const ui = require('./ui');
++const shell = require('./shell');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
// A visitor's initial, in a circle, beside their row.
@@@ -100,19 +101,28 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
? `<ul class="ib-list">${mine.map((i) => `<li><a href="/inbox/${esc(i.id)}"><span class="ib-dot" style="background:${esc(i.color)}"></span><b>${esc(i.name)}</b>
<span class="oc-muted">${i.waiting ? `${i.waiting} waiting · ` : ''}${i.open} open · ${i.role === 'owner' ? 'owner' : 'agent'}</span></a></li>`).join('')}</ul>`
: '<p class="oc-muted">No inboxes yet. Make one below, paste its snippet into your site, and visitors can write to you: Vibe answers first, you take over when it matters.</p>';
++ const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
++ const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
await page(req, res, 200, {
-- actor, title: 'Inbox', path: '/inbox', active: 'inbox', robots: 'noindex, nofollow',
-- body: `<h1>Inbox</h1>
++ actor, title: 'Inbox', path: '/inbox', active: 'inbox', robots: 'noindex, nofollow', shell: true, footer: 'none',
++ body: shell.shell({
++ rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: shell.sideInbox({ inboxes: mine, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),
++ sideLabel: 'Inbox',
++ main: `<h1>Inbox</h1>
<p class="oc-muted">Live chat for your website or community: one line of HTML adds a chat bubble. Vibe, OpenVibe's assistant, answers visitors from your own instructions and pages, and hands them to you or your team when they ask for a person.</p>
${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
${list}
--<h2>Make an inbox</h2>
++<h2 id="oc-new-inbox">Make an inbox</h2>
<form class="oc-form" method="post" action="/inbox">
<label>Name <input name="name" required minlength="2" maxlength="60" placeholder="Acme support"></label>
<label>Greeting <input name="greeting" maxlength="300" placeholder="Hi! Ask us anything."></label>
<label>What Vibe should know <textarea name="ai_instructions" rows="4" maxlength="4000" placeholder="We ship worldwide in 3-5 days. Refunds within 30 days. Hours: 9-5 PT."></textarea></label>
<label>Pages Vibe may read (one per line) <textarea name="knowledge_urls" rows="3" placeholder="https://example.com/faq"></textarea></label>
<p><button type="submit">Make the inbox</button></p></form>`,
++ mainLabel: 'Inbox',
++ page: true,
++ }),
});
});
@@@ -143,16 +153,26 @@ ${list}
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, current);
const me = Number(actor.user.id);
-- const body = `<div class="ibk" id="ib" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">${thread.page({
-- sidebar: conversationsRail({ inbox, list, filter, current, me, nameOf, waiting: counts.waiting }),
-- head: thread.head({ title: current ? (current.visitor_name || 'Visitor') : inbox.name, sidebar: 'ib-rail', sidebarLabel: 'Show conversations', actions: headActions({ inbox, current, team, me }) }),
++ // The shell's side column lists the conversations; the main column is the DM-like thread (head, turns, composer).
++ const pane = thread.page({
++ sidebar: '',
++ head: thread.head({ title: current ? (current.visitor_name || 'Visitor') : inbox.name, sidebar: null, actions: headActions({ inbox, current, team, me }) }),
turns: current ? [...facts(current), ...msgs.map((m) => inboxTurn(m, current))] : [],
empty: `<div class="ib-hello"><h2>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h2><p>${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`,
composer: current ? replyBox({ inbox, current, actor, saved }) : '',
label: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
++ });
++ const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
++ const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
++ const body = `<div class="ibk" id="ib" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">${shell.shell({
++ rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: conversationsRail({ inbox, list, filter, current, me, nameOf, waiting: counts.waiting }),
++ sideLabel: 'Conversations',
++ main: pane,
++ mainLabel: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
})}</div>`;
await page(req, res, 200, {
-- actor, app: true, kit: true, appScript: 'inbox.js', appStyle: 'inbox.css', scripts: ['support-call.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${current ? `${current.visitor_name || 'Visitor'} · ` : ''}${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,
++ actor, shell: true, kit: true, appStyle: 'inbox.css', scripts: ['support-call.js', 'inbox.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${current ? `${current.visitor_name || 'Visitor'} · ` : ''}${inbox.name} · Inbox`, robots: 'noindex, nofollow', footer: 'none', body,
page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, call: !!(current && inboxes.convShape(current).call), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
});
});
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 81baa82..0b874cc 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -165,7 +165,7 @@ ${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
--${o.app ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
++${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex c0204f0..2df0ed4 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -294,27 +294,44 @@ function createWebRoutes({ config }) {
].filter((pane) => pane.html);
const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
const inlineStyles = [];
-- const feedList = `<ol class="oc-feed${signedIn ? ' oc-room-feed' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>`;
-- const appBody = () => `<section class="oc-room oc-global" aria-labelledby="oc-h">
--<header class="oc-room-head"><div class="oc-room-id"><h1 id="oc-h">Global chat</h1>
--<p class="oc-muted oc-room-sub">Everyone on OpenVibe${home.peopleLine(activity)} <span class="oc-live" id="oc-live" hidden>● live</span></p></div>
--<div class="oc-room-tools"><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></div></header>
++ const feedList = `<ol class="oc-feed${signedIn ? ' oc-srv-feed' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>`;
++ // Someone signed in gets the app shell (the plan, 2026-11-09, stage V2): Global chat is a destination on the
++ // rail, the side column is Home's, and the feed fills the middle with the composer at its foot.
++ const shellBody = signedIn ? await (async () => {
++ const me = actor.user;
++ const convs = await dm.getConversations(me.id);
++ const participants = await Promise.all(convs.map((c) => dm.getParticipants(c.id)));
++ await presence.attachTo(me.subject_id, participants.flat());
++ const dms = convs.map((c, i) => {
++ const others = participants[i].filter((p) => p.id !== me.id);
++ return {
++ id: c.id, title: c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you',
++ preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet',
++ unread: Number(c.unread_count) || 0, presence: others.length === 1 ? others[0].presence : null,
++ avatarUrl: others.length === 1 ? others[0].avatar_url : '',
++ };
++ });
++ const contacts = await require('../chat/contacts').of(me.id);
++ const mineServers = (await require('../rooms/servers').list(me)).mine;
++ return shell.shell({
++ rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
++ sideLabel: 'Home',
++ main: `<header class="oc-chan-head"><h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>
${error}
${feedList}
--${composer}
--</section>
--${parts.vibe}
--${parts.streams}
--${parts.servers}
--${parts.rooms}`;
++${composer}`,
++ mainLabel: 'Global chat',
++ });
++ })() : '';
await page(req, res, 200, {
actor, fullTitle: 'OpenVibe.Chat: global chat, servers, rooms and calls', path: '/', active: 'global', robots: 'index, follow', script: true,
description: 'Chat with everyone on OpenVibe, start a server with text and voice channels, message friends and call them. Free and open source.',
// The same JSON-LD for every reader (discovery.test.js): the questions are the site's, shown to visitors.
jsonLd: [...homeJsonLd(config), sharedSeo.jsonLd.faq(home.FAQ)],
page: { view: 'global', latest: latest_id, signedIn, me: signedIn ? actor.user.username : null },
-- footer: signedIn ? 'compact' : 'full',
-- body: signedIn ? appBody() : `<header class="oc-hero">
++ shell: signedIn, footer: signedIn ? 'none' : 'full',
++ body: signedIn ? shellBody : `<header class="oc-hero">
<h1>Chat with everyone on OpenVibe, or start your own server</h1>
<p class="oc-lede">The chat on every stream, servers with voice channels, messages and calls, and Vibe, your assistant. One free account, everywhere.</p>
<p class="oc-hero-actions"><a class="oc-button" href="/vibe">Chat with Vibe</a> <a class="oc-button oc-button--quiet" href="${signedIn ? '/servers#oc-new-server' : '/auth/login?next=%2Fservers'}">Start a server</a></p>
@@@ -615,9 +632,23 @@ ${composer}`,
const sizes = ['small', 'default', 'large'].map((v) => `<label class="oc-seg-opt"><input type="radio" name="font_size" value="${v}"${v === size ? ' checked' : ''}><span>${v[0].toUpperCase()}${v.slice(1)}</span></label>`).join('');
const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
++ const dmUnread = await dm.getTotalUnread(actor.user.id);
++ const sections = [
++ { href: '#oc-set-look', label: 'Messages look' },
++ { href: '#oc-set-dm', label: 'Direct messages' },
++ { href: '#oc-set-agents', label: 'AI agents' },
++ { href: '#oc-set-sound', label: 'Speech and sounds' },
++ { href: '#oc-set-data', label: 'Your data' },
++ ];
++ if (require('../rooms/ai-members').enabled()) sections.push({ href: '#oc-ai-key', label: 'Your AI key' });
await page(req, res, 200, {
-- actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', footer: 'compact',
-- body: `${ui.head({ title: 'Settings', sub: 'Saved to your account: here, in Live chat and on every device' })}${saved}${error}
++ actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', shell: true, footer: 'none',
++ body: shell.shell({
++ rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: shell.sideSettings({ items: sections, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),
++ sideLabel: 'Settings',
++ main: `${ui.head({ title: 'Settings', sub: 'Saved to your account: here, in Live chat and on every device' })}${saved}${error}
<form class="oc-settings" method="post" action="/settings">
<section class="oc-set" aria-labelledby="oc-set-look"><h2 id="oc-set-look">Messages look</h2>
${box('timestamps', 'Times next to messages', p.timestamps === true)}
@@@ -651,6 +682,9 @@ ${volume('tts_sound_volume', 'Sound volume', tts.sound_volume)}
<div class="oc-set-row"><span class="oc-set-text">Every message you sent that is still visible<small>In Live chat and here</small></span><span class="oc-set-actions"><a class="oc-pill" href="/api/chat/me/export" download>${IC.download}<span>JSON</span></a><a class="oc-pill" href="/api/chat/me/export?format=csv" download>${IC.download}<span>CSV</span></a></span></div>
</section>
${await aiKeySection(actor, req.query || {})}`,
++ mainLabel: 'Settings',
++ page: true,
++ }),
});
});
@@@ -837,10 +871,14 @@ ${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system
<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>
<p><button type="submit">Create room</button></p>
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${wantWork ? '%2Frooms%3Fnew%3Dworkspace' : '%2Frooms'}">Sign in with OpenVibe</a> to start or join a room.</p>`;
-- await page(req, res, 200, {
-- actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', footer: 'compact',
-- description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
-- body: `${ui.head({ title: 'Rooms', sub: 'Group chats, calls and workspaces with Vibe', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-room" aria-label="Start a room" title="Start a room">${IC.plus}</a>`] : [] })}${error}
++ const mineServers = me ? (await require('../rooms/servers').list(me)).mine : [];
++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
++ const sideRooms = shell.sideRooms({
++ mine: mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private' })),
++ public: others.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private' })),
++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
++ });
++ const content = `${ui.head({ title: 'Rooms', sub: 'Group chats, calls and workspaces with Vibe', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-room" aria-label="Start a room" title="Start a room">${IC.plus}</a>`] : [] })}${error}
${mine.length ? `<h2 class="oc-list-head">Your rooms</h2><ul class="oc-list">${mine.map(roomCard).join('')}</ul>` : ''}
<h2 class="oc-list-head">${mine.length ? 'More public rooms' : 'Public rooms'}</h2>${others.length ? `<ul class="oc-list">${others.map(roomCard).join('')}</ul>` : mine.length ? '<p class="oc-rooms-empty oc-muted">No other public room yet.</p>' : `<div class="oc-rooms-empty">
<p><strong>No public room is open right now: start the first one below.</strong> A room can be about anything, private to the people you invite, a voice call, or a group chat with AI members who know the context.</p>
@@@ -849,7 +887,18 @@ ${mine.length ? `<h2 class="oc-list-head">Your rooms</h2><ul class="oc-list">${m
<li><a href="/servers">Servers</a>: channels, voice, roles and invite links, Discord-style</li>
<li><a href="/messages">Messages</a>: direct messages with anyone on OpenVibe</li></ul></div>`}
${create}
--<p class="oc-muted">Building something? A <a href="/feeds">feed</a> shows your coding session live in a room, on your Live channel or on your site.</p>`,
++<p class="oc-muted">Building something? A <a href="/feeds">feed</a> shows your coding session live in a room, on your Live channel or on your site.</p>`;
++ await page(req, res, 200, {
++ actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', shell: true, footer: 'none',
++ description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
++ body: shell.shell({
++ rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: sideRooms,
++ sideLabel: 'Rooms',
++ main: content,
++ mainLabel: 'Rooms',
++ page: true,
++ }),
});
});
@@@ -946,13 +995,24 @@ ${people}
const topic = typeof room.topic === 'string' ? room.topic.trim() : '';
const description = topic.length >= 40 ? topic
: `${room.name}: a ${room.visibility === 'public' ? 'public' : 'private'} room on OpenVibe.Chat with ${memberCount} ${memberCount === 1 ? 'member' : 'members'}. Read along, or sign in to talk.`;
++ const mineServers = me ? (await require('../rooms/servers').list(me)).mine : [];
++ const listed = await rooms.list(me);
++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
await page(req, res, 200, {
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
-- footer: 'compact', scripts: ais.length ? ['vibe-md.js'] : [], wide: !!work,
++ footer: 'none', shell: true, scripts: ais.length ? ['vibe-md.js'] : [],
description,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
-- body: `<header class="oc-room-head">
++ body: shell.shell({
++ rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: shell.sideRooms({
++ mine: listed.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
++ public: listed.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
++ }),
++ sideLabel: 'Rooms',
++ main: `<header class="oc-room-head">
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
<p class="oc-muted oc-room-sub">${room.topic && !isAgentRoom ? `${esc(room.topic)} · ` : ''}${memberCount} ${memberCount === 1 ? 'member' : 'members'}${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole} <span class="oc-live" id="oc-live" hidden>● live</span></p>${roomRules(room)}</div>
@@@ -964,8 +1024,10 @@ ${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 oc-room-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>
++${work ? '<div class="oc-ws"><div class="oc-ws-main">' : ''}<ol class="oc-feed oc-srv-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}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
++ mainLabel: room.name,
++ }),
});
});
@@@ -1095,11 +1157,23 @@ ${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on thi
${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove ${esc(a.name)}</span></button></form></section>`).join('')}
${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ? `<form class="oc-form oc-ai-quick" method="post" action="/r/${esc(room.slug)}/ai-members"><input type="hidden" name="name" value="Vibe"><input type="hidden" name="persona" value="${esc(VIBE_IN_ROOMS)}"><p><button type="submit">Add Vibe</button> <span class="oc-muted">OpenVibe's assistant joins the room: anyone here can ask it with @vibe.</span></p></form>` : ''}
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
++ const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
++ const listedRooms = await rooms.list(actor.user);
++ const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
++ const roomShell = (main) => shell.shell({
++ rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
++ side: shell.sideRooms({
++ mine: listedRooms.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
++ public: listedRooms.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
++ you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' },
++ }),
++ sideLabel: 'Rooms', main, mainLabel: 'Settings', page: true,
++ });
if (room.server_id) {
return await page(req, res, 200, {
-- actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', footer: 'compact',
-- body: `${ui.head({ title: `AI members in #${esc(room.name)}`, back: { href: await channelUrl(room, '/settings'), label: 'Server settings' } })}${saved}${error}
--${aiSection}`,
++ actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none',
++ body: roomShell(`${ui.head({ title: `AI members in #${esc(room.name)}`, back: { href: await channelUrl(room, '/settings'), label: 'Server settings' } })}${saved}${error}
++${aiSection}`),
});
}
const hookList = can.manage && rooms.isWorkspace(room) ? await roomHooks.list(room) : [];
@@@ -1113,8 +1187,8 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<section class="oc-set"><h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/forum/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted oc-set-note">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}</section>` : '';
await page(req, res, 200, {
-- actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow', footer: 'compact', script: true, page: { view: 'settings' },
-- body: `${ui.head({ title: 'Room settings', sub: esc(room.name), back: { href: `/r/${room.slug}`, label: 'Back to the room' } })}${saved}${error}
++ actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow', shell: true, footer: 'none', script: true, page: { view: 'settings' },
++ body: roomShell(`${ui.head({ title: 'Room settings', sub: esc(room.name), back: { href: `/r/${room.slug}`, label: 'Back to the room' } })}${saved}${error}
${can.manage ? `<section class="oc-set"><h2>Room</h2><form class="oc-form oc-grid-form oc-set-form" method="post" action="/r/${esc(room.slug)}/settings">
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
@@@ -1137,7 +1211,7 @@ ${can.manage && await require('./room-embed-pages').embeddable(room) ? `<details
<p class="oc-muted oc-small">Or in a part of your page (an element with id="chat"):</p>
<div class="oc-snip"><button type="button" class="oc-icon-btn" data-oc-copy="#oc-snip-inline" aria-label="Copy" title="Copy" hidden>${IC.copy}</button><pre class="ib-snippet" tabindex="0" id="oc-snip-inline"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" data-target="#chat" data-height="520" async></script>`)}</code></pre></div></details>` : ''}
${aiSection}
--${attachList}`,
++${attachList}`),
});
});
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex c7a4c96..f76e496 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -55,6 +55,7 @@ function mountServerPages(router, h) {
const actor = await viewerOf(req);
const me = userOf(actor);
const { public: pub, mine } = await servers.list(me);
++ const featuredServers = await servers.featured();
const templates = require('../rooms/server-templates');
const q = String(req.query.q || '').trim().toLowerCase();
const cat = String(req.query.category || '');
@@@ -95,6 +96,8 @@ function mountServerPages(router, h) {
${join}</article>`;
};
const startCard = `<article class="oc-card oc-server-card oc-server-card--new"><h3>Start a server</h3><p class="oc-card-line">A template, a name and a picture: under ten seconds.</p><a class="oc-button" href="#oc-new-server">Start a server</a></article>`;
++ // Staff feature a few servers above the categories. With none featured the row is left out entirely.
++ const featuredRow = featuredServers.length ? `<section class="oc-featured" aria-labelledby="oc-featured-h"><h2 class="oc-list-head" id="oc-featured-h">Featured</h2><div class="oc-cards oc-featured-cards">${featuredServers.map(card).join('')}</div></section>` : '';
// The sheet: a template first, then a name and a picture, then public or private.
const sheet = me ? `<details class="oc-sheet" id="oc-new-server"><summary class="oc-button">Start a server</summary>
<form class="oc-form oc-sheet-form" method="post" action="/servers/new" enctype="multipart/form-data">
@@@ -128,6 +131,7 @@ ${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"
side, sideLabel: 'Your servers',
main: `${ui.head({ title: 'Explore servers', sub: `${listed.length} server${listed.length === 1 ? '' : 's'}` })}
<form class="oc-find" method="get" action="/servers" role="search">${IC.search}<label for="oc-server-q" class="oc-sr">Find a server</label><input id="oc-server-q" name="q" value="" placeholder="Find a server" autocomplete="off"><button type="submit" class="oc-send" aria-label="Find" title="Find">${IC.search}</button></form>
++${featuredRow}
${chips}
<div class="oc-cards">${listed.map(card).join('')}${startCard}</div>
${sheet}`,
@@@ -223,6 +227,7 @@ ${sheet}`,
id: p.user_id, username: p.username || '', display_name: p.display_name || p.username || '',
avatar_url: p.avatar_url || null, presence: p.presence || null,
color: /^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? p.profile_color : '',
++ roles: p.roles || [],
href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
})),
}));
@@@ -289,7 +294,8 @@ ${composer}`,
req.back = `/s/${server.slug}`;
await servers.join(server, actor.user);
await serverRoutes().applyServerChange(server);
-- res.redirect(303, `/s/${server.slug}`);
++ // Onboarding: the server's rules, then the roles they may pick (the plan, stage V3).
++ res.redirect(303, servers.hasOnboarding(server) ? `/s/${server.slug}/welcome` : `/s/${server.slug}`);
}));
router.post('/s/:slug/leave', formLimit, sameSite, orError(async (req, res) => {
@@@ -302,6 +308,118 @@ ${composer}`,
res.redirect(303, '/servers');
}));
++ // ── Onboarding when joining (the plan, 2026-11-09, stage V3): the server's rules, then the roles they may pick ──
++ /** The shell's side column for a server: its channels and the people in its voice channels. */
++ async function serverSide(server, can, me, chans) {
++ const categories = [];
++ const byCat = new Map();
++ for (const c of chans) {
++ const name = c.category || (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;
++ 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 dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
++ return shell.sideServer({
++ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,
++ categories: categories.map((c) => ({ name: c.name, channels: c.channels.map((ch) => ({ kind: ch.kind, name: ch.name, slug: ch.slug, href: `/s/${server.slug}/${ch.slug}` })) })),
++ occupants,
++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
++ });
++ }
++ async function serverRail(me, activeSlug) {
++ const mine = me ? (await servers.list(me)).mine : [];
++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
++ return shell.rail({ dmUnread, servers: mine.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === activeSlug })) });
++ }
++
++ router.get('/s/:slug/welcome', async (req, res) => {
++ const x = await loadServer(req, res); if (!x) return;
++ const { actor, me, server, can } = x;
++ if (!me) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/s/${server.slug}/welcome`)}`);
++ if (!can.member) return res.redirect(303, `/s/${server.slug}`);
++ const roles = servers.selfRolesOf(server);
++ const picked = await servers.memberRoles(server, me.id);
++ const chans = await servers.channels(server);
++ const rules = typeof server.rules === 'string' ? server.rules.trim() : '';
++ const roleList = roles.map((r) => `<label class="oc-role-pick"${r.color ? ` style="--oc-role:${esc(r.color)}"` : ''}><input type="checkbox" name="roles" value="${esc(r.name)}"${picked.includes(r.name) ? ' checked' : ''}><span><i aria-hidden="true"></i>${esc(r.name)}</span></label>`).join('\n');
++ // One rule a line (a leading "-", "*", "•" or "1." is the owner's own numbering and goes); a single line stays prose.
++ const ruleLines = rules.split(/\r?\n/).map((l) => l.replace(/^\s*(?:[-*\u2022]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
++ const rulesHtml = ruleLines.length > 1 ? `<ol class="oc-rules">${ruleLines.map((l) => `<li>${esc(l)}</li>`).join('\n')}</ol>` : `<p class="oc-rules-text">${esc(rules)}</p>`;
++ await page(req, res, 200, {
++ actor, shell: true, footer: 'none', title: `Welcome to ${server.name}`, path: `/s/${server.slug}/welcome`, active: 'servers', robots: 'noindex, nofollow',
++ body: shell.shell({
++ rail: await serverRail(me, server.slug),
++ side: await serverSide(server, can, me, chans),
++ sideLabel: server.name,
++ main: `<div class="oc-welcome">
++<header class="oc-welcome-head"><span class="oc-srv-icon oc-srv-icon--big" style="--oc-hue:${hueOf(server.slug)}" aria-hidden="true">${esc(initials(server.name))}</span>
++<div><p class="oc-welcome-kicker">Welcome to</p><h1>${esc(server.name)}</h1><p class="oc-muted">${rules && roles.length ? 'Read the rules and pick your roles, then you are in.' : rules ? 'Read the rules, then you are in.' : 'Pick your roles, then you are in.'}</p></div></header>
++<form method="post" action="/s/${esc(server.slug)}/welcome" class="oc-welcome-form">
++${rules ? `<section class="oc-set" aria-labelledby="oc-onboard-rules"><h2 id="oc-onboard-rules">The rules</h2>${rulesHtml}</section>` : ''}
++${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-roles">Pick your roles</legend>
++<p class="oc-set-note">Optional. You can change them any time in the server's menu.</p>
++<div class="oc-role-picks">${roleList}</div></fieldset>` : ''}
++<div class="oc-welcome-go"><button type="submit">${roles.length ? 'Continue' : `Continue to ${esc(server.name)}`}</button>${roles.length ? `<a class="oc-welcome-skip" href="/s/${esc(server.slug)}">Skip for now</a>` : ''}</div>
++</form></div>`,
++ mainLabel: `Welcome to ${server.name}`,
++ page: true,
++ }),
++ });
++ });
++ router.post('/s/:slug/welcome', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ await servers.setMemberRoles(server, actor.user, [].concat((req.body && req.body.roles) || []));
++ res.redirect(303, `/s/${server.slug}`);
++ }));
++
++ // The owner edits the rules and the roles members may pick, here in the server's settings.
++ router.post('/s/:slug/onboarding', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/settings`;
++ await servers.update(server, actor.user, { rules: (req.body && req.body.rules) || '' });
++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-onboarding`);
++ }));
++ router.post('/s/:slug/roles', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/settings`;
++ const b = req.body || {};
++ const roles = servers.selfRolesOf(server);
++ if (roles.length >= servers.MAX_SELF_ROLES) return res.redirect(303, `/s/${server.slug}/settings?error=${encodeURIComponent(`A server offers up to ${servers.MAX_SELF_ROLES} roles`)}#oc-onboarding`);
++ await servers.update(server, actor.user, { self_roles: [...roles, { name: b.name, color: b.color }] });
++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-onboarding`);
++ }));
++ router.post('/s/:slug/roles/delete', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/settings`;
++ const name = String((req.body && req.body.name) || '');
++ await servers.update(server, actor.user, { self_roles: servers.selfRolesOf(server).filter((r) => r.name !== name) });
++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-onboarding`);
++ }));
++ // Explore's featured row (the plan, stage V3): OpenVibe staff mark a public server featured, or take it down.
++ router.post('/s/:slug/featured', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ if (!server) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/settings`;
++ await servers.setFeatured(server, actor.user, (req.body && req.body.featured) === '1');
++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-featured`);
++ }));
++
++
// ── Settings: what each rank may do (overview and channels: owner/admins; invites: owner/admins, and mods in a
// public server; members: owner/admins/mods; delete: the owner) ──
router.get('/s/:slug/settings', async (req, res) => {
@@@ -313,7 +431,23 @@ ${composer}`,
const people = await servers.members(server, { blocked: true, limit: 2000 });
const canInvite = can.manage || server.visibility === 'public';
const invites = canInvite ? await servers.invites(server) : [];
++ const selfRoles = servers.selfRolesOf(server);
const myRank = can.staff ? -1 : servers.RANK[can.role];
++ // The side column: the server's channels, with the people in its voice channels, and the you-bar.
++ const categories = [];
++ const byCat = new Map();
++ for (const c of chans) {
++ const name = c.category || (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;
++ 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 railServers = me ? (await servers.list(me)).mine : [];
++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
const choices = (target) => {
const list = [];
if (myRank <= servers.RANK.owner) list.push('admin');
@@@ -341,15 +475,37 @@ ${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ?
const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
await page(req, res, 200, {
-- actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',
-- body: `<p><a href="/s/${esc(server.slug)}">← ${esc(server.name)}</a></p><h1>Server settings</h1>${saved}${errorOf(req)}
++ actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none',
++ body: shell.shell({
++ rail: shell.rail({ dmUnread, servers: railServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === server.slug })) }),
++ side: shell.sideServer({
++ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can: { ...can, invite: canInvite }, me: !!me,
++ categories: categories.map((c) => ({ name: c.name, channels: c.channels.map((ch) => ({ kind: ch.kind, name: ch.name, slug: ch.slug, href: `/s/${server.slug}/${ch.slug}` })) })),
++ occupants,
++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
++ }),
++ sideLabel: server.name,
++ main: `<p><a href="/s/${esc(server.slug)}">← ${esc(server.name)}</a></p><h1>Server settings</h1>${saved}${errorOf(req)}
${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/settings">
<label>Name <input name="name" required minlength="2" maxlength="40" value="${esc(server.name)}"></label>
<label>Description <input name="description" maxlength="300" value="${esc(server.description || '')}"></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>
<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></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">
++<label>Rules <textarea name="rules" rows="6" maxlength="${servers.MAX_RULES}" placeholder="Be kind. No spam. Keep it on topic.">${esc(server.rules || '')}</textarea></label>
++<p><button type="submit">Save the rules</button></p></form>
++<h3>Roles people may pick</h3>
++${selfRoles.length ? `<ul class="oc-members">${selfRoles.map((r) => `<li>${r.color ? `<span class="oc-dot" style="background:${esc(r.color)}" aria-hidden="true"></span> ` : ''}<strong>${esc(r.name)}</strong> <form class="oc-inline" method="post" action="/s/${esc(server.slug)}/roles/delete"><input type="hidden" name="name" value="${esc(r.name)}"><button type="submit" class="oc-link">Remove</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">No roles yet.</p>'}
++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/roles"><label for="oc-role-new">Add a role</label> <input id="oc-role-new" name="name" required maxlength="24" placeholder="Raider"> <input name="color" type="color" value="#5865f2" aria-label="Its colour"> <button type="submit">Add</button></form>
++</section>` : ''}
++${can.staff ? `<section class="oc-set" id="oc-featured" aria-labelledby="oc-feat-h"><h2 id="oc-feat-h">Explore</h2>
++<p class="oc-muted">Featured servers sit above the categories on Explore, for people who have not joined them.</p>
++<form method="post" action="/s/${esc(server.slug)}/featured"><input type="hidden" name="featured" value="${server.featured_at ? '0' : '1'}"><button type="submit">${server.featured_at ? 'Remove from featured' : 'Feature on Explore'}</button></form>
++</section>` : ''}
${canInvite ? `<h2 id="oc-invites">Invites</h2><p class="oc-muted">${server.visibility === 'private' ? 'A private server is joined only through an invite.' : 'Anyone can join a public server; an invite link is a handy way to bring people straight to it.'}</p>
<form class="oc-new" method="post" action="/s/${esc(server.slug)}/invites"><label for="oc-inv-uses">New invite</label> <select id="oc-inv-uses" name="max_uses" aria-label="How many times it can be used"><option value="">any number of uses</option><option value="1">1 use</option><option value="10">10 uses</option><option value="100">100 uses</option></select> <select name="expires_hours" aria-label="When it expires"><option value="168">for 7 days</option><option value="24">for 1 day</option><option value="720">for 30 days</option><option value="">forever</option></select> <button type="submit">Make an invite</button></form>
<ul class="oc-members">${invites.map(inviteRow).join('') || '<li class="oc-muted">No invites yet.</li>'}</ul>` : ''}
@@@ -359,6 +515,9 @@ ${canInvite ? `<h2 id="oc-invites">Invites</h2><p class="oc-muted">${server.visi
${banned.length ? `<details class="oc-blocked"><summary>Banned (${banned.length})</summary><ul class="oc-members">${banned.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span><form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><input type="hidden" name="role" value="none"><button type="submit" class="oc-link">Lift the ban</button></form></li>`).join('')}</ul></details>` : ''}
<form class="oc-new" method="post" action="/s/${esc(server.slug)}/members"><label for="oc-ban">Ban someone</label> <input id="oc-ban" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="blocked"> <button type="submit">Ban</button></form>
${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/delete"><label class="oc-check"><input type="checkbox" name="confirm" value="1" required> Delete ${esc(server.name)} and all its channels</label><p><button type="submit" class="oc-danger">Delete server</button></p></form>` : ''}`,
++ mainLabel: 'Server settings',
++ page: true,
++ }),
});
});
@@@ -447,7 +606,7 @@ ${errorOf(req)}${action}</section>`,
if (!found) return res.redirect(303, req.back);
await servers.join(found.server, actor.user, { invite: found.invite.code });
await serverRoutes().applyServerChange(found.server);
-- res.redirect(303, `/s/${found.server.slug}`);
++ res.redirect(303, servers.hasOnboarding(found.server) ? `/s/${found.server.slug}/welcome` : `/s/${found.server.slug}`);
}));
// A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex a24fdd4..8dcce68 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -38,8 +38,8 @@ const APP_BAR = [
const iconBtn = (href, label, svg, extra = '') => `<a class="oc-icon-btn" href="${esc(href)}" aria-label="${esc(label)}" title="${esc(label)}"${extra}>${svg}</a>`;
/**
-- * The rail: Home and Vibe, your servers, then Add a server and Explore.
-- * o: { servers: [{ slug, name, unread, active }], dmUnread, home, vibe, mine }
++ * The rail: Home, Global chat and Vibe, your servers, then Add a server and Explore.
++ * o: { servers: [{ slug, name, unread, active }], dmUnread, home, global, vibe, explore }
*/
function rail(o = {}) {
const servers = o.servers || [];
@@@ -47,6 +47,7 @@ function rail(o = {}) {
return `<nav class="oc-rail" aria-label="Chat destinations">
<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-label="Open the side column" title="Open the side column">${IC.menu}</button>
<a class="oc-rail-icon oc-rail-home${o.home ? ' is-active' : ''}" href="/messages" title="Home"${o.home ? ' aria-current="page"' : ''}>${IC.chat}<span class="oc-sr">Home</span>${o.dmUnread ? `<span class="oc-rail-dot" aria-label="${o.dmUnread} unread">${o.dmUnread > 99 ? '99+' : o.dmUnread}</span>` : ''}</a>
++<a class="oc-rail-icon oc-rail-global${o.global ? ' is-active' : ''}" href="/" title="Global chat"${o.global ? ' aria-current="page"' : ''}>${IC.globe}<span class="oc-sr">Global chat</span></a>
<a class="oc-rail-icon oc-rail-vibe${o.vibe ? ' is-active' : ''}" href="/vibe" title="Vibe"${o.vibe ? ' aria-current="page"' : ''}>${IC.spark}<span class="oc-sr">Vibe</span></a>
<span class="oc-rail-divider" role="separator" aria-hidden="true"></span>
${servers.map(server).join('')}
@@@ -138,6 +139,51 @@ ${categories.map(cat).join('')}
${youBar(o.you || {})}`;
}
++/**
++ * The side column for the rooms: start one, Global chat, then the rooms you are in and the public ones.
++ * o: { mine: [{ slug, name, unread, private, current }], public: [...], you }
++ */
++function sideRooms(o = {}) {
++ const mine = o.mine || [];
++ const others = (o.public || []).filter((r) => !mine.some((m) => m.slug === r.slug));
++ const row = (r) => `<li><a class="oc-chan${r.current ? ' is-active' : ''}${r.unread ? ' is-unread' : ''}" href="/r/${esc(r.slug)}"${r.current ? ' aria-current="page"' : ''}><span class="oc-chan-hash" aria-hidden="true">#</span><span class="oc-chan-name">${esc(r.name)}</span>${r.private ? `<span class="oc-muted" aria-hidden="true">${IC.lock}</span>` : ''}${r.unread ? `<span class="oc-chan-dot" aria-label="${r.unread} unread">${r.unread > 99 ? '99+' : r.unread}</span>` : ''}</a></li>`;
++ const section = (id, title, rooms, empty) => `<section class="oc-side-sec" aria-labelledby="${id}"><h2 class="oc-side-group" id="${id}">${title}</h2>
++${rooms.length ? `<ul class="oc-side-list">${rooms.map(row).join('')}</ul>` : `<p class="oc-side-empty oc-muted">${empty}</p>`}</section>`;
++ return `<div class="oc-side-top">
++<a class="oc-side-find" href="/rooms#oc-new-room">${IC.plus}<span>Start a room</span></a>
++<a class="oc-side-find" href="/">${IC.globe}<span>Global chat</span></a>
++</div>
++${section('oc-side-your-rooms', 'Your rooms', mine, 'You are in no rooms yet.')}
++${section('oc-side-public-rooms', mine.length ? 'More public rooms' : 'Public rooms', others, 'No public room is open right now.')}
++${youBar(o.you || {})}`;
++}
++
++/**
++ * The side column for your settings: the sections of the page, then the you-bar.
++ * o: { items: [{ href, label, active }], you }
++ */
++function sideSettings(o = {}) {
++ const items = o.items || [];
++ return `<div class="oc-side-top"><a class="oc-side-find" href="/messages">${IC.back}<span>Back to chat</span></a></div>
++<section class="oc-side-sec" aria-labelledby="oc-side-set"><h2 class="oc-side-group" id="oc-side-set">Settings</h2>
++<ul class="oc-side-list">${items.map((i) => `<li><a class="oc-chan${i.active ? ' is-active' : ''}" href="${esc(i.href)}"${i.active ? ' aria-current="page"' : ''}><span class="oc-chan-name">${esc(i.label)}</span></a></li>`).join('')}</ul>
++</section>
++${youBar(o.you || {})}`;
++}
++
++/**
++ * The side column for the inbox: make one, then the inboxes you answer.
++ * o: { inboxes: [{ id, name, color, waiting, open, role, current }], you }
++ */
++function sideInbox(o = {}) {
++ const inboxes = o.inboxes || [];
++ const row = (i) => `<li class="oc-row${i.waiting ? ' oc-unread' : ''}"><a href="/inbox/${esc(i.id)}"${i.current ? ' aria-current="page"' : ''}><span class="oc-dot" style="background:${esc(i.color || 'var(--oc-accent)')}" aria-hidden="true"></span><span class="oc-row-main"><span class="oc-row-top"><strong>${esc(i.name)}</strong></span><span class="oc-row-sub">${i.waiting ? `${i.waiting} waiting · ` : ''}${i.open} open · ${i.role === 'owner' ? 'owner' : 'agent'}</span></span></a></li>`;
++ return `<div class="oc-side-top"><a class="oc-side-find" href="/inbox#oc-new-inbox">${IC.plus}<span>Make an inbox</span></a></div>
++<section class="oc-side-sec" aria-labelledby="oc-side-inboxes"><h2 class="oc-side-group" id="oc-side-inboxes">Your inboxes</h2>
++${inboxes.length ? `<ul class="oc-side-list">${inboxes.map(row).join('')}</ul>` : '<p class="oc-side-empty oc-muted">No inboxes yet.</p>'}</section>
++${youBar(o.you || {})}`;
++}
++
/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */
function youBar(o = {}) {
if (!o.name) return '';
@@@ -153,7 +199,7 @@ function membersColumn(o = {}) {
const groups = o.groups || [];
return `<div class="oc-members-head"><h2 class="oc-side-group">People</h2><button type="button" class="oc-icon-btn oc-members-close" data-oc-panel="main" aria-label="Hide the people" title="Hide the people">${IC.back}</button></div>
${groups.map((g) => `<section class="oc-members-sec"><h3 class="oc-side-group">${esc(g.title)} <span class="oc-muted">${g.people.length}</span></h3>
--<ul class="oc-side-list">${g.people.map((p) => `<li class="oc-row oc-member-row${p.presence ? ' is-online' : ''}"><a href="${p.href || '#'}"${p.id ? ` data-oc-user="${Number(p.id)}"` : ''}>${ui.initials(p.display_name || p.username || '?', p.presence, { avatarUrl: p.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong${p.color ? ` style="--nc:${esc(p.color)}"` : ''}>${esc(p.display_name || p.username || '?')}</strong></span><span class="oc-row-sub">${esc(p.username ? `@${p.username}` : '')}</span></span></a></li>`).join('')}</ul></section>`).join('')}`;
++<ul class="oc-side-list">${g.people.map((p) => `<li class="oc-row oc-member-row${p.presence ? ' is-online' : ''}"><a href="${p.href || '#'}"${p.id ? ` data-oc-user="${Number(p.id)}"` : ''}>${ui.initials(p.display_name || p.username || '?', p.presence, { avatarUrl: p.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong${p.color ? ` style="--nc:${esc(p.color)}"` : ''}>${esc(p.display_name || p.username || '?')}</strong></span><span class="oc-row-sub">${esc([p.username ? `@${p.username}` : '', ...(p.roles || [])].filter(Boolean).join(' · '))}</span></span></a></li>`).join('')}</ul></section>`).join('')}`;
}
/**
@@@ -185,4 +231,4 @@ ${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersL
</div>`;
}
--module.exports = { APP_BAR, rail, sideHome, sideServer, membersColumn, channelHeader, youBar, shell, personRow };
++module.exports = { APP_BAR, rail, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };
ddiff --git a/test/featured.test.js b/test/featured.test.js
nnew file mode 100644
iindex 0000000..7c92635
---- /dev/null
++++ b/test/featured.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * Explore's featured row (the plan, 2026-11-09, stage V3): staff mark a public server featured in the server's
++ * settings, and the row sits above the categories. With none featured the row is left out; a private server never
++ * appears in it.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('explore featured');
++let h, ann, staff, servers;
++const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });
++const get = async (path, u) => {
++ const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {} });
++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
++};
++const post = async (path, u, fields) => {
++ const r = await fetch(`${h.base}${path}`, {
++ method: 'POST', redirect: 'manual',
++ headers: { cookie: `ov_token=${u.token}`, 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams(fields).toString(),
++ });
++ return { status: r.status, location: r.headers.get('location') };
++};
++
++t('boot', async () => {
++ h = await boot();
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ staff = h.addUser('staffer', { subject: ids.newId('user'), role: 'global_mod' });
++ for (const u of [ann, staff]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ servers = require('../server/rooms/servers');
++});
++
++t('staff feature a server: the row sits above the categories; nobody else can', async () => {
++ const made = await api('POST', '/', ann, { name: 'Saturday Raiders' });
++ const slug = made.body.server.slug;
++
++ const before = await get('/servers', ann);
++ assert.doesNotMatch(before.text, /id="oc-featured-h"/, 'no row when nothing is featured');
++ assert.doesNotMatch(before.text, /No other public server yet\./, 'and the old empty line is gone');
++
++ const refused = await post(`/s/${slug}/featured`, ann, { featured: '1' });
++ assert.match(refused.location, /^\/s\/saturday-raiders\/settings\?error=/, 'the owner is not staff');
++ assert.strictEqual((await servers.bySlug(slug)).featured_at, null);
++
++ const done = await post(`/s/${slug}/featured`, staff, { featured: '1' });
++ assert.strictEqual(done.status, 303);
++ assert.match(done.location, /#oc-featured$/);
++ assert.ok((await servers.bySlug(slug)).featured_at, 'marked featured');
++
++ const shown = await get('/servers', ann);
++ const row = shown.text.indexOf('id="oc-featured-h"');
++ const chips = shown.text.indexOf('class="oc-chips"');
++ assert.ok(row > 0, 'the row is there');
++ assert.ok(chips > 0 && row < chips, 'above the categories');
++ assert.match(shown.text.slice(row, chips), new RegExp(`href="/s/${slug}"`), 'the featured server is its card');
++
++ assert.deepStrictEqual((await servers.featured()).map((s) => s.slug), [slug]);
++
++ const settings = await get(`/s/${slug}/settings`, staff);
++ assert.match(settings.text, /id="oc-featured"/, 'staff see the toggle');
++ assert.match(settings.text, /name="featured" value="0"/, 'and can take it down');
++
++ // A private server is never featured on Explore, even when staff mark it.
++ const priv = await api('POST', '/', ann, { name: 'Secret Club', visibility: 'private' });
++ await servers.setFeatured(await servers.bySlug(priv.body.server.slug), h.live.users.get(staff.id), true);
++ assert.ok(!(await servers.featured()).some((s) => s.slug === priv.body.server.slug), 'private stays out');
++
++ await post(`/s/${slug}/featured`, staff, { featured: '0' });
++ assert.doesNotMatch((await get('/servers', ann)).text, /id="oc-featured-h"/, 'and the row goes');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/onboarding.test.js b/test/onboarding.test.js
nnew file mode 100644
iindex 0000000..f510713
---- /dev/null
++++ b/test/onboarding.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * Onboarding on joining a server (the plan, 2026-11-09, stage V3): the server's rules, if it has any, then the roles
++ * a member may pick. Owners edit both in the server's settings (server/rooms/servers.js, server/web/server-pages.js).
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('onboarding');
++let h, ann, bob, servers;
++const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });
++const get = async (path, u) => {
++ const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {} });
++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
++};
++const post = async (path, u, fields) => {
++ const r = await fetch(`${h.base}${path}`, {
++ method: 'POST', redirect: 'manual',
++ headers: { cookie: `ov_token=${u.token}`, 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams(fields).toString(),
++ });
++ return { status: r.status, location: r.headers.get('location') };
++};
++
++t('boot', async () => {
++ h = await boot();
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ servers = require('../server/rooms/servers');
++});
++
++t('a server with rules and roles shows them on joining: the rules first, then the roles', async () => {
++ const made = await api('POST', '/', ann, { name: 'Book Club' });
++ const slug = made.body.server.slug;
++ let server = await servers.bySlug(slug);
++ await servers.update(server, h.live.users.get(ann.id), { rules: 'Be kind.\nNo spoilers.', self_roles: [{ name: 'Reader', color: '#ff2255' }] });
++ server = await servers.bySlug(slug);
++
++ const joined = await post(`/s/${slug}/join`, bob, {});
++ assert.deepStrictEqual([joined.status, joined.location], [303, `/s/${slug}/welcome`], 'a new member goes to onboarding');
++
++ const welcome = await get(`/s/${slug}/welcome`, bob);
++ assert.strictEqual(welcome.status, 200);
++ assert.match(welcome.text, /class="oc-app"/, 'inside the app shell');
++ assert.match(welcome.text, /<li>Be kind\.<\/li>\s*<li>No spoilers\./, 'the rules, one a line, in order');
++ assert.match(welcome.text, /name="roles" value="Reader"/, 'a role to pick');
++ assert.ok(welcome.text.indexOf('Be kind.') < welcome.text.indexOf('Pick your roles'), 'the rules come first');
++
++ const done = await post(`/s/${slug}/welcome`, bob, { roles: 'Reader' });
++ assert.deepStrictEqual([done.status, done.location], [303, `/s/${slug}`]);
++ assert.deepStrictEqual(await servers.memberRoles(server, bob.id), ['Reader'], 'the pick is saved');
++
++ const channel = await get(`/s/${slug}`, ann);
++ assert.match(channel.text, /@bob · Reader/, 'the people column shows the role they picked');
++});
++
++t('owners edit the rules and the roles; a member cannot', async () => {
++ const server = await servers.bySlug('book-club');
++ const saved = await post('/s/book-club/onboarding', ann, { rules: 'New rules' });
++ assert.strictEqual(saved.status, 303);
++ assert.match(saved.location, /^\/s\/book-club\/settings\?saved=1#oc-onboarding$/);
++ assert.strictEqual((await servers.bySlug('book-club')).rules, 'New rules');
++
++ const added = await post('/s/book-club/roles', ann, { name: 'Host', color: '#00ff00' });
++ assert.strictEqual(added.status, 303);
++ assert.deepStrictEqual(servers.selfRolesOf(await servers.bySlug('book-club')).map((r) => r.name), ['Reader', 'Host']);
++
++ const settings = await get('/s/book-club/settings', ann);
++ assert.match(settings.text, /id="oc-onboarding"/, 'the section is in the server settings');
++ assert.match(settings.text, /name="rules"/, 'the rules are editable');
++ assert.match(settings.text, /value="Reader"/, 'the roles are listed');
++
++ const removed = await post('/s/book-club/roles/delete', ann, { name: 'Host' });
++ assert.strictEqual(removed.status, 303);
++ assert.deepStrictEqual(servers.selfRolesOf(await servers.bySlug('book-club')).map((r) => r.name), ['Reader']);
++
++ // A permission word is not a role to pick.
++ const bad = await post('/s/book-club/roles', ann, { name: 'admin' });
++ assert.match(bad.location, /^\/s\/book-club\/settings\?error=/, 'refused');
++ assert.deepStrictEqual(servers.selfRolesOf(await servers.bySlug('book-club')).map((r) => r.name), ['Reader']);
++
++ // Bob is a member, not an owner or an admin.
++ const refused = await post('/s/book-club/onboarding', bob, { rules: 'Hacked' });
++ assert.match(refused.location, /^\/s\/book-club\/settings\?error=/);
++ assert.strictEqual((await servers.bySlug('book-club')).rules, 'New rules');
++
++ assert.ok(server, 'the server is there');
++});
++
++t('a server with no rules and no roles joins straight into its channels', async () => {
++ const made = await api('POST', '/', ann, { name: 'Plain Server' });
++ const slug = made.body.server.slug;
++ const joined = await post(`/s/${slug}/join`, bob, {});
++ assert.deepStrictEqual([joined.status, joined.location], [303, `/s/${slug}`], 'no onboarding page');
++ assert.strictEqual((await get(`/s/${slug}/welcome`, bob)).status, 200, 'and the page is harmless if visited');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex faa4e5d..2f844f5 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -131,4 +131,64 @@ t('a server starts from a template and ends with its invite link', async () => {
assert.match(shown.text, /\/join\/[a-z0-9]+/);
});
++t('the rest of chat is inside the shell too: Global chat, Rooms, a room, settings and the inbox', async () => {
++ // A server, so the rail has one, and a standalone room with a message.
++ const made = await api('POST', '/', dee, { name: 'Shell Pages', description: 'Pages in the shell' });
++ const slug = made.body.server.slug;
++ const roomRes = await fetch(`${h.base}/rooms/new`, { method: 'POST', headers: { cookie: `ov_token=${dee.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name: 'Shell Room' }).toString(), redirect: 'manual' });
++ assert.strictEqual(roomRes.status, 303);
++ const roomPath = roomRes.headers.get('location');
++ assert.match(roomPath, /^\/r\/shell-room$/);
++
++ // Global chat: the rail's Global entry is current, Home's side column, the feed and its composer.
++ let r = await page('/', dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on Global chat');
++ assert.match(r.text, /class="oc-rail-icon oc-rail-global is-active" href="\/"/, 'Global chat on the rail');
++ assert.match(r.text, /class="oc-side"/, 'Home down the side');
++ assert.match(r.text, /id="oc-feed"/, 'the global feed');
++ assert.match(r.text, /id="oc-compose"/, 'and the composer');
++
++ // Rooms: the side column lists them.
++ r = await page('/rooms', dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on Rooms');
++ assert.match(r.text, /id="oc-side-your-rooms"/, 'your rooms down the side');
++ assert.match(r.text, /href="\/r\/shell-room"/, 'the room is a row');
++ assert.match(r.text, /id="oc-new-room"/, 'the Start form is still the page');
++
++ // A room: the same side column and the room's feed in the middle.
++ r = await page(roomPath, dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell in a room');
++ assert.match(r.text, /class="oc-chan is-active" href="\/r\/shell-room" aria-current="page"/, 'the room is marked in the side column');
++ assert.match(r.text, /id="oc-feed"/, 'the room feed');
++
++ // Settings: the shell's page gutter and its own sections down the side.
++ r = await page('/settings', dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on settings');
++ assert.match(r.text, /class="oc-page"/, 'the settings page scrolls in the gutter');
++ assert.match(r.text, /id="oc-side-set"/, 'the settings sections down the side');
++
++ // A room's settings and a server's settings.
++ r = await page('/r/shell-room/settings', dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on a room\'s settings');
++ assert.match(r.text, /class="oc-page"/);
++ r = await page(`/s/${slug}/settings`, dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on a server\'s settings');
++ assert.match(r.text, /class="oc-page"/);
++ assert.match(r.text, /id="oc-invites"/, 'the settings are still the page');
++
++ // The inbox: the list is a page, the conversation like a DM.
++ const madeInbox = await fetch(`${h.base}/inbox`, { method: 'POST', headers: { cookie: `ov_token=${dee.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name: 'Shell Support' }).toString(), redirect: 'manual' });
++ assert.strictEqual(madeInbox.status, 303);
++ const inboxId = (madeInbox.headers.get('location') || '').split('/')[2];
++ assert.ok(inboxId, 'an inbox was made');
++ r = await page('/inbox', dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on the inbox list');
++ assert.match(r.text, /class="oc-page"/);
++ assert.match(r.text, /id="oc-side-inboxes"/, 'your inboxes down the side');
++ r = await page(`/inbox/${inboxId}`, dee);
++ assert.match(r.text, /class="oc-app"/, 'the shell on a conversation');
++ assert.match(r.text, /id="ib-rail"/, 'the conversations are the side column');
++ assert.match(r.text, /data-ovt-thread/, 'the kit\'s thread is the middle');
++});
++
t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex c380f56..23579a0 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -180,11 +180,12 @@ t('the room page shows the queue beside the conversation; the task page has the
const cookie = `ov_token=${staff.token}`;
let r = await h.http('GET', '/r/staff', { headers: { cookie } });
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<div class="oc-ws"><div class="oc-ws-main"><ol class="oc-feed oc-room-feed"/);
++ // A room is a channel in the app shell since Chat V2 (#441): its feed is the server channel's (oc-srv-feed).
++ assert.match(r.text, /<div class="oc-ws"><div class="oc-ws-main"><ol class="oc-feed oc-srv-feed"/);
// The room's header: its controls are icons with names; an AI member's Markdown is rendered, escaped first.
assert.match(r.text, /<a class="oc-icon-btn" href="\/r\/staff\/settings" aria-label="Room settings"/);
assert.match(r.text, /<div class="oc-text vb-md"><p>✅ Done: &quot;/);
-- assert.match(r.text, /data-variant="compact"/, 'a room is a page people work in: the one-line footer');
++ assert.doesNotMatch(r.text, /data-variant="(compact|full)"/, 'a room fills the app shell: no footer under the conversation (Chat V2)');
assert.match(r.text, /<aside class="oc-queue" id="oc-queue"[^>]*data-queue="\/r\/staff\/queue"/);
assert.match(r.text, /Add a dark favicon to Tools/);
r = await h.http('GET', '/r/staff/queue', { headers: { cookie } });