openvibers/openvibe.chat Commits

Every room says how it works, with the limits it enforces (market outcome) (#292)

OP OpenVibers committed 13 days ago · b5891f083e · 4 files, +79 −1

* Every room says how it works, with the limits it enforces (market outcome)

Under a room's name, 'How this room works' opens a short list: messages up to 2,000 characters, at
most 6 messages in 10 seconds each, the slow mode when it is on, that spam is refused and a word
filter masks slurs, that moderators can remove messages, and who reads. The numbers come from
rooms.LIMITS, the ones rooms.post enforces. The list floats, so the feed never moves.

* room-rules test: end with t.run(), so its checks run (they registered and never ran)

* Merge branch 'main' into room-how-it-works

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 93b8f41..ec3bc92 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -683,3 +683,17 @@ div.oc-set-row { cursor: default; }
.oc-fold-hint { display: none; }
.oc-fold-block > .oc-home-sec { margin-top: 0; padding: 0; }
}
++/* How this room works (market outcome: every room states its rules and rate limits): a small summary under the
++ room's name; its list floats over the feed instead of pushing it down */
++.oc-room-id { position: relative; }
++.oc-rules { display: inline-block; margin-top: 2px; }
++.oc-rules > summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; font-size: .78rem; font-weight: 650; color: var(--text-secondary, #96a7c2); }
++.oc-rules > summary::-webkit-details-marker { display: none; }
++.oc-rules > summary::after { content: '▾'; font-size: .7em; opacity: .8; }
++.oc-rules[open] > summary::after { content: '▴'; }
++.oc-rules > summary:hover, .oc-rules[open] > summary { color: var(--text-primary, #e8ecf3); }
++.oc-rules > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 4px; }
++.oc-rules > ul { position: absolute; z-index: 30; left: 0; top: 100%; margin: 6px 0 0; padding: 10px 14px 10px 28px; min-width: min(340px, 86vw); max-width: 420px;
++ border: 1px solid var(--border, rgba(255, 255, 255, .1)); border-radius: 12px; background: var(--bg-card, #121826); box-shadow: 0 16px 40px -18px rgba(0, 0, 0, .6);
++ font-size: .84rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.oc-rules > ul li + li { margin-top: 3px; }
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 07625f5..57e5409 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -496,7 +496,11 @@ async function attachments(room) {
function _reset() { _rate.clear(); }
++// What every room's page states about posting (market outcome: "every room shows its rules and rate limits").
++const LIMITS = Object.freeze({ maxText: MAX_TEXT, rateMax: RATE.max, rateSeconds: RATE.windowMs / 1000 });
++
module.exports = {
++ LIMITS,
isWorkspace, isStaffRoom,
postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex e1eea29..67754c1 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -667,6 +667,24 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
};
const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };
const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };
++ /**
++ * How a room works, in its own words (market outcome: every room shows its rules and rate limits on the page): what a
++ * message may be, how fast one may post, slow mode, what is filtered, and who reads. The numbers are the ones
++ * rooms.post enforces (rooms.LIMITS), so the page can never promise one thing and do another.
++ */
++ function roomRules(room) {
++ const L = require('../rooms/rooms').LIMITS;
++ const items = [
++ `Messages up to ${L.maxText.toLocaleString('en-US')} characters`,
++ `At most ${L.rateMax} messages in ${L.rateSeconds} seconds each`,
++ room.slow_seconds > 0 ? `Slow mode: one message every ${Number(room.slow_seconds)} seconds (moderators are not slowed)` : 'No slow mode',
++ 'Obvious spam is refused, and a word filter masks slurs',
++ 'Moderators can remove messages',
++ room.visibility === 'private' ? 'Private: only the people invited read and post' : 'Anyone can read; members post',
++ ];
++ return `<details class="oc-rules"><summary>How this room works</summary><ul>${items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul></details>`;
++ }
++
const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');
// One room in a list: its initials, name with its kind and a lock when private, the topic or the members under it.
const roomCard = (r) => `<li class="oc-row${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}">${ui.initials(r.name)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
@@@ -818,7 +836,7 @@ ${people}
body: `<header class="oc-room-head">
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
--<p class="oc-muted oc-room-sub">${room.topic ? `${esc(room.topic)} · ` : ''}${memberCount} ${memberCount === 1 ? 'member' : 'members'}${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole} <span class="oc-live" id="oc-live" hidden>● live</span></p></div>
++<p class="oc-muted oc-room-sub">${room.topic ? `${esc(room.topic)} · ` : ''}${memberCount} ${memberCount === 1 ? 'member' : 'members'}${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole} <span class="oc-live" id="oc-live" hidden>● live</span></p>${roomRules(room)}</div>
<div class="oc-room-tools">${manage}${leave}</div>
</header>
${aiHere}${error}
ddiff --git a/test/room-rules.test.js b/test/room-rules.test.js
nnew file mode 100644
iindex 0000000..817ea32
---- /dev/null
++++ b/test/room-rules.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++/**
++ * Every room states how it works on its page (market outcome: "every room shows its rules and rate limits"): the
++ * length a message may be, how fast a person may post, slow mode when it is on, what is filtered, and who reads. The
++ * numbers are the ones rooms.post enforces (rooms.LIMITS), so the page and the rule cannot drift.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('room-rules');
++let h, alice;
++const page = async (slug) => (await fetch(`${h.base}/r/${slug}`)).text();
++const rules = (html) => (/<details class="oc-rules"><summary>How this room works<\/summary><ul>([\s\S]*?)<\/ul><\/details>/.exec(html) || [])[1] || '';
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(alice.id));
++ const rooms = require('../server/rooms/rooms');
++ await rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });
++});
++
++t('a public room: its limits as rooms.post enforces them, no slow mode, anyone reads', async () => {
++ const { LIMITS } = require('../server/rooms/rooms');
++ const r = rules(await page('night-owls'));
++ assert.ok(r, 'the room says how it works');
++ assert.ok(r.includes(`<li>Messages up to ${LIMITS.maxText.toLocaleString('en-US')} characters</li>`));
++ assert.ok(r.includes(`<li>At most ${LIMITS.rateMax} messages in ${LIMITS.rateSeconds} seconds each</li>`));
++ assert.ok(r.includes('<li>No slow mode</li>'));
++ assert.ok(r.includes('<li>Anyone can read; members post</li>'));
++});
++
++t('slow mode, when a room has it, is stated with its seconds', async () => {
++ const db = require('../server/db/database');
++ await db.run("UPDATE rooms SET slow_seconds = 15 WHERE slug = 'night-owls'");
++ const html = await page('night-owls');
++ assert.ok(rules(html).includes('<li>Slow mode: one message every 15 seconds (moderators are not slowed)</li>'));
++ assert.ok(!/<p class="oc-muted oc-room-sub">[^<]*<details/.test(html), 'the fold is not inside the paragraph');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });