openvibers/openvibe.chat Commits
Every room says how it works, with the limits it enforces (market outcome) (#292)
Changes
@@@ -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; }@@@ -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,@@@ -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}nnew file mode 100644@@@ -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(); });
@@ -683,3 +683,17 @@ div.oc-set-row { cursor: default; }
@@ -683,3 +683,17 @@ div.oc-set-row { cursor: default; }
.oc-fold-hint { display: none; }
.oc-fold-hint { display: none; }
.oc-fold-block > .oc-home-sec { margin-top: 0; padding: 0; }
.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; }
@@ -496,7 +496,11 @@ async function attachments(room) {
@@ -496,7 +496,11 @@ async function attachments(room) {
function _reset() { _rate.clear(); }
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 = {
module.exports = {
+ LIMITS,
isWorkspace, isStaffRoom,
isWorkspace, isStaffRoom,
postAsAi,
postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
@@ -667,6 +667,24 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -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 KIND_LABEL = { community: '', call: 'call', system: 'announcements' };
const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };
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>` : '');
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.
// 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>`
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}
@@ -818,7 +836,7 @@ ${people}
body: `<header class="oc-room-head">
body: `<header class="oc-room-head">
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
-<p class="oc-muted oc-room-sub">${room.topic ? `${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>
<div class="oc-room-tools">${manage}${leave}</div>
</header>
</header>
${aiHere}${error}
${aiHere}${error}
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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(); });