openvibers/openvibe.chat Commits
Only chat staff take /s/openvibe; the official server is marked Official and listed first (#127)
Changes
@@@ -228,6 +228,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.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; }.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }.oc-move:hover { border-color: var(--oc-accent); }@@@ -35,6 +35,9 @@ const fail = (status, code, message) => { throw new RoomError(status, code, messconst SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invites', 'api', 'admin', 'staff', 'help', 'explore', 'discover', 'openvibe']);++// OpenVibe's own server (/s/openvibe): only chat staff may take this reserved address, and the pages mark it Official++// and list it first, so the official place is never a look-alike someone else registered.++const OFFICIAL_SLUG = 'openvibe';const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };const CHANNEL_KINDS = { text: 'community', voice: 'call' };@@@ -101,7 +104,7 @@ function publicServer(s, extra = {}) {if (!s) return null;return {id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,-- members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,++ official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,};}@@@ -162,7 +165,7 @@ async function create(user, { name, slug, description, visibility = 'public' } =if (!n) fail(422, 'servers.name', 'Server names are 2 to 40 characters');if (wordFilter.check(n).safe === false) fail(422, 'servers.name_filtered', 'That server name is not allowed');const s = slug ? String(slug).toLowerCase().trim() : slugify(n);-- if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');++ if (!SLUG_RE.test(s) || (RESERVED.has(s) && !(s === OFFICIAL_SLUG && isStaff(user)))) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private');const d = description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null : null;if (d && wordFilter.check(d).safe === false) fail(422, 'servers.description_filtered', 'That description is not allowed');@@@ -185,7 +188,7 @@ async function create(user, { name, slug, description, visibility = 'public' } =/** The servers `user` is in (with unread counts), and public servers by size. */async function list(user, { limit = 50 } = {}) {const lim = Math.min(100, Math.max(1, Number(limit) || 50));-- const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY member_count DESC, id DESC LIMIT ?", [lim])).map((s) => publicServer(s));++ const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY (slug = ?) DESC, member_count DESC, id DESC LIMIT ?", [OFFICIAL_SLUG, lim])).map((s) => publicServer(s));if (!user) return { public: pub, mine: [] };const rows = await db.all(`SELECT s.*, m.role FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_idWHERE m.user_id = ? AND m.role != 'blocked' AND s.archived_at IS NULL ORDER BY m.joined_at, s.id`, [user.id]);@@@ -413,5 +416,5 @@ 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,++ SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG,};@@@ -120,7 +120,7 @@ function streamsSection(a) {}function placesSection(a) {-- 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></span><span class="oc-muted">${esc(s.description || plural(s.members, 'member', 'members'))}</span></a></li>`);++ 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 [servers.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-servers"><div class="oc-home-head"><h2 id="oc-h-servers">Public servers</h2><a href="/servers">All servers</a></div><ul class="oc-streams" role="list">${servers.join('')}</ul></section>` : '',@@@ -43,7 +43,7 @@ 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.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>`;++ 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>`;// ── The directory ──router.get('/servers', async (req, res) => {@@@ -143,7 +143,7 @@ ${create}`,<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a><a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav><aside class="oc-srv-side" aria-label="${esc(server.name)}">--<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}++<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.slug === servers.OFFICIAL_SLUG ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header><details class="oc-srv-channels" open><summary>Channels</summary>@@@ -221,4 +221,17 @@ t('account deletion: memberships go; an owned server stays without an owner whilassert.ok(sent[0].body.erased.servers >= 1 && sent[0].body.retained.servers_without_owner >= 1, JSON.stringify(sent[0].body));});++t('the official server: only chat staff take /s/openvibe; it is marked official and listed first', async () => {++ assert.strictEqual((await api('POST', '/', bob, { name: 'OpenVibe', slug: 'openvibe' })).status, 422, 'a member cannot take the official address');++ const r = await api('POST', '/', staff, { name: 'OpenVibe', slug: 'openvibe', description: 'OpenVibe\'s own server: help, feedback and what is new' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.server.official, true);++ const list = (await api('GET', '/', null)).body.public;++ assert.strictEqual(list[0].slug, 'openvibe', 'listed first, whatever its size');++ assert.ok(list.slice(1).every((x) => x.official === false), 'no other server is official');++ assert.strictEqual((await api('POST', '/', staff, { name: 'Explore', slug: 'explore' })).status, 422, 'the other reserved words stay reserved, even for staff');++ assert.match((await page('/servers', null)).text, /<strong>OpenVibe<\/strong><span class="oc-badge oc-badge-official">official<\/span>/);++ assert.match((await page('/s/openvibe', null)).text, /oc-srv-name">OpenVibe<\/h1><span class="oc-badge oc-badge-official">official<\/span>/);++});++t.run(async () => { if (h && h.close) await h.close(); });
@@ -228,6 +228,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -228,6 +228,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-srv-card-title { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
.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-srv-card-title .oc-badge { margin-left: 0; }
.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }
.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; }
.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }
.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }
.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }
.oc-move:hover { border-color: var(--oc-accent); }
.oc-move:hover { border-color: var(--oc-accent); }
@@ -35,6 +35,9 @@ const fail = (status, code, message) => { throw new RoomError(status, code, mess
@@ -35,6 +35,9 @@ const fail = (status, code, message) => { throw new RoomError(status, code, mess
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invites', 'api', 'admin', 'staff', 'help', 'explore', 'discover', 'openvibe']);
const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invites', 'api', 'admin', 'staff', 'help', 'explore', 'discover', 'openvibe']);
+// OpenVibe's own server (/s/openvibe): only chat staff may take this reserved address, and the pages mark it Official
+// and list it first, so the official place is never a look-alike someone else registered.
+const OFFICIAL_SLUG = 'openvibe';
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
const CHANNEL_KINDS = { text: 'community', voice: 'call' };
const CHANNEL_KINDS = { text: 'community', voice: 'call' };
@@ -101,7 +104,7 @@ function publicServer(s, extra = {}) {
@@ -101,7 +104,7 @@ function publicServer(s, extra = {}) {
if (!s) return null;
if (!s) return null;
return {
return {
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,
- members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,
+ official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,
};
};
}
}
@@ -162,7 +165,7 @@ async function create(user, { name, slug, description, visibility = 'public' } =
@@ -162,7 +165,7 @@ async function create(user, { name, slug, description, visibility = 'public' } =
if (!n) fail(422, 'servers.name', 'Server names are 2 to 40 characters');
if (!n) fail(422, 'servers.name', 'Server names are 2 to 40 characters');
if (wordFilter.check(n).safe === false) fail(422, 'servers.name_filtered', 'That server name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'servers.name_filtered', 'That server name is not allowed');
const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
- if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
+ if (!SLUG_RE.test(s) || (RESERVED.has(s) && !(s === OFFICIAL_SLUG && isStaff(user)))) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private');
if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private');
const d = description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null : null;
const d = description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null : null;
if (d && wordFilter.check(d).safe === false) fail(422, 'servers.description_filtered', 'That description is not allowed');
if (d && wordFilter.check(d).safe === false) fail(422, 'servers.description_filtered', 'That description is not allowed');
@@ -185,7 +188,7 @@ async function create(user, { name, slug, description, visibility = 'public' } =
@@ -185,7 +188,7 @@ async function create(user, { name, slug, description, visibility = 'public' } =
/** The servers `user` is in (with unread counts), and public servers by size. */
/** The servers `user` is in (with unread counts), and public servers by size. */
async function list(user, { limit = 50 } = {}) {
async function list(user, { limit = 50 } = {}) {
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
- const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY member_count DESC, id DESC LIMIT ?", [lim])).map((s) => publicServer(s));
+ const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY (slug = ?) DESC, member_count DESC, id DESC LIMIT ?", [OFFICIAL_SLUG, lim])).map((s) => publicServer(s));
if (!user) return { public: pub, mine: [] };
if (!user) return { public: pub, mine: [] };
const rows = await db.all(`SELECT s.*, m.role FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id
const rows = await db.all(`SELECT s.*, m.role FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id
WHERE m.user_id = ? AND m.role != 'blocked' AND s.archived_at IS NULL ORDER BY m.joined_at, s.id`, [user.id]);
WHERE m.user_id = ? AND m.role != 'blocked' AND s.archived_at IS NULL ORDER BY m.joined_at, s.id`, [user.id]);
@@ -413,5 +416,5 @@ module.exports = {
@@ -413,5 +416,5 @@ 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,
+ SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS, OFFICIAL_SLUG,
};
};
@@ -120,7 +120,7 @@ function streamsSection(a) {
@@ -120,7 +120,7 @@ function streamsSection(a) {
}
}
function placesSection(a) {
function placesSection(a) {
- 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></span><span class="oc-muted">${esc(s.description || plural(s.members, 'member', 'members'))}</span></a></li>`);
+ 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>`);
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 [
return [
servers.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-servers"><div class="oc-home-head"><h2 id="oc-h-servers">Public servers</h2><a href="/servers">All servers</a></div><ul class="oc-streams" role="list">${servers.join('')}</ul></section>` : '',
servers.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-servers"><div class="oc-home-head"><h2 id="oc-h-servers">Public servers</h2><a href="/servers">All servers</a></div><ul class="oc-streams" role="list">${servers.join('')}</ul></section>` : '',
@@ -43,7 +43,7 @@ function mountServerPages(router, h) {
@@ -43,7 +43,7 @@ function mountServerPages(router, h) {
const errorOf = (req) => (req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '');
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 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.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>`;
+ 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>`;
// ── The directory ──
// ── The directory ──
router.get('/servers', async (req, res) => {
router.get('/servers', async (req, res) => {
@@ -143,7 +143,7 @@ ${create}`,
@@ -143,7 +143,7 @@ ${create}`,
<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>
<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>
<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>
<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>
<aside class="oc-srv-side" aria-label="${esc(server.name)}">
<aside class="oc-srv-side" aria-label="${esc(server.name)}">
-<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}
+<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.slug === servers.OFFICIAL_SLUG ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}
<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>
<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>
${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>
${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>
<details class="oc-srv-channels" open><summary>Channels</summary>
<details class="oc-srv-channels" open><summary>Channels</summary>
@@ -221,4 +221,17 @@ t('account deletion: memberships go; an owned server stays without an owner whil
@@ -221,4 +221,17 @@ t('account deletion: memberships go; an owned server stays without an owner whil
assert.ok(sent[0].body.erased.servers >= 1 && sent[0].body.retained.servers_without_owner >= 1, JSON.stringify(sent[0].body));
assert.ok(sent[0].body.erased.servers >= 1 && sent[0].body.retained.servers_without_owner >= 1, JSON.stringify(sent[0].body));
});
});
+t('the official server: only chat staff take /s/openvibe; it is marked official and listed first', async () => {
+ assert.strictEqual((await api('POST', '/', bob, { name: 'OpenVibe', slug: 'openvibe' })).status, 422, 'a member cannot take the official address');
+ const r = await api('POST', '/', staff, { name: 'OpenVibe', slug: 'openvibe', description: 'OpenVibe\'s own server: help, feedback and what is new' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.server.official, true);
+ const list = (await api('GET', '/', null)).body.public;
+ assert.strictEqual(list[0].slug, 'openvibe', 'listed first, whatever its size');
+ assert.ok(list.slice(1).every((x) => x.official === false), 'no other server is official');
+ assert.strictEqual((await api('POST', '/', staff, { name: 'Explore', slug: 'explore' })).status, 422, 'the other reserved words stay reserved, even for staff');
+ assert.match((await page('/servers', null)).text, /<strong>OpenVibe<\/strong><span class="oc-badge oc-badge-official">official<\/span>/);
+ assert.match((await page('/s/openvibe', null)).text, /oc-srv-name">OpenVibe<\/h1><span class="oc-badge oc-badge-official">official<\/span>/);
+});
+
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });