openvibers/openvibe.chat Commits

Settings, room settings, the front page and Servers: compact, icon actions, the app when signed in (#224)

OP OpenVibers committed 17 days ago · 160f122be7 · 6 files, +218 −96

* Settings and room settings: sections of switch rows, ranges and a size toggle with one save; members as rows with an add box and a role that saves from its own check; AI members, hooks and the embed folded; the AI key folded

* Front page as the app when signed in: global chat first at the screen's height with a one-line composer, Ask Vibe in one line, compact lists, the compact footer; the hero, questions and full footer stay for new visitors. Servers like Rooms: one header with Start a server, rows with each server's own mark, yours listed once

* The front page's JSON-LD stays the same for every reader (the FAQ too, signed in or not)

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 7313396..49ab49c 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -195,11 +195,6 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-srv-people-list a { display: block; padding: 3px 8px; border-radius: 6px; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oc-srv-people-list a:hover { background: var(--oc-panel-2); }
/* The directory, an invite, settings */
--.oc-srv-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 10px; }
--.oc-srv-card a { display: flex; align-items: center; gap: 12px; height: 100%; padding: 12px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; color: var(--oc-text); text-decoration: none; }
--.oc-srv-card a:hover, .oc-srv-card a:focus-visible { border-color: var(--oc-accent); }
--.oc-srv-card-text { display: flex; flex-direction: column; min-width: 0; }
--.oc-srv-card-text .oc-muted { font-size: .88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oc-srv-icon--big { width: 72px; height: 72px; margin: 0 auto 6px; border-radius: 22px; font-size: 1.5rem; }
.oc-invite-card { max-width: 440px; margin: 32px auto; padding: 28px 24px; text-align: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; }
.oc-invite-card h1 { margin-top: 0; }
@@@ -232,8 +227,6 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }
}
--.oc-srv-card-title { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
--.oc-srv-card-title .oc-badge { margin-left: 0; }
.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }
.oc-badge-official { color: var(--oc-text); border-color: var(--accent-strong, var(--accent, #3b82f6)); }
.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }
@@@ -272,7 +265,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-lede { margin: 0; max-width: 64ch; font-size: 1.05rem; color: var(--oc-muted); }
.oc-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 1rem 0 0; }
.oc-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 1.2rem 0 0; padding: 0; }
--.oc-stats > div { display: flex; flex-direction: column-reverse; min-width: 0; padding: 8px 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
++.oc-stats > div { flex: 1 1 100px; display: flex; flex-direction: column-reverse; min-width: 0; padding: 8px 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
.oc-stats dt { margin: 0; font-size: .8rem; color: var(--oc-muted); }
.oc-stats dd { margin: 0; font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
.oc-room > h2 { margin: 1.4rem 0 .2rem; font-size: 1.3rem; }
@@@ -564,3 +557,70 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-pop-item.oc-danger { color: var(--oc-red); }
.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }
.oc-row-lock .oc-i { width: .9em; height: .9em; }
++
++/* Settings (GET /settings): grouped cards of rows, each a label on the left and its control on the right (a switch, a
++ slider, a choice), the save button kept in reach, and the rarely needed parts folded. */
++.oc-settings { display: grid; gap: 12px; margin: 0; max-width: none; }
++.oc-set { border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); overflow: hidden; margin: 0 0 12px; }
++.oc-settings .oc-set { margin: 0; }
++.oc-set > h2 { margin: 0; padding: 12px 14px 6px; font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }
++.oc-set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; padding: 8px 14px; border-top: 1px solid var(--oc-line); cursor: pointer; }
++.oc-set > h2 + .oc-set-row { border-top: 0; }
++div.oc-set-row { cursor: default; }
++.oc-set-text { flex: 1; min-width: 0; display: grid; gap: 2px; }
++.oc-set-text small { color: var(--oc-muted); font-size: .8rem; }
++.oc-set-row select { flex: none; max-width: 50%; margin: 0; }
++.oc-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; accent-color: var(--accent-strong, var(--oc-accent)); padding: 0; border: 0; background: transparent; }
++.oc-set-actions { display: flex; gap: 6px; flex: none; }
++.oc-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; padding: 0; border-radius: 999px; border: 0; background: var(--oc-line); cursor: pointer; transition: background .15s; }
++.oc-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .35); transition: transform .15s; }
++.oc-switch:checked { background: var(--accent-strong, var(--oc-accent)); }
++.oc-switch:checked::after { transform: translateX(18px); }
++.oc-switch:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++.oc-seg { display: inline-flex; flex: none; padding: 3px; gap: 2px; border-radius: 10px; background: var(--oc-panel-2); }
++.oc-seg-opt input { position: absolute; opacity: 0; pointer-events: none; }
++.oc-seg-opt span { display: block; padding: 5px 11px; border-radius: 8px; font-size: .86rem; font-weight: 600; color: var(--oc-muted); cursor: pointer; }
++.oc-seg-opt input:checked + span { background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); }
++.oc-seg-opt input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
++.oc-set-chips { flex-wrap: wrap; }
++.oc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.oc-chip input { position: absolute; opacity: 0; pointer-events: none; }
++.oc-chip span { display: inline-block; padding: 5px 11px; border-radius: 999px; border: 1px solid var(--oc-line); font-size: .84rem; font-weight: 600; color: var(--oc-muted); cursor: pointer; }
++.oc-chip input:checked + span { border-color: color-mix(in srgb, var(--oc-accent) 55%, transparent); background: color-mix(in srgb, var(--oc-accent) 14%, transparent); color: var(--oc-text); }
++.oc-chip input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
++.oc-set-save { position: sticky; bottom: 0; z-index: 2; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--oc-bg) 35%); }
++.oc-set-save button { width: 100%; min-height: 46px; border-radius: 12px; }
++.oc-aikey .oc-ai-form { max-width: none; }
++@media (min-width: 720px) { .oc-set-save button { width: auto; min-width: 200px; } }
++@media (prefers-reduced-motion: reduce) { .oc-switch, .oc-switch::after { transition: none; } }
++/* Room settings: the room's form, its members (a row each, the role a small select that saves), and folded extras. */
++.oc-set-form { padding: 4px 14px 14px; }
++.oc-set-note { margin: 0; padding: 0 14px 10px; font-size: .86rem; }
++.oc-set .oc-set-inline { padding: 8px 0; border-top: 0; }
++.oc-small { font-size: .86rem; }
++.oc-count { margin-left: 4px; color: var(--oc-text); font-weight: 700; letter-spacing: 0; }
++.oc-find-inset { margin: 0 14px 12px; }
++.oc-find select { flex: none; max-width: 38%; border: 0; background: var(--oc-panel-2); padding: 6px 8px; }
++.oc-list-flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--oc-line); }
++.oc-row-static { display: flex; align-items: center; gap: 12px; padding: 8px 8px 8px 14px; }
++.oc-role-form { display: flex; align-items: center; gap: 2px; margin: 0; flex: none; }
++.oc-role-form select { padding: 5px 6px; font-size: .86rem; }
++.oc-role { flex: none; padding-right: 8px; font-size: .86rem; }
++.oc-ai-member { padding: 10px 0; border-top: 1px solid var(--oc-line); }
++.oc-ai-member h3 { margin: 0 0 6px; font-size: 1rem; }
++.oc-grid-form label.oc-set-row { display: flex; color: var(--oc-text); font-size: 1rem; }
++.oc-ai-form h3 { margin: 6px 0 2px; font-size: 1rem; }
++
++/* The front page for someone signed in (pages.js appBody): global chat first at the screen's height, Ask Vibe in one
++ line under it, then the stream chats, servers and rooms as compact lists. A server's own mark keeps its colour in
++ a list row. */
++.oc-global { margin-top: 4px; }
++.oc-vibe-mini { margin-top: 16px; }
++.oc-vibe-mini .oc-find { margin: 0; border-color: color-mix(in srgb, var(--oc-accent) 35%, var(--oc-line)); }
++.oc-vibe-mini .oc-find > .oc-i { color: var(--oc-accent); }
++.oc-home-list { margin-top: 18px; }
++.oc-home-list .oc-home-head { align-items: center; margin: 0 0 8px; }
++.oc-home-list h2.oc-list-head { margin: 0; font-family: inherit; font-size: .78rem; letter-spacing: .06em; }
++.oc-home-list .oc-home-head a { font-size: .82rem; }
++.oc-home-list .oc-list { margin: 0; }
++.oc-srv-icon--row { width: 40px; height: 40px; border-radius: 12px; font-size: .85rem; }
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 8c1a1f5..c5c5e34 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -9,10 +9,14 @@
* channel of a banned account. A person is counted once per week, by account or by anonymous id.
*
* activity() → { weekMessages, weekPeople, totalMessages, streams: [...], rooms: [...], servers: [...] }
-- * sections({ activity, signedIn, live }) → the HTML pieces the page places around global chat
++ * sections(activity, { app }) → the HTML pieces the page places around global chat; app (someone signed in): the
++ * lists as compact rows and Ask Vibe as one line, no introduction
++ * peopleLine(activity) → "4 people this week" for the room's header, or ''
*/
const db = require('../db/database');
const { esc } = require('./layout');
++const ui = require('./ui');
++const { ICONS } = require('./icons');
const TTL_MS = 60_000;
const WEEK_MS = 7 * 24 * 3600 * 1000;
@@@ -103,7 +107,7 @@ const FAQ = [
function stats(a) {
const items = [
a.weekMessages ? [n(a.weekMessages), 'messages this week'] : null,
-- a.weekPeople ? [n(a.weekPeople), a.weekPeople === 1 ? 'person chatting this week' : 'people chatting this week'] : null,
++ a.weekPeople ? [n(a.weekPeople), 'chatting this week'] : null,
a.totalMessages ? [n(a.totalMessages), 'messages in all'] : null,
a.servers.length ? [n(a.servers.length), a.servers.length === 1 ? 'public server' : 'public servers'] : null,
a.streams.some((s) => s.live) ? [n(a.streams.filter((s) => s.live).length), 'streams live now'] : null,
@@@ -112,8 +116,16 @@ function stats(a) {
return `<dl class="oc-stats">${items.map(([v, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>`;
}
--function streamsSection(a) {
++/** One place in a compact list: its mark, the name with a badge, one muted line. */
++const row = (href, name, mark, badge, sub) => `<li class="oc-row"><a href="${href}">${mark}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(name)}</strong>${badge}</span><span class="oc-row-sub">${sub}</span></span></a></li>`;
++const listSection = (id, title, more, rows) => `<section class="oc-home-sec oc-home-list" aria-labelledby="${id}"><div class="oc-home-head"><h2 id="${id}" class="oc-list-head">${title}</h2>${more}</div><ul class="oc-list">${rows.join('')}</ul></section>`;
++
++function streamsSection(a, app) {
if (!a.streams.length) return '';
++ if (app) {
++ return listSection('oc-h-streams', 'Stream chats', `<a href="${LIVE}/">Who is live</a>`, a.streams.map((s) => row(`${LIVE}/@${esc(s.username)}`, s.display_name, ui.initials(s.display_name), s.live ? '<span class="oc-badge oc-badge-live">Live</span>' : '',
++ s.live && s.title ? esc(s.title) : `${s.messages ? `${plural(s.messages, 'message', 'messages')} this week` : ''}${s.last ? `${s.messages ? ' · ' : ''}last ${esc(ago(s.last))}` : ''}`)));
++ }
const item = (s) => `<li><a class="oc-stream" href="${LIVE}/@${esc(s.username)}"><span class="oc-stream-name"><strong>${esc(s.display_name)}</strong>${s.live ? ' <span class="oc-badge oc-badge-live">Live now</span>' : ''}</span>
<span class="oc-muted">${s.live && s.title ? `${esc(s.title)}${s.messages ? ' · ' : ''}` : ''}${s.messages ? `${plural(s.messages, 'message', 'messages')} from ${plural(s.people, 'person', 'people')} this week` : ''}${s.last && !s.live ? ` · last ${esc(ago(s.last))}` : ''}</span></a></li>`;
return `<section class="oc-home-sec" aria-labelledby="oc-h-streams"><div class="oc-home-head"><h2 id="oc-h-streams">Stream chats on OpenVibe.Live</h2><a href="${LIVE}/">Who is live</a></div>
@@@ -121,7 +133,13 @@ function streamsSection(a) {
<ul class="oc-streams" role="list">${a.streams.map(item).join('')}</ul></section>`;
}
--function placesSection(a) {
++function placesSection(a, app) {
++ if (app) {
++ return [
++ a.servers.length ? listSection('oc-h-servers', 'Public servers', '<a href="/servers">All</a>', a.servers.map((s) => row(`/s/${esc(s.slug)}`, s.name, ui.serverMark(s), s.official ? '<span class="oc-badge oc-badge-official">official</span>' : '', esc(s.description || plural(s.members, 'member', 'members'))))) : '',
++ a.rooms.length ? listSection('oc-h-rooms', 'Public rooms', '<a href="/rooms">All</a>', a.rooms.map((r) => row(`/r/${esc(r.slug)}`, r.name, ui.initials(r.name), '', `${esc(r.topic || plural(r.members, 'member', 'members'))}${r.last_message_at ? ` · last ${esc(ago(r.last_message_at))}` : ''}`))) : '',
++ ].join('\n');
++ }
const servers = a.servers.map((s) => `<li><a class="oc-stream" href="/s/${esc(s.slug)}"><span class="oc-stream-name"><strong>${esc(s.name)}</strong>${s.official ? ' <span class="oc-badge oc-badge-official">official</span>' : ''}</span><span class="oc-muted">${esc(s.description || plural(s.members, 'member', 'members'))}</span></a></li>`);
const rooms = a.rooms.map((r) => `<li><a class="oc-stream" href="/r/${esc(r.slug)}"><span class="oc-stream-name"><strong>${esc(r.name)}</strong></span><span class="oc-muted">${esc(r.topic || plural(r.members, 'member', 'members'))}${r.last_message_at ? ` · last ${esc(ago(r.last_message_at))}` : ''}</span></a></li>`);
return [
@@@ -131,7 +149,11 @@ function placesSection(a) {
}
/** Ask Vibe from the front page: the question opens a new Vibe chat with it in the box (never sent for you). */
--function vibeSection() {
++function vibeSection(app) {
++ if (app) {
++ return `<section class="oc-home-sec oc-vibe-mini" aria-labelledby="oc-h-vibe"><h2 id="oc-h-vibe" class="oc-sr">Ask Vibe</h2>
++<form class="oc-find oc-vibe-ask" action="/vibe" method="get" role="search">${ICONS.spark}<label for="oc-vibe-q" class="oc-sr">Ask Vibe</label><input id="oc-vibe-q" name="ask" type="text" maxlength="1200" placeholder="Ask Vibe anything" autocomplete="off" enterkeyhint="go" required><button type="submit" class="oc-send" aria-label="Ask Vibe" title="Ask Vibe">${ICONS.send}</button></form></section>`;
++ }
return `<section class="oc-home-sec oc-vibe-card" aria-labelledby="oc-h-vibe"><h2 id="oc-h-vibe">Ask Vibe, OpenVibe's assistant</h2>
<p class="oc-muted">Free with your account. It searches the web and all of OpenVibe, reads the pages and pictures you share, remembers what you ask it to, and sets up work that runs every day for you. Your chats with it are private.</p>
<form class="oc-vibe-ask" action="/vibe" method="get" role="search"><label for="oc-vibe-q" class="oc-sr">Ask Vibe</label><input id="oc-vibe-q" name="ask" type="text" maxlength="1200" placeholder="Ask Vibe anything" autocomplete="off" enterkeyhint="go" required><button type="submit" class="oc-button">Ask</button></form></section>`;
@@@ -161,8 +183,11 @@ function moreSection() {
}
/** Everything around global chat: the numbers, Ask Vibe, then stream chats, servers and rooms, how it works, questions, more. */
--function sections(a) {
-- return { stats: stats(a), vibe: vibeSection(), streams: streamsSection(a), places: placesSection(a), steps: stepsSection(), faq: faqSection(), more: moreSection() };
++function sections(a, { app = false } = {}) {
++ return { stats: app ? '' : stats(a), vibe: vibeSection(app), streams: streamsSection(a, app), places: placesSection(a, app), steps: app ? '' : stepsSection(), faq: app ? '' : faqSection(), more: app ? '' : moreSection() };
}
--module.exports = { activity, sections, FAQ, _reset: () => { cached = null; } };
++/** The room's header line for someone signed in: how many people wrote this week. */
++const peopleLine = (a) => (a.weekPeople ? ` · ${plural(a.weekPeople, 'person', 'people')} this week` : '');
++
++module.exports = { activity, sections, peopleLine, FAQ, _reset: () => { cached = null; } };
ddiff --git a/server/web/icons.js b/server/web/icons.js
iindex 767cf1b..c53a098 100644
---- a/server/web/icons.js
++++ b/server/web/icons.js
@@@ -37,6 +37,7 @@ const ICONS = {
key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),
help: svg('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>'),
more: svg('<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>'),
++ download: svg('<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>'),
chevron: svg('<path d="m9 6 6 6-6 6"/>'),
hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
};
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 33a780a..ae6a807 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -236,24 +236,37 @@ function createWebRoutes({ config }) {
}
} catch { /* the names stay plain */ }
const composer = actor.kind === 'user'
-- ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
--<label for="oc-input" class="oc-sr">Message to everyone</label>
--<textarea id="oc-input" name="message" maxlength="500" rows="2" required placeholder="Say something to everyone on OpenVibe"></textarea>
--<button type="submit">Send</button>
--</form>`)
++ ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<div class="oc-dock"><form class="oc-compose" method="post" action="/send" id="oc-compose"><label for="oc-input" class="oc-sr">Message to everyone</label><textarea id="oc-input" name="message" maxlength="500" rows="1" required placeholder="Message everyone on OpenVibe"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`)
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
-- // Around the room: the real activity (server/web/home.js, read once a minute), how it works and questions.
-- const parts = home.sections(await home.activity());
const signedIn = actor.kind === 'user';
++ // Around the room: the real activity (server/web/home.js, read once a minute). Someone signed in gets the app: the
++ // room first at the screen's height, Ask Vibe in one line, compact lists, and no introduction or questions (those,
++ // the hero and the full footer, are for people who are new here, and for search).
++ const activity = await home.activity();
++ const parts = home.sections(activity, { app: signedIn });
++ 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>
++${error}
++${feedList}
++${composer}
++</section>
++${parts.vibe}
++${parts.streams}
++${parts.places}`;
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 },
-- body: `<header class="oc-hero"><p class="oc-eyebrow">OpenVibe.Chat</p>
++ footer: signedIn ? 'compact' : 'full',
++ body: signedIn ? appBody() : `<header class="oc-hero"><p class="oc-eyebrow">OpenVibe.Chat</p>
<h1>Chat with everyone on OpenVibe, or start your own server</h1>
--<p class="oc-lede">Vibe, OpenVibe's own assistant, global chat for the whole network, the chat of every stream on OpenVibe.Live, servers with text and voice channels, rooms, private messages, calls of up to eight people, and AI members that join in. One free account, everywhere.</p>
++<p class="oc-lede">Vibe, OpenVibe's assistant. One chat for the whole network and every stream on OpenVibe.Live. Servers with text and voice channels, rooms, private messages and calls of up to eight, with AI members that join in. 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> <a class="oc-button oc-button--quiet" href="/rooms">Rooms</a> <a class="oc-button oc-button--quiet" href="${signedIn ? '/messages' : '/auth/login?next=%2Fmessages'}">Messages</a></p>
${parts.stats}</header>
${parts.vibe}
@@@ -261,7 +274,7 @@ ${parts.vibe}
<h2 id="oc-h">Global chat</h2>
<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
${error}
--<ol class="oc-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>
++${feedList}
${composer}
</section>
${parts.streams}
@@@ -464,39 +477,43 @@ ${composer}`,
const sid = actor.user.subject_id;
const [p, tts, dmSet, pres] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid)]);
const src = tts.sources || {};
-- const volume = (name, label, v) => `<label>${label} <input type="number" name="${name}" min="0" max="100" step="5" value="${Number.isInteger(v) ? v : 80}"></label>`;
++ // A settings row: what it does on the left (with one muted line when it needs saying), the switch on the right.
++ const box = (name, label, on, hint = '') => `<label class="oc-set-row"><span class="oc-set-text">${label}${hint ? `<small>${hint}</small>` : ''}</span><input type="checkbox" class="oc-switch" name="${name}" value="1"${on ? ' checked' : ''}></label>`;
++ const volume = (name, label, v) => { const val = Number.isInteger(v) ? v : 80; return `<label class="oc-set-row oc-set-range"><span class="oc-set-text">${label}</span><input type="range" name="${name}" min="0" max="100" step="5" value="${val}" aria-valuetext="${val} percent"></label>`; };
const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
-- const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;
++ 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)) : '';
await page(req, res, 200, {
actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', footer: 'compact',
-- body: `<h1>Chat settings</h1>${saved}${error}
--<p class="oc-muted">Saved to your OpenVibe account, so they apply here, in Live chat and on every device you sign in to.</p>
--<form class="oc-form" method="post" action="/settings">
--${box('timestamps', 'Show the time next to each message', p.timestamps === true)}
++ body: `${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)}
${box('compact', 'Compact messages', p.compact === true)}
--${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}
++${box('show_badges', 'Staff and moderator badges', p.show_badges !== false)}
${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
--<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>
--<h2>Messages</h2>
--<label>Who can start a conversation with you <select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody (your existing conversations stay open)</option></select></label>
--${box('group_invites', 'People can add me to group conversations', dmSet.group_invites !== false)}
--${box('previews', 'Show message text in notifications', dmSet.previews !== false)}
--<h2>Presence</h2>
--${box('show_in_user_list', 'Show my name in chat user lists (when off, you are counted but not named)', pres.show_in_user_list !== false)}
--<h2>Text-to-speech and sounds</h2>
--${box('tts_send', 'Read my messages aloud where the streamer has text-to-speech on', tts.send !== false)}
--${box('tts_send_while_live', 'Also while I am broadcasting', tts.send_while_live !== false)}
--${volume('tts_volume', 'Text-to-speech volume', tts.volume)}
--${box('tts_sounds', 'Play chat sounds', tts.sounds !== false)}
--${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
--<fieldset class="oc-fieldset"><legend>Read messages relayed from</legend>${TTS_SOURCES.map(([k, label]) => box(`tts_src_${k}`, label, src[k] !== false)).join('')}</fieldset>
--<p><button type="submit">Save</button></p>
++<div class="oc-set-row"><span class="oc-set-text" id="oc-size-l">Text size</span><span class="oc-seg" role="radiogroup" aria-labelledby="oc-size-l">${sizes}</span></div>
++</section>
++<section class="oc-set" aria-labelledby="oc-set-dm"><h2 id="oc-set-dm">Direct messages</h2>
++<label class="oc-set-row"><span class="oc-set-text">Who can start one with you</span><select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody</option></select></label>
++${box('group_invites', 'People can add me to groups', dmSet.group_invites !== false)}
++${box('previews', 'Message text in notifications', dmSet.previews !== false)}
++${box('show_in_user_list', 'My name in chat user lists', pres.show_in_user_list !== false, 'Off: you are counted, not named')}
++</section>
++<section class="oc-set" aria-labelledby="oc-set-sound"><h2 id="oc-set-sound">Speech and sounds</h2>
++${box('tts_send', 'Read my messages aloud', tts.send !== false, 'Where the streamer has text-to-speech on')}
++${box('tts_send_while_live', 'Also while I broadcast', tts.send_while_live !== false)}
++${volume('tts_volume', 'Speech volume', tts.volume)}
++${box('tts_sounds', 'Chat sounds', tts.sounds !== false)}
++${volume('tts_sound_volume', 'Sound volume', tts.sound_volume)}
++<div class="oc-set-row oc-set-chips"><span class="oc-set-text">Read messages from</span><span class="oc-chips">${TTS_SOURCES.map(([k, label]) => `<label class="oc-chip"><input type="checkbox" name="tts_src_${k}" value="1"${src[k] !== false ? ' checked' : ''}><span>${label}</span></label>`).join('')}</span></div>
++</section>
++<p class="oc-set-save"><button type="submit">Save settings</button></p>
</form>
--<h2>Your chat history</h2>
--<p>Download every message you have sent that is still visible, in Live chat and here.</p>
--<p><a class="oc-button" href="/api/chat/me/export" download>Download (JSON)</a> <a class="oc-button" href="/api/chat/me/export?format=csv" download>Download (CSV)</a></p>
++<section class="oc-set" aria-labelledby="oc-set-data"><h2 id="oc-set-data">Your data</h2>
++<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 || {})}`,
});
});
@@@ -517,7 +534,8 @@ ${await aiKeySection(actor, req.query || {})}`,
const keyLine = key && key.provider === 'node' ? `${key.machine === 'any' ? 'on whichever of your computers is on, the fastest first' : `on your computer <code>${esc(key.machine || '')}</code>`} · nothing spent` : key ? `key ending ${esc(key.key_hint || '····')} · ${esc(spent)}` : '';
const vibeOwn = key ? (await require('../rooms/vibe').engineOf(actor.user)).own : false;
const vibeBox = require('../rooms/vibe').enabled() ? `<label class="oc-check"><input type="checkbox" name="vibe" value="1"${vibeOwn || offered ? ' checked' : ''}> Vibe thinks with it too (in all your chats with Vibe; its answers then have no daily limit)</label>` : '';
-- return `<h2 id="oc-ai-key">Your AI key</h2>
++ const open = offered || q.saved || q.error ? ' open' : '';
++ return `<details class="oc-card oc-fold oc-aikey" id="oc-ai-key"${open}><summary><span>Your AI key${key ? ` <span class="oc-muted">· ${esc(credentials.label(key))}</span>` : ''}</span>${IC.chevron}</summary>
<p class="oc-muted">AI members you add to a room, your Vibe among them, can think with your own provider and model instead of OpenVibe's: OpenAI, Anthropic, any OpenAI-compatible endpoint (OpenRouter, Groq, Together, your own server), or <strong>a model on your own computer</strong> (Ollama, LM Studio or llama.cpp through the OpenVibe Node, free, with no port opened: turn on "Let my AI use this computer's models" for it on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>). A key is kept encrypted in OpenVibe.AI and never shown again; the room's messages go to your provider or your computer when one of those members answers, and a key's replies count against your daily budget, not the room's limit.</p>
${offered ? notice('ok', `Save to let your AI members think with ${offered.model || 'a model'} on ${offered.machine === 'any' ? 'whichever of your computers is on' : 'your own computer'}.`) : ''}
${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot be shown or changed right now.') : `${key ? `<p><strong>${esc(credentials.label(key))}</strong> <span class="oc-muted">· ${keyLine}${vibeOwn ? ' · Vibe thinks with it' : ''}</span></p>` : '<p class="oc-muted">No key yet.</p>'}
@@@ -532,7 +550,7 @@ ${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot
${vibeBox}
<p><button type="submit">${key ? 'Save' : 'Add key'}</button></p>
</form>
--${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete"><button type="submit" class="oc-link">Remove my key</button> <span class="oc-muted">(AI members that use it go back to OpenVibe's model)</span></form>` : ''}`}`;
++${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/ai-key/delete"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove my key</span></button> <span class="oc-muted">AI members that use it go back to OpenVibe's model</span></form>` : ''}`}</details>`;
}
router.post('/settings/ai-key', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
@@@ -627,12 +645,15 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const actor = await viewerOf(req);
const me = actor.kind === 'user' ? actor.user : null;
const { public: pub, mine } = await rooms.list(me);
++ // Your rooms are listed once, in their own list.
++ const yours = new Set(mine.map((r) => r.slug));
++ const others = pub.filter((r) => !yours.has(r.slug));
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const staff = !!me && permissions.can(me, 'staff.moderation.chat');
// ?new=workspace (other OpenVibe sites link "Work on it together" here) picks the workspace kind and fills the name.
const wantWork = req.query.new === 'workspace';
const preName = wantWork && typeof req.query.name === 'string' ? req.query.name.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 40) : '';
-- const create = me ? `<form class="oc-form oc-create" id="oc-new-room" method="post" action="/rooms/new">
++ const create = me ? `<form class="oc-form oc-grid-form oc-create" id="oc-new-room" method="post" action="/rooms/new">
<h2>Start a room</h2>
<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls" value="${esc(preName)}"${wantWork ? ' autofocus' : ''}></label>
<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
@@@ -651,7 +672,7 @@ ${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system
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}
${mine.length ? `<h2 class="oc-list-head">Your rooms</h2><ul class="oc-list">${mine.map(roomCard).join('')}</ul>` : ''}
--<h2 class="oc-list-head">Public rooms</h2>${pub.length ? `<ul class="oc-list">${pub.map(roomCard).join('')}</ul>` : `<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>Meanwhile the conversation is here:</p>
<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>
@@@ -834,10 +855,11 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;
-- const seen = (m) => { const t = hhmm(m.last_seen_at); return t.iso ? ` <span class="oc-muted">· seen <time datetime="${t.iso}">${t.iso.slice(0, 10)}</time></span>` : ''; };
-- const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>'
-- : (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>'
-- : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;
++ const seen = (m) => { const t = ui.when(m.last_seen_at); return t.text ? ` · seen <time datetime="${t.iso}">${esc(t.text)}</time>` : ''; };
++ const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted oc-role">owner</span>'
++ : (m.role === 'mod' && !can.manage) ? '<span class="oc-muted oc-role">moderator</span>'
++ : `<form class="oc-role-form" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select><button type="submit" class="oc-icon-btn" aria-label="Set the role of ${esc(m.username || '')}" title="Set">${IC.check}</button></form>`;
++ const memberRow = (m) => `<li class="oc-row oc-row-static">${ui.initials(m.display_name || m.username || '?')}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(m.display_name || m.username || '?')}</strong></span><span class="oc-row-sub">@${esc(m.username || '')}${seen(m)}</span></span>${roleForm(m)}</li>`;
const addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
@@@ -849,7 +871,7 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
return `<label>Thinks with <select name="engine"><option value="openvibe"${cur === 'openvibe' ? ' selected' : ''}>OpenVibe's model</option><option value="own"${cur === 'own' ? ' selected' : ''}${cur === 'own' || myKey ? '' : ' disabled'}>${esc(ownLabel)}</option></select></label>
${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider, or a model on your own computer), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;
};
-- const aiForm = (a) => `<form class="oc-form oc-ai-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
++ const aiForm = (a) => `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
${a ? '' : '<h3>Add an AI member</h3>'}
<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
@@@ -857,51 +879,52 @@ ${a ? '' : '<h3>Add an AI member</h3>'}
${engineField(a)}
${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label>` : ''}
<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
-- const aiSection = can.manage && room.kind !== 'system' ? `<h2 id="oc-ai">AI members</h2>
++ const aiSection = can.manage && room.kind !== 'system' ? `<details class="oc-card oc-fold" id="oc-ai"${ais.length ? '' : ' open'}><summary><span>AI members${ais.length ? ` <span class="oc-muted">· ${ais.map((a) => esc(a.name)).join(', ')}</span>` : ''}</span>${IC.chevron}</summary>
${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
--<p class="oc-muted">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
--${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-link">Remove ${esc(a.name)}</button></form></section>`).join('')}
++<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
++${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) : ''}` : '';
++${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
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: `<p><a href="${esc(await channelUrl(room, '/settings'))}">← Server settings</a> · <a href="${esc(await channelUrl(room))}">#${esc(room.name)}</a></p><h1>AI members in #${esc(room.name)}</h1>${saved}${error}
++ body: `${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 hookSection = can.manage && rooms.isWorkspace(room) ? `<h2 id="oc-hooks">Alerts into this room</h2>
--<p class="oc-muted">Other systems post here through a hook: an OpenVibe.Watch webhook action, an OpenVibe.Events subscription, or anything that signs its POST the same way. Each one arrives as a message from the room's Vibe, and can add a task to the queue for you. At most ${roomHooks.MAX_PER_ROOM}.</p>
++ const hookSection = can.manage && rooms.isWorkspace(room) ? `<details class="oc-card oc-fold" id="oc-hooks"><summary><span>Alerts into this room${hookList.length ? ` <span class="oc-muted">· ${hookList.length}</span>` : ''}</span>${IC.chevron}</summary>
++<p class="oc-muted oc-small">Other systems post here through a hook: an OpenVibe.Watch webhook action, an OpenVibe.Events subscription, or anything that signs its POST the same way. Each one arrives as a message from the room's Vibe, and can add a task to the queue for you. At most ${roomHooks.MAX_PER_ROOM}.</p>
${hookList.length ? `<ul class="oc-members">${hookList.map((h) => `<li><strong>${esc(h.label)}</strong> <span class="oc-muted">${h.queue_kind ? `queues ${esc(h.queue_kind)} work` : 'a message only'} · ${h.deliveries} received${h.last_at ? ` · last ${esc(String(h.last_at).slice(0, 16))}` : ''}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/hooks/${encodeURIComponent(h.id)}/delete"><button type="submit" class="oc-link">Delete</button></form></li>`).join('')}</ul>` : ''}
${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/hooks">
<label>Name <input name="label" required minlength="2" maxlength="60" placeholder="Site errors"></label>
<label>Each alert <select name="queue_kind"><option value="">posts a message</option><option value="research">posts and asks Vibe to look into it (research, runs on your allowance)</option><option value="code">posts and queues a code task for a worker</option><option value="other">posts and queues a task for a worker</option></select></label>
--<p><button type="submit">Make a hook</button></p></form>` : ''}` : '';
++<p><button type="submit">Make a hook</button></p></form>` : ''}</details>` : '';
const communityUrl = config.web.communityUrl;
-- const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${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">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
++ 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}/s/${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',
-- body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
--${can.manage && await require('./room-embed-pages').embeddable(room) ? `<h2>Put this room on your site</h2>
--<p>Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>
--<pre class="ib-snippet" tabindex="0"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" async></script>`)}</code></pre>
--<p>Or in a part of your page (an element with id="chat"):</p>
--<pre class="ib-snippet" tabindex="0"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" data-target="#chat" data-height="520" async></script>`)}</code></pre>` : ''}
--${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings">
++ 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}
++${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>
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}
--<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
--${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-check"><input type="checkbox" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}> Work together here: a work queue beside the conversation (Vibe runs research at once; code and computer tasks go to a member or to the asker's own computer), alerts from other systems, and a shared screen</label>` : ''}
--${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
--<p><button type="submit">Save</button></p></form>` : ''}
++<label>Slow mode: seconds between messages (0 is off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
++${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Work together here<small>A work queue beside the conversation, alerts from other systems and a shared screen</small></span><input type="checkbox" class="oc-switch" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}></label>` : ''}
++${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
++<p><button type="submit">Save</button></p></form></section>` : ''}
++<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2>
++<p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p>
++<form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>
++<ul class="oc-list oc-list-flush">${members.map(memberRow).join('')}</ul>
++</section>
${hookSection}
--<h2>Members</h2>
--<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
--<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
--<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span>${seen(m)} ${roleForm(m)}</li>`).join('')}</ul>
++${can.manage && await require('./room-embed-pages').embeddable(room) ? `<details class="oc-card oc-fold" id="oc-embed"><summary><span>Put this room on your site</span>${IC.chevron}</summary>
++<p class="oc-muted oc-small">Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>
++<div class="oc-snip"><button type="button" class="oc-icon-btn" data-oc-copy="#oc-snip-bubble" aria-label="Copy" title="Copy" hidden>${IC.copy}</button><pre class="ib-snippet" tabindex="0" id="oc-snip-bubble"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" async></script>`)}</code></pre></div>
++<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}`,
});
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex b9d063b..fa9d84a 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -14,6 +14,8 @@
* room (a channel is a room), and call.js the voice channel's call.
*/
const { esc } = require('./layout');
++const ui = require('./ui');
++const { ICONS: IC } = require('./icons');
const rooms = require('../rooms/rooms');
const servers = require('../rooms/servers');
const ctx = require('../live-context');
@@@ -25,9 +27,9 @@ const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', mem
const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';
const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 9v6h4l5 4V5L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>';
--const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
--/** A steady hue per server, so its icon is recognisable in the rail. */
--const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
++const initials = ui.serverLetters;
++/** A steady hue per server, so its icon is recognisable in the rail (and in every list: ui.serverMark). */
++const hueOf = ui.hueOf;
function mountServerPages(router, h) {
const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config } = h;
@@@ -43,27 +45,32 @@ function mountServerPages(router, h) {
const errorOf = (req) => (req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '');
const icon = (s, active) => `<a class="oc-srv-icon${active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" title="${esc(s.name)}" style="--oc-hue:${hueOf(s.slug)}"${active ? ' aria-current="page"' : ''}><span aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-sr">${esc(s.name)}</span>${s.unread ? `<span class="oc-srv-dot" aria-label="${s.unread} unread">${s.unread > 99 ? '99+' : s.unread}</span>` : ''}</a>`;
-- const serverCard = (s) => `<li class="oc-srv-card"><a href="/s/${esc(s.slug)}"><span class="oc-srv-icon oc-srv-icon--card" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-srv-card-text"><span class="oc-srv-card-title"><strong>${esc(s.name)}</strong>${s.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${s.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}${s.unread ? `<span class="oc-badge oc-badge-new">${s.unread} new</span>` : ''}</span><span class="oc-muted">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}</span></span></a></li>`;
++ // One server in the directory: its mark, the name with its badges, the description or members under it.
++ const serverRow = (s) => `<li class="oc-row${s.unread ? ' oc-unread' : ''}"><a href="/s/${esc(s.slug)}">${ui.serverMark(s)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(s.name)}</strong>${s.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${s.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
++ + `<span class="oc-row-sub">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}${s.unread ? `<span class="oc-dot" aria-label="${s.unread} new">${s.unread > 9 ? '9+' : s.unread}</span>` : ''}</span></span></a></li>`;
// ── The directory ──
router.get('/servers', async (req, res) => {
const actor = await viewerOf(req);
const me = userOf(actor);
const { public: pub, mine } = await servers.list(me);
-- const create = me ? `<form class="oc-form oc-create" id="oc-new-server" method="post" action="/servers/new">
++ // Your servers are listed once, in their own list.
++ const yours = new Set(mine.map((s) => s.slug));
++ const others = pub.filter((s) => !yours.has(s.slug));
++ const create = me ? `<form class="oc-form oc-grid-form oc-create" id="oc-new-server" method="post" action="/servers/new">
<h2>Start a server</h2>
--<p class="oc-muted">A server is a place for a group: text channels to write in and voice channels to talk in, one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>
++<p class="oc-muted oc-small">Text channels to write in and voice channels to talk in, with one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>
<label>Name <input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>
<label>Description <input name="description" maxlength="300" placeholder="What it is for (optional)"></label>
<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people with an invite can find, read or join it</label>
<p><button type="submit">Create server</button></p>
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;
await page(req, res, 200, {
-- actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow',
++ actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'compact',
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
-- body: `<h1>Servers</h1>${errorOf(req)}
--${mine.length ? `<h2>Your servers</h2><ul class="oc-srv-cards">${mine.map(serverCard).join('')}</ul>` : ''}
--<h2>Public servers</h2><ul class="oc-srv-cards">${pub.map(serverCard).join('') || '<li class="oc-muted">No public servers yet. Start the first one.</li>'}</ul>
++ body: `${ui.head({ title: 'Servers', sub: 'Groups with text and voice channels, roles and invites', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-server" aria-label="Start a server" title="Start a server">${IC.plus}</a>`] : [] })}${errorOf(req)}
++${mine.length ? `<h2 class="oc-list-head">Your servers</h2><ul class="oc-list">${mine.map(serverRow).join('')}</ul>` : ''}
++<h2 class="oc-list-head">${mine.length ? 'More public servers' : 'Public servers'}</h2>${others.length ? `<ul class="oc-list">${others.map(serverRow).join('')}</ul>` : `<p class="oc-rooms-empty oc-muted">${mine.length ? 'No other public server yet.' : 'No public server yet. Start one: it can be public, or private to the people you invite.'}</p>`}
${create}`,
});
});
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex 0f66351..0b866de 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -7,6 +7,8 @@
* head({ title, sub, back, actions }) <header class="oc-page-head">: back icon, title and one muted line, icon actions
* when(ts, now) { iso, text }: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20
* initials(name) a round mark with up to two letters, its colour from the name
++ * hueOf(slug) / serverMark(server) a server's steady hue, and its rounded-square mark in a list row (the same
++ * colour as in the server rail, wherever the server is listed)
*/
const { esc } = require('./layout');
const { ICONS } = require('./icons');
@@@ -44,4 +46,8 @@ function initials(name) {
return `<span class="oc-av" style="--av:${h}" aria-hidden="true">${esc(letters)}</span>`;
}
--module.exports = { head, when, initials };
++const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
++const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
++const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
++
++module.exports = { head, when, initials, hueOf, serverLetters, serverMark };