openvibers/openvibe.chat Commits
Chat V2, part 2: every page in the app shell, server onboarding, Explore's featured row (#441)
Changes
nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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;@@@ -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; }@@@ -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}`],@@@ -46,10 +46,28 @@ const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'inviconst 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 mLEFT 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,};@@@ -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 })) },});});@@@ -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>` : ''}@@@ -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="postconst 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}`),});});@@@ -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).@@@ -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 };nnew file mode 100644@@@ -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(); });nnew file mode 100644@@@ -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(); });@@@ -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(); });@@@ -180,11 +180,12 @@ t('the room page shows the queue beside the conversation; the task page has theconst 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: "/);-- 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 } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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;
@@ -33,6 +33,7 @@
@@ -33,6 +33,7 @@
.oc-side-top { padding: 0 0 10px; }
.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 { 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-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-find .oc-i { width: 18px; height: 18px; color: var(--oc-muted); }
.oc-side-sec { margin-bottom: 12px; }
.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); }
.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 @@
@@ -156,6 +157,10 @@
.oc-card > .oc-button { text-align: center; }
.oc-card > .oc-button { text-align: center; }
.oc-server-card--new { justify-content: center; border-style: dashed; }
.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. */
/* 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 { 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; }
.oc-sheet > summary { display: block; padding: 14px 18px; cursor: pointer; font-weight: 700; list-style: none; }
@@ -202,3 +207,50 @@
@@ -202,3 +207,50 @@
/* Absolutely placed inputs (the template radios) stay inside the page that scrolls, not the document. */
/* Absolutely placed inputs (the template radios) stay inside the page that scrolls, not the document. */
.oc-page, .oc-template { position: relative; }
.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; }
@@ -50,7 +50,7 @@ const EXPORTS = [
@@ -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}`],
['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}`],
['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],
['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
- ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.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}`],
['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
@@ -46,10 +46,28 @@ const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invi
@@ -46,10 +46,28 @@ const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invi
const MAX_SERVERS_PER_OWNER = 5;
const MAX_SERVERS_PER_OWNER = 5;
const MAX_CHANNELS = 50;
const MAX_CHANNELS = 50;
const MAX_INVITES = 25;
const MAX_INVITES = 25;
+const MAX_RULES = 4000;
+const MAX_SELF_ROLES = 10;
const INVITE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
const INVITE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
const isStaff = (user) => rooms.isStaff(user);
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) {
function slugify(name) {
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
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 = {}) {
@@ -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,
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,
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,
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) {
@@ -307,21 +327,74 @@ async function setRole(server, actor, targetUserId, role) {
return role === 'none' ? null : 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 } = {}) {
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`;
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'"}
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))]);
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');
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 (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 (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; }
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).
// The channels follow the server: going private redacts what public channels had published (rooms.update).
if (next.visibility !== server.visibility) {
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])) {
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 = {
@@ -423,5 +496,7 @@ module.exports = {
bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
createInvite, invites, revokeInvite, inviteByCode, archive,
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,
};
};
@@ -24,6 +24,7 @@ const { embedOf, sendExport } = require('../inbox/routes');
@@ -24,6 +24,7 @@ const { embedOf, sendExport } = require('../inbox/routes');
const records = require('../inbox/records');
const records = require('../inbox/records');
const { esc, asset } = require('./layout');
const { esc, asset } = require('./layout');
const ui = require('./ui');
const ui = require('./ui');
+const shell = require('./shell');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const thread = require('openvibe-shared/thread');
// A visitor's initial, in a circle, beside their row.
// A visitor's initial, in a circle, beside their row.
@@ -100,19 +101,28 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
@@ -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>
? `<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>`
<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>';
: '<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, {
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>
<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)) : ''}
${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
${list}
${list}
-<h2>Make an inbox</h2>
+<h2 id="oc-new-inbox">Make an inbox</h2>
<form class="oc-form" method="post" action="/inbox">
<form class="oc-form" method="post" action="/inbox">
<label>Name <input name="name" required minlength="2" maxlength="60" placeholder="Acme support"></label>
<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>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>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>
<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>`,
<p><button type="submit">Make the inbox</button></p></form>`,
+ mainLabel: 'Inbox',
+ page: true,
+ }),
});
});
});
});
@@ -143,16 +153,26 @@ ${list}
@@ -143,16 +153,26 @@ ${list}
const msgs = current ? await inboxes.messages(current) : [];
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, current);
if (current) await inboxes.markRead(inbox, current);
const me = Number(actor.user.id);
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))] : [],
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>`,
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 }) : '',
composer: current ? replyBox({ inbox, current, actor, saved }) : '',
label: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
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>`;
})}</div>`;
await page(req, res, 200, {
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 })) },
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 })) },
});
});
});
});
@@ -165,7 +165,7 @@ ${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
@@ -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.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(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.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.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.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>` : ''}
${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>` : ''}
@@ -294,27 +294,44 @@ function createWebRoutes({ config }) {
@@ -294,27 +294,44 @@ function createWebRoutes({ config }) {
].filter((pane) => pane.html);
].filter((pane) => pane.html);
const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
const inlineStyles = [];
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}
${error}
${feedList}
${feedList}
-${composer}
-</section>
-${parts.vibe}
-${parts.streams}
-${parts.servers}
-${parts.rooms}`;
+${composer}`,
+ mainLabel: 'Global chat',
+ });
+ })() : '';
await page(req, res, 200, {
await page(req, res, 200, {
actor, fullTitle: 'OpenVibe.Chat: global chat, servers, rooms and calls', path: '/', active: 'global', robots: 'index, follow', script: true,
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.',
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.
// 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)],
jsonLd: [...homeJsonLd(config), sharedSeo.jsonLd.faq(home.FAQ)],
page: { view: 'global', latest: latest_id, signedIn, me: signedIn ? actor.user.username : null },
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>
<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-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>
<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}`,
@@ -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 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 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 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, {
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">
<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>
<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)}
${box('timestamps', 'Times next to messages', p.timestamps === true)}
@@ -651,6 +682,9 @@ ${volume('tts_sound_volume', 'Sound volume', tts.sound_volume)}
@@ -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>
<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>
</section>
${await aiKeySection(actor, req.query || {})}`,
${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
@@ -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>
<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>
<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>`;
</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>` : ''}
${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">
<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>
<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
@@ -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="/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>`}
<li><a href="/messages">Messages</a>: direct messages with anyone on OpenVibe</li></ul></div>`}
${create}
${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}
@@ -946,13 +995,24 @@ ${people}
const topic = typeof room.topic === 'string' ? room.topic.trim() : '';
const topic = typeof room.topic === 'string' ? room.topic.trim() : '';
const description = topic.length >= 40 ? topic
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.`;
: `${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, {
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,
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,
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,
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 } } : {}) },
...(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>
<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>
<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>
<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}
@@ -964,8 +1024,10 @@ ${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${isCall ? callPanel(room, can, me) : ''}
${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
${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 && 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>` : ''}`,
${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
@@ -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.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 && !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>` : '';
${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) {
if (room.server_id) {
return await page(req, res, 200, {
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) : [];
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
@@ -1113,8 +1187,8 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
const communityUrl = config.web.communityUrl;
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>` : '';
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, {
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">
${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>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>
<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
@@ -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>
<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>` : ''}
<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}
${aiSection}
-${attachList}`,
+${attachList}`),
});
});
});
});
@@ -55,6 +55,7 @@ function mountServerPages(router, h) {
@@ -55,6 +55,7 @@ function mountServerPages(router, h) {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const me = userOf(actor);
const me = userOf(actor);
const { public: pub, mine } = await servers.list(me);
const { public: pub, mine } = await servers.list(me);
+ const featuredServers = await servers.featured();
const templates = require('../rooms/server-templates');
const templates = require('../rooms/server-templates');
const q = String(req.query.q || '').trim().toLowerCase();
const q = String(req.query.q || '').trim().toLowerCase();
const cat = String(req.query.category || '');
const cat = String(req.query.category || '');
@@ -95,6 +96,8 @@ function mountServerPages(router, h) {
@@ -95,6 +96,8 @@ function mountServerPages(router, h) {
${join}</article>`;
${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>`;
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.
// 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>
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">
<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"
@@ -128,6 +131,7 @@ ${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"
side, sideLabel: 'Your servers',
side, sideLabel: 'Your servers',
main: `${ui.head({ title: 'Explore servers', sub: `${listed.length} server${listed.length === 1 ? '' : 's'}` })}
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>
<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}
${chips}
<div class="oc-cards">${listed.map(card).join('')}${startCard}</div>
<div class="oc-cards">${listed.map(card).join('')}${startCard}</div>
${sheet}`,
${sheet}`,
@@ -223,6 +227,7 @@ ${sheet}`,
@@ -223,6 +227,7 @@ ${sheet}`,
id: p.user_id, username: p.username || '', display_name: p.display_name || p.username || '',
id: p.user_id, username: p.username || '', display_name: p.display_name || p.username || '',
avatar_url: p.avatar_url || null, presence: p.presence || null,
avatar_url: p.avatar_url || null, presence: p.presence || null,
color: /^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? p.profile_color : '',
color: /^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? p.profile_color : '',
+ roles: p.roles || [],
href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
})),
})),
}));
}));
@@ -289,7 +294,8 @@ ${composer}`,
@@ -289,7 +294,8 @@ ${composer}`,
req.back = `/s/${server.slug}`;
req.back = `/s/${server.slug}`;
await servers.join(server, actor.user);
await servers.join(server, actor.user);
await serverRoutes().applyServerChange(server);
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) => {
router.post('/s/:slug/leave', formLimit, sameSite, orError(async (req, res) => {
@@ -302,6 +308,118 @@ ${composer}`,
@@ -302,6 +308,118 @@ ${composer}`,
res.redirect(303, '/servers');
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
// ── 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) ──
// public server; members: owner/admins/mods; delete: the owner) ──
router.get('/s/:slug/settings', async (req, res) => {
router.get('/s/:slug/settings', async (req, res) => {
@@ -313,7 +431,23 @@ ${composer}`,
@@ -313,7 +431,23 @@ ${composer}`,
const people = await servers.members(server, { blocked: true, limit: 2000 });
const people = await servers.members(server, { blocked: true, limit: 2000 });
const canInvite = can.manage || server.visibility === 'public';
const canInvite = can.manage || server.visibility === 'public';
const invites = canInvite ? await servers.invites(server) : [];
const invites = canInvite ? await servers.invites(server) : [];
+ const selfRoles = servers.selfRolesOf(server);
const myRank = can.staff ? -1 : servers.RANK[can.role];
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 choices = (target) => {
const list = [];
const list = [];
if (myRank <= servers.RANK.owner) list.push('admin');
if (myRank <= servers.RANK.owner) list.push('admin');
@@ -341,15 +475,37 @@ ${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ?
@@ -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>
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>`;
<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, {
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">
${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>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>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>
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
-<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></select> <button type="submit">Add</button></form>` : ''}
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></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>
${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>
<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>` : ''}
<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
@@ -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>` : ''}
${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>
<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>` : ''}`,
${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>`,
@@ -447,7 +606,7 @@ ${errorOf(req)}${action}</section>`,
if (!found) return res.redirect(303, req.back);
if (!found) return res.redirect(303, req.back);
await servers.join(found.server, actor.user, { invite: found.invite.code });
await servers.join(found.server, actor.user, { invite: found.invite.code });
await serverRoutes().applyServerChange(found.server);
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).
// A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
@@ -38,8 +38,8 @@ const APP_BAR = [
@@ -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>`;
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 = {}) {
function rail(o = {}) {
const servers = o.servers || [];
const servers = o.servers || [];
@@ -47,6 +47,7 @@ function rail(o = {}) {
@@ -47,6 +47,7 @@ function rail(o = {}) {
return `<nav class="oc-rail" aria-label="Chat destinations">
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>
<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-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>
<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>
<span class="oc-rail-divider" role="separator" aria-hidden="true"></span>
${servers.map(server).join('')}
${servers.map(server).join('')}
@@ -138,6 +139,51 @@ ${categories.map(cat).join('')}
@@ -138,6 +139,51 @@ ${categories.map(cat).join('')}
${youBar(o.you || {})}`;
${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). */
/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */
function youBar(o = {}) {
function youBar(o = {}) {
if (!o.name) return '';
if (!o.name) return '';
@@ -153,7 +199,7 @@ function membersColumn(o = {}) {
@@ -153,7 +199,7 @@ function membersColumn(o = {}) {
const groups = o.groups || [];
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>
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>
${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
@@ -185,4 +231,4 @@ ${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersL
</div>`;
</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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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(); });
@@ -131,4 +131,64 @@ t('a server starts from a template and ends with its invite link', async () => {
@@ -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]+/);
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(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -180,11 +180,12 @@ t('the room page shows the queue beside the conversation; the task page has the
@@ -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}`;
const cookie = `ov_token=${staff.token}`;
let r = await h.http('GET', '/r/staff', { headers: { cookie } });
let r = await h.http('GET', '/r/staff', { headers: { cookie } });
assert.strictEqual(r.status, 200);
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.
// 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, /<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: "/);
assert.match(r.text, /<div class="oc-text vb-md"><p>✅ Done: "/);
- 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, /<aside class="oc-queue" id="oc-queue"[^>]*data-queue="\/r\/staff\/queue"/);
assert.match(r.text, /Add a dark favicon to Tools/);
assert.match(r.text, /Add a dark favicon to Tools/);
r = await h.http('GET', '/r/staff/queue', { headers: { cookie } });
r = await h.http('GET', '/r/staff/queue', { headers: { cookie } });