openvibers/openvibe.chat Commits

Only chat staff take /s/openvibe; the official server is marked Official and listed first (#127)

OP OpenVibers committed 22 days ago · 94b3b3408c · 5 files, +24 −7

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 50cbe15..9877423 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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); }
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex 636d6dc..1b816c5 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -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 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_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 = {
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,
};
ddiff --git a/server/web/home.js b/server/web/home.js
iindex cf0d893..75767c5 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -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>` : '',
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 1d19385..3f69885 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -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>
ddiff --git a/test/servers.test.js b/test/servers.test.js
iindex b9bf76b..e1772de 100644
---- a/test/servers.test.js
++++ b/test/servers.test.js
@@@ -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));
});
++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(); });