openvibers/openvibe.chat Commits

Chat owns chat.tts_defaults, chat.dm_settings and chat.presence_prefs (Contracts 0.41.0, WS-B task 9): one module store per namespace (server/prefs/module-store.js), /api/chat/tts-settings, /dm-settings and /presence beside /preferences, openvibe.chat Settings edits all four; DMs honour new_conversations nobody (existing conversations stay open) and group_invites; user lists count, never name, someone hidden; network.module.updated refreshes them

OP OpenVibers committed 25 Sept 2026 · 2bdb0cfec1 · 14 files, +442 −177


Changes

ddiff --git a/README.md b/README.md
iindex 88d3dfd..b8e5a58 100644
---- a/README.md
++++ b/README.md
@@@ -178,6 +178,16 @@ network.modules.read` and `network.modules.write` on `chat.preferences`) and nam
every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser
If-Match is strict: 412).
++Three more modules work the same way, with `{ settings: { … } }` as the body (openvibe-contracts 0.41.0):
++
++| Route | Module | Fields | Enforced by |
++| --- | --- | --- | --- |
++| `/api/chat/tts-settings` | `chat.tts_defaults` v2 | `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources` | Live's chat panel |
++| `/api/chat/dm-settings` | `chat.dm_settings` | `new_conversations` (`everyone`/`nobody`), `group_invites`, `previews` | the DM routes. `nobody` refuses a new direct conversation, but an existing one stays open; `group_invites: false` refuses groups (`dm.not_accepting`, `dm.no_group_invites`) |
++| `/api/chat/presence` | `chat.presence_prefs` | `show_in_user_list` | the user list. A hidden person is counted in `hiddenCount`, not named |
++
++openvibe.chat's Settings page edits all four. When Network cannot answer, an enforcement check uses the defaults, so an outage never locks anyone out.
++
- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; a
change made through Network directly (the person's account page) shows at once: its
`network.module.updated` reaches the Events consumer, and `prefs.handleEvent()` drops the cached copy
ddiff --git a/package-lock.json b/package-lock.json
iindex 7405018..895fb85 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
@@@ -1062,9 +1062,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.40.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
-- "integrity": "sha512-/s6JVX88dC2hC2tDmHQcg9vJGZ2RkdMdulIorL0guIzwPq8o0qG7Vv7wk3bVlCSEhzrH62rt6BgRQmD+wmAz2w==",
++ "version": "0.41.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
++ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 729c9f2..a34ea49 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 700845c..276a486 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -65,6 +65,8 @@ button[disabled] { opacity: .6; cursor: default; }
.oc-conv.oc-unread { border-color: var(--oc-accent); }
.oc-form { display: grid; gap: 10px; max-width: 520px; }
.oc-check { display: flex; gap: 8px; align-items: center; }
++.oc-fieldset { border: 1px solid var(--oc-line); border-radius: 12px; padding: 8px 12px; display: grid; gap: 6px; }
++.oc-form h2 { font-size: 1rem; margin: 18px 0 4px; }
.oc-form select { margin-left: 8px; }
@media (max-width: 560px) {
.oc-main { padding: 12px 12px 40px; }
ddiff --git a/server/app.js b/server/app.js
iindex 7f2cf8d..e998f83 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -200,8 +200,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
next();
});
-- // The person's chat preferences: Network user module chat.preferences (server/prefs/).
-- app.use('/api/chat/preferences', require('./prefs/routes'));
++ // The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
++ for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat', require('./chat/routes'));
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex a1fa996..4d3cb5f 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -407,6 +407,7 @@ class ChatServer {
if (user) {
if (!client.user || client.user.id === user.id) {
client.user = user;
++ this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
client.tokenIat = session.tokenIat(msg.token);
client.anonId = null; // no longer anonymous
} else {
@@@ -592,12 +593,14 @@ class ChatServer {
getUserList(streamId) {
const seen = new Set();
const logged = [];
-- let anonCount = 0;
++ let anonCount = 0, hiddenCount = 0;
for (const [, c] of this.clients) {
if (c.streamId !== streamId) continue;
if (c.user) {
if (seen.has(c.user.id)) continue;
seen.add(c.user.id);
++ // chat.presence_prefs: someone who turned off "show my name in user lists" is counted, not named.
++ if (this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }
logged.push({
username: c.user.username,
display_name: c.user.display_name || c.user.username,
@@@ -613,7 +616,33 @@ class ChatServer {
// Sort: admins first, then mods, then alphabetical
const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };
logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));
-- return { logged, anonCount };
++ return { logged, anonCount, hiddenCount };
++ }
++
++ /**
++ * Whether a signed-in user asked not to be named in user lists (Network user module
++ * chat.presence_prefs). Synchronous: the cached record, fetched at join; not cached yet → fetch it
++ * in the background and name them until it arrives.
++ */
++ _hiddenFromUserList(user) {
++ const subject = this._subjectOfUser(user);
++ if (!subject) return false;
++ const { presence } = require('../prefs/stores');
++ const cached = presence.peek(subject);
++ if (cached) return cached.show_in_user_list === false;
++ presence.get(subject).catch(() => {});
++ return false;
++ }
++
++ /** Someone changed chat.presence_prefs elsewhere: read it again, then refresh the user lists they are in. */
++ presenceChanged(subject) {
++ if (!subject) return;
++ const { presence } = require('../prefs/stores');
++ presence.get(subject).catch(() => {}).then(() => {
++ const streams = new Set();
++ for (const [, c] of this.clients) if (c.user && this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);
++ for (const sid of streams) this.broadcastUsersList(sid);
++ });
}
_parseSlurFilterTerms(rawTerms) {
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex cc7e03f..5e4b3ac 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -18,6 +18,16 @@ const router = express.Router();
const { requireAuth } = require('../auth/auth');
const dm = require('./dm');
const ctx = require('../live-context');
++const prefStores = require('../prefs/stores');
++
++/**
++ * The target's chat.dm_settings (Network user module): does this person accept a new direct
++ * conversation, or being added to a group? When Network cannot answer, the defaults (yes) apply.
++ */
++async function dmSettingsOf(target) {
++ const subject = (target && target.subject_id) || require('../db/database').subjectFor(target && target.id);
++ return prefStores.settingsOf(prefStores.dm, subject);
++}
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
function publicMessage(m) {
@@@ -103,6 +113,14 @@ router.post('/conversations', async (req, res) => {
if (dm.isBlockedEither(req.user.id, uid)) {
return res.status(403).json({ error: 'Cannot start a conversation with this user' });
}
++ // Their message settings: a new direct conversation, or a group, only if they accept one.
++ const wants = await dmSettingsOf(target);
++ if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !dm.findDirectConversation(req.user.id, uid)) {
++ return res.status(403).json({ error: `${target.display_name || target.username} is not accepting new conversations`, code: 'dm.not_accepting' });
++ }
++ if (participantIds.length > 2 && wants.group_invites === false) {
++ return res.status(403).json({ error: `${target.display_name || target.username} cannot be added to group conversations`, code: 'dm.no_group_invites' });
++ }
}
let conversationId;
@@@ -273,6 +291,9 @@ router.post('/conversations/:id/participants', async (req, res) => {
if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
return res.status(403).json({ error: 'Cannot add this user' });
}
++ if ((await dmSettingsOf(target)).group_invites === false) {
++ return res.status(403).json({ error: `${target.display_name || target.username} cannot be added to group conversations`, code: 'dm.no_group_invites' });
++ }
// Group size limit
if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex c6659f6..14500f4 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -9,10 +9,11 @@
* paths claim the head in deploy_releases, so while both run (compatibility
* register C-84) a deploy makes one card whichever arrives first. Older than
* 6 hours = ignored:stale.
-- * network.module.updated a user-module record changed at Network. Only chat.preferences matters: a
-- * revision newer than the cached copy drops it (../prefs/chat-preferences.js
-- * handleEvent), so a change made elsewhere shows at once instead of within
-- * CHAT_PREFS_TTL_MS. Other namespaces are acknowledged and not recorded.
++ * network.module.updated a user-module record changed at Network. Only the chat.* namespaces Chat owns
++ * matter (../prefs/stores.js): a revision newer than the cached copy drops it
++ * (handleEvent), so a change made elsewhere shows at once instead of within
++ * CHAT_PREFS_TTL_MS; a chat.presence_prefs change also refreshes the user lists
++ * the person is in. Other namespaces are acknowledged and not recorded.
* vip.membership.changed a membership started, lapsed or was revoked (source vip): the member's cached
* subscriber-badge answers for that creator are dropped at once
* (../vip/badges.js handleEvent) instead of converging by the cache TTL.
@@@ -35,7 +36,7 @@ const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const db = require('../db/database');
const { viaProxy } = require('../net/service-auth');
const deployNotice = require('../chat/deploy-notice');
--const prefs = require('../prefs/chat-preferences');
++const prefStores = require('../prefs/stores');
const vipBadges = require('../vip/badges');
const revocations = require('../auth/revocations');
const ctx = require('../live-context');
@@@ -71,8 +72,14 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (event.event_type === 'network.module.updated') {
if (event.source !== 'network') return 'ignored:source';
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
-- if (p.namespace !== prefs.NAMESPACE) return 'ignored:namespace';
-- return () => (prefs.handleEvent(event) ? 'invalidated' : 'unchanged');
++ const store = prefStores.byNamespace.get(p.namespace);
++ if (!store) return 'ignored:namespace';
++ return () => {
++ if (!store.handleEvent(event)) return 'unchanged';
++ // Someone changed whether they are named in user lists: fetch it again and refresh the lists they are in.
++ if (store === prefStores.presence && chatServer && typeof chatServer.presenceChanged === 'function') chatServer.presenceChanged(p.owner && p.owner.id);
++ return 'invalidated';
++ };
}
if (event.event_type === 'network.user.token_valid_after') {
// Signed out everywhere, password changed, banned…: refuse this person's older tokens and
ddiff --git a/server/prefs/chat-preferences.js b/server/prefs/chat-preferences.js
iindex 0ceec46..552ec93 100644
---- a/server/prefs/chat-preferences.js
++++ b/server/prefs/chat-preferences.js
@@@ -1,141 +1,13 @@
/**
* A person's chat preferences: the OpenVibe.Network user module `chat.preferences`
* (openvibe-contracts manifests/namespaces/chat.preferences.json, schema v1: timestamps, compact,
-- * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover; Network
-- * stores it, validates it, versions it (revision) and announces every change (network.module.updated).
-- *
-- * get(subject) the record ({ preferences, revision, version }); {} and revision 0 when the
-- * person has none. Cached per person for config.prefs.ttlMs; when Network
-- * cannot answer, a cached copy is served (stale: true), else 503.
-- * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against
-- * Network naming the revision read (If-Match); a revision that moved is read
-- * again, up to 3 times. With expectedRevision (the browser's If-Match) it is
-- * strict instead: 412 when the record moved. A patch that changes nothing
-- * writes nothing.
-- * handleEvent(envelope) network.module.updated for chat.preferences newer than the cached copy
-- * drops it at once. Called by the Events consumer (server/events/consumer.js,
-- * Chat's subscription to network.module.updated); without that subscription
-- * the TTL bounds how long a change made outside Chat takes to show.
-- *
-- * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.
++ * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover. The store
++ * (get, update, handleEvent, invalidate) is ./module-store.js; the other chat.* modules are ./stores.js.
*/
'use strict';
--const { modules } = require('openvibe-contracts');
--const config = require('../config');
--const client = require('./network-modules');
++const { PrefsError, enabled, createModuleStore, _configure } = require('./module-store');
--const NAMESPACE = 'chat.preferences';
--const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));
--const MAX_TRIES = 3;
++const store = createModuleStore('chat.preferences', { label: 'preferences', key: 'preferences' });
--class PrefsError extends Error {
-- constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }
--}
--
--const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)
--let clock = () => Date.now();
--
--function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }
--
--function remember(subject, rec) {
-- cache.delete(subject);
-- cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });
-- while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);
--}
--
--const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, preferences: { ...rec.data }, ...extra });
--
--function unavailable(r) {
-- return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);
--}
--
--function checkSubject(subject) {
-- if (!enabled()) throw new PrefsError(503, 'prefs.disabled', 'chat preferences are not configured on this server');
-- if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');
--}
--
--/** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */
--async function fetchRecord(subject) {
-- const r = await client.request('GET', NAMESPACE, subject);
-- if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {
-- return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
-- }
-- if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };
-- if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', 'Chat may not read chat.preferences (Network grant missing)');
-- throw unavailable(r);
--}
--
--async function get(subject, { fresh = false } = {}) {
-- checkSubject(subject);
-- const hit = cache.get(subject);
-- if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);
-- try {
-- const rec = await fetchRecord(subject);
-- remember(subject, rec);
-- return view(rec);
-- } catch (err) {
-- if (hit && err.status === 503) return view(hit, { stale: true });
-- throw err;
-- }
--}
--
--/** A patch's problems: unknown fields, or a value the schema refuses once applied. */
--function checkPatch(patch) {
-- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', 'send { preferences: { field: value } }');
-- const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));
-- if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown preference${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: 'not a chat preference' })));
--}
--
--const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
--
--async function update(subject, patch, { expectedRevision } = {}) {
-- checkSubject(subject);
-- checkPatch(patch);
-- for (let i = 0; i < MAX_TRIES; i++) {
-- const cur = await fetchRecord(subject);
-- if (expectedRevision !== undefined && cur.revision !== expectedRevision) {
-- remember(subject, cur);
-- throw new PrefsError(412, 'prefs.revision_conflict', `the preferences changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);
-- }
-- const next = { ...cur.data };
-- for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }
-- const v = modules.validateData(NAMESPACE, next);
-- if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);
-- if (same(next, cur.data)) { remember(subject, cur); return view(cur); }
-- const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });
-- if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {
-- const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
-- remember(subject, rec);
-- return view(rec);
-- }
-- if (r.status === 412) {
-- if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', 'the preferences changed; read them again');
-- continue; // someone else wrote first: read again
-- }
-- if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);
-- if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');
-- if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);
-- throw unavailable(r);
-- }
-- throw new PrefsError(409, 'prefs.busy', 'the preferences kept changing; try again');
--}
--
--function handleEvent(envelope) {
-- if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;
-- const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};
-- const subject = p.owner && p.owner.id;
-- if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;
-- if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold
-- cache.delete(subject);
-- return true;
--}
--
--function invalidate(subject) { return cache.delete(subject); }
--
--/** Tests: an injected clock; clears the cache. */
--function _configure({ now } = {}) { if (now) clock = now; cache.clear(); }
--function _cached(subject) { return cache.get(subject) || null; }
--
--module.exports = { NAMESPACE, FIELDS, PrefsError, enabled, get, update, handleEvent, invalidate, _configure, _cached };
++module.exports = { ...store, PrefsError, enabled, _configure };
ddiff --git a/server/prefs/module-store.js b/server/prefs/module-store.js
nnew file mode 100644
iindex 0000000..bf6d3c6
---- /dev/null
++++ b/server/prefs/module-store.js
@@@ -0,0 +1,147 @@
++/**
++ * One OpenVibe.Network user module Chat owns, per person: chat.preferences (./chat-preferences.js), and since
++ * openvibe-contracts 0.41.0 chat.tts_defaults, chat.dm_settings and chat.presence_prefs (./stores.js).
++ * Network stores each record, validates it against the namespace's schema, versions it (revision) and
++ * announces every change (network.module.updated). createModuleStore(namespace, { label }) returns:
++ *
++ * get(subject) the record ({ data, revision, version }); {} and revision 0 when the person has
++ * none. Cached per person for config.prefs.ttlMs; when Network cannot answer, a
++ * cached copy is served (stale: true), else 503.
++ * peek(subject) the cached data, or null, without asking Network (for synchronous paths).
++ * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against
++ * Network naming the revision read (If-Match); a revision that moved is read
++ * again, up to 3 times. With expectedRevision (the browser's If-Match) it is
++ * strict instead: 412 when the record moved. A patch that changes nothing
++ * writes nothing.
++ * handleEvent(envelope) network.module.updated for this namespace newer than the cached copy drops it
++ * at once (the Events consumer, server/events/consumer.js); without that
++ * subscription the TTL bounds how long a change made outside Chat takes to show.
++ *
++ * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.
++ */
++'use strict';
++
++const { modules } = require('openvibe-contracts');
++const config = require('../config');
++const client = require('./network-modules');
++
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MAX_TRIES = 3;
++
++class PrefsError extends Error {
++ constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }
++}
++
++let clock = () => Date.now();
++function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }
++const stores = [];
++
++function createModuleStore(NAMESPACE, { label = NAMESPACE, key = 'preferences' } = {}) {
++ const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));
++ const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)
++
++ function remember(subject, rec) {
++ cache.delete(subject);
++ cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });
++ while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);
++ }
++
++ const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, [key]: { ...rec.data }, ...extra });
++
++ function unavailable(r) {
++ return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);
++ }
++
++ function checkSubject(subject) {
++ if (!enabled()) throw new PrefsError(503, 'prefs.disabled', `${label} are not configured on this server`);
++ if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');
++ }
++
++ /** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */
++ async function fetchRecord(subject) {
++ const r = await client.request('GET', NAMESPACE, subject);
++ if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {
++ return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
++ }
++ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };
++ if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', `Chat may not read ${NAMESPACE} (Network grant missing)`);
++ throw unavailable(r);
++ }
++
++ async function get(subject, { fresh = false } = {}) {
++ checkSubject(subject);
++ const hit = cache.get(subject);
++ if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);
++ try {
++ const rec = await fetchRecord(subject);
++ remember(subject, rec);
++ return view(rec);
++ } catch (err) {
++ if (hit && err.status === 503) return view(hit, { stale: true });
++ throw err;
++ }
++ }
++
++ /** A patch's problems: unknown fields, or a value the schema refuses once applied. */
++ function checkPatch(patch) {
++ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', `send { ${key}: { field: value } }`);
++ const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));
++ if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown field${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: `not in ${NAMESPACE}` })));
++ }
++
++ const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
++
++ async function update(subject, patch, { expectedRevision } = {}) {
++ checkSubject(subject);
++ checkPatch(patch);
++ for (let i = 0; i < MAX_TRIES; i++) {
++ const cur = await fetchRecord(subject);
++ if (expectedRevision !== undefined && cur.revision !== expectedRevision) {
++ remember(subject, cur);
++ throw new PrefsError(412, 'prefs.revision_conflict', `the ${label} changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);
++ }
++ const next = { ...cur.data };
++ for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }
++ const v = modules.validateData(NAMESPACE, next);
++ if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);
++ if (same(next, cur.data)) { remember(subject, cur); return view(cur); }
++ const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });
++ if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {
++ const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
++ remember(subject, rec);
++ return view(rec);
++ }
++ if (r.status === 412) {
++ if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', `the ${label} changed; read them again`);
++ continue; // someone else wrote first: read again
++ }
++ if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);
++ if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');
++ if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);
++ throw unavailable(r);
++ }
++ throw new PrefsError(409, 'prefs.busy', `the ${label} kept changing; try again`);
++ }
++
++ function handleEvent(envelope) {
++ if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;
++ const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};
++ const subject = p.owner && p.owner.id;
++ if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;
++ if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold
++ cache.delete(subject);
++ return true;
++ }
++
++ function invalidate(subject) { return cache.delete(subject); }
++ function peek(subject) { const hit = cache.get(subject); return hit ? { ...hit.data } : null; }
++
++ const store = { NAMESPACE, FIELDS, get, peek, update, handleEvent, invalidate, _clear: () => cache.clear(), _cached: (subject) => cache.get(subject) || null };
++ stores.push(store);
++ return store;
++}
++
++/** Tests: an injected clock; clears every store's cache. */
++function _configure({ now } = {}) { if (now) clock = now; for (const st of stores) st._clear(); }
++
++module.exports = { PrefsError, enabled, createModuleStore, _configure };
ddiff --git a/server/prefs/routes.js b/server/prefs/routes.js
iindex 7644b18..1840f37 100644
---- a/server/prefs/routes.js
++++ b/server/prefs/routes.js
@@@ -1,24 +1,27 @@
/**
-- * The signed-in person's chat preferences (Network user module chat.preferences, ./chat-preferences.js).
-- * Mounted at /api/chat/preferences (nginx already sends /api/chat/ to Chat).
++ * The signed-in person's chat settings, one router per Network user module Chat owns (./stores.js).
++ * Mounted under /api/chat/ (nginx already sends /api/chat/ to Chat):
*
-- * GET /api/chat/preferences → { namespace, version, revision, preferences, stale? }
-- * PUT /api/chat/preferences { preferences } change these fields (null removes one); optional
-- * If-Match: <revision> makes it fail with 412 if they moved
++ * /api/chat/preferences chat.preferences body key `preferences`
++ * /api/chat/tts-settings chat.tts_defaults body key `settings`
++ * /api/chat/dm-settings chat.dm_settings body key `settings`
++ * /api/chat/presence chat.presence_prefs body key `settings`
*
-- * Field names are the namespace's (timestamps, compact, font_scale, show_badges, hide_emotes). ETag is
-- * the revision. API tokens (hbt_) may read, never write: preferences are the person's own.
++ * GET → { namespace, version, revision, <key>, stale? }
++ * PUT { <key>: { field: value | null } } change these fields (null removes one); optional
++ * If-Match: <revision> makes it fail with 412 if they moved
++ *
++ * Field names are the namespace's. ETag is the revision. API tokens (hbt_) may read, never write:
++ * settings are the person's own.
*/
'use strict';
const express = require('express');
const { requireAuth } = require('../auth/auth');
--const prefs = require('./chat-preferences');
--
--const router = express.Router();
++const { PrefsError } = require('./module-store');
function fail(res, err) {
-- if (err instanceof prefs.PrefsError) {
++ if (err instanceof PrefsError) {
return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
}
console.error('[Prefs] unexpected error:', err && err.message);
@@@ -27,18 +30,30 @@ function fail(res, err) {
const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);
--router.get('/', requireAuth, async (req, res) => {
-- try { reply(res, await prefs.get(req.user.subject_id)); } catch (err) { fail(res, err); }
--});
++function routesFor(store, key) {
++ const router = express.Router();
++ router.get('/', requireAuth, async (req, res) => {
++ try { reply(res, await store.get(req.user.subject_id)); } catch (err) { fail(res, err); }
++ });
++ router.put('/', requireAuth, async (req, res) => {
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat settings', code: 'prefs.token_denied' });
++ let expectedRevision;
++ if (req.headers['if-match'] !== undefined) {
++ expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));
++ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });
++ }
++ try { reply(res, await store.update(req.user.subject_id, req.body && req.body[key], { expectedRevision })); } catch (err) { fail(res, err); }
++ });
++ return router;
++}
--router.put('/', requireAuth, async (req, res) => {
-- if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat preferences', code: 'prefs.token_denied' });
-- let expectedRevision;
-- if (req.headers['if-match'] !== undefined) {
-- expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));
-- if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });
-- }
-- try { reply(res, await prefs.update(req.user.subject_id, req.body && req.body.preferences, { expectedRevision })); } catch (err) { fail(res, err); }
--});
++const stores = require('./stores');
++/** { mount path under /api/chat → router } */
++const ROUTES = {
++ preferences: routesFor(stores.preferences, 'preferences'),
++ 'tts-settings': routesFor(stores.tts, 'settings'),
++ 'dm-settings': routesFor(stores.dm, 'settings'),
++ presence: routesFor(stores.presence, 'settings'),
++};
--module.exports = router;
++module.exports = { routesFor, ROUTES };
ddiff --git a/server/prefs/stores.js b/server/prefs/stores.js
nnew file mode 100644
iindex 0000000..e85a20f
---- /dev/null
++++ b/server/prefs/stores.js
@@@ -0,0 +1,32 @@
++/**
++ * The user modules Chat owns (openvibe-contracts 0.41.0), one store each (./module-store.js):
++ *
++ * preferences chat.preferences display (timestamps, compact, font_scale, show_badges, hide_emotes)
++ * tts chat.tts_defaults text-to-speech and sounds (send, send_while_live, volume, sounds,
++ * sound_volume, sources) — Live's chat panel reads and writes it
++ * dm chat.dm_settings new_conversations (everyone | nobody), group_invites, previews —
++ * enforced by the DM routes (server/chat/dm-routes.js)
++ * presence chat.presence_prefs show_in_user_list — enforced by ChatServer.getUserList()
++ */
++'use strict';
++
++const { createModuleStore } = require('./module-store');
++const preferences = require('./chat-preferences');
++
++const tts = createModuleStore('chat.tts_defaults', { label: 'text-to-speech settings', key: 'settings' });
++const dm = createModuleStore('chat.dm_settings', { label: 'message settings', key: 'settings' });
++const presence = createModuleStore('chat.presence_prefs', { label: 'presence settings', key: 'settings' });
++
++const all = [preferences, tts, dm, presence];
++const byNamespace = new Map(all.map((s) => [s.NAMESPACE, s]));
++
++/**
++ * A person's settings in one store for an enforcement check: the record (cached, else from Network), or
++ * {} when there is none or Network cannot answer — the defaults, so an outage never locks anyone out.
++ */
++async function settingsOf(store, subject) {
++ if (!subject) return {};
++ try { return (await store.get(subject))[store === preferences ? 'preferences' : 'settings'] || {}; } catch { return {}; }
++}
++
++module.exports = { preferences, tts, dm, presence, all, byNamespace, settingsOf };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex b6f187b..088de01 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -28,6 +28,7 @@ const session = require('../auth/network-session');
const historyStore = require('../chat/history-store');
const dm = require('../chat/dm');
const prefs = require('../prefs/chat-preferences');
++const prefStores = require('../prefs/stores');
const ctx = require('../live-context');
const permissions = require('../auth/permissions');
@@@ -230,10 +231,14 @@ ${composer}
})(req, res, next);
});
-- // ── Settings (chat.preferences, the same record Live's chat reads) ──
++ // ── Settings: the chat.* user modules (server/prefs/stores.js), the same records Live's chat reads ──
++ const TTS_SOURCES = [['native', 'OpenVibe chat'], ['robotstreamer', 'RobotStreamer'], ['kick', 'Kick'], ['youtube', 'YouTube'], ['twitch', 'Twitch']];
router.get('/settings', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
-- const p = await prefsOf(actor);
++ 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>`;
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 saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
@@@ -248,6 +253,19 @@ ${box('compact', 'Compact messages', p.compact === true)}
${box('show_badges', 'Show 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>
</form>`,
});
@@@ -263,8 +281,20 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
hide_emotes: b.hide_emotes === '1' ? true : null,
font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,
};
-- try { await prefs.update(actor.user.subject_id, patch); return res.redirect(303, '/settings?saved=1'); }
-- catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
++ const on = (k) => b[k] === '1';
++ const pct = (k) => { const n = Math.round(Number(b[k])); return Number.isFinite(n) && n >= 0 && n <= 100 && n !== 80 ? n : null; };
++ const sources = Object.fromEntries(TTS_SOURCES.filter(([k]) => !on(`tts_src_${k}`)).map(([k]) => [k, false]));
++ const sid = actor.user.subject_id;
++ try {
++ await prefs.update(sid, patch);
++ await prefStores.dm.update(sid, { new_conversations: b.new_conversations === 'nobody' ? 'nobody' : null, group_invites: on('group_invites') ? null : false, previews: on('previews') ? null : false });
++ await prefStores.presence.update(sid, { show_in_user_list: on('show_in_user_list') ? null : false });
++ await prefStores.tts.update(sid, {
++ send: on('tts_send') ? null : false, send_while_live: on('tts_send_while_live') ? null : false, volume: pct('tts_volume'),
++ sounds: on('tts_sounds') ? null : false, sound_volume: pct('tts_sound_volume'), sources: Object.keys(sources).length ? sources : null,
++ });
++ return res.redirect(303, '/settings?saved=1');
++ } catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
});
// ── Rooms (server/rooms/) ──
ddiff --git a/test/chat-modules.test.js b/test/chat-modules.test.js
nnew file mode 100644
iindex 0000000..44d07cc
---- /dev/null
++++ b/test/chat-modules.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * The chat.* user modules Chat owns since openvibe-contracts 0.41.0 (server/prefs/stores.js):
++ *
++ * - /api/chat/tts-settings, /api/chat/dm-settings, /api/chat/presence read and write chat.tts_defaults,
++ * chat.dm_settings and chat.presence_prefs on Network, like /api/chat/preferences
++ * - chat.dm_settings is enforced: "nobody" refuses a new direct conversation (an existing one stays
++ * open), group_invites false refuses a group and being added to one
++ * - chat.presence_prefs is enforced: someone hidden is counted in the user list, not named; a change
++ * made elsewhere (network.module.updated) refreshes the list
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++
++const t = suite('chat modules');
++const ANN = 'usr_01J9ANN0000000000000000AAA';
++const BOB = 'usr_01J9B0B0000000000000000AAA';
++const CAT = 'usr_01J9CAT0000000000000000AAA';
++let h, ann, bob, cat;
++
++t('boot with a stub Network that knows ann, bob and cat', async () => {
++ h = await boot();
++ ann = h.addUser('ann', { subject: ANN });
++ bob = h.addUser('bob', { subject: BOB });
++ cat = h.addUser('cat', { subject: CAT });
++ h.netModules.subjects.add(ANN).add(BOB).add(CAT);
++});
++
++t('each module has its route; the body key is settings', async () => {
++ for (const [path, ns] of [['tts-settings', 'chat.tts_defaults'], ['dm-settings', 'chat.dm_settings'], ['presence', 'chat.presence_prefs']]) {
++ const r = await h.http('GET', `/api/chat/${path}`, { token: ann.token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.namespace, ns);
++ assert.deepStrictEqual(r.body.settings, {});
++ }
++ let r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { volume: 40, sources: { kick: false } } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.settings, { volume: 40, sources: { kick: false } });
++ assert.strictEqual(r.body.version, 2, 'chat.tts_defaults v2');
++ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { volume: 400 } } });
++ assert.strictEqual(r.status, 422, 'the schema is enforced');
++ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { voice: 'x' } } });
++ assert.strictEqual(r.status, 422, 'v1 fields are gone');
++ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { preferences: { volume: 1 } } });
++ assert.strictEqual(r.status, 400, 'these routes take { settings }');
++});
++
++t('chat.dm_settings: nobody refuses a new direct conversation, an existing one stays open', async () => {
++ const existing = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(existing.status, 200, existing.text);
++ let r = await h.http('PUT', '/api/chat/dm-settings', { token: bob.token, body: { settings: { new_conversations: 'nobody', group_invites: false } } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await h.http('POST', '/api/dm/conversations', { token: cat.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.not_accepting');
++ r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(r.status, 200, 'the conversation they already have still opens');
++ assert.strictEqual(r.body.conversation.id, existing.body.conversation.id);
++ r = await h.http('POST', `/api/dm/conversations/${existing.body.conversation.id}/messages`, { token: ann.token, body: { message: 'still here' } });
++ assert.strictEqual(r.status, 200, r.text);
++});
++
++t('chat.dm_settings: group_invites false refuses a group and being added to one', async () => {
++ let r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id, cat.id], name: 'trio' } });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.no_group_invites');
++ const dave = h.addUser('dave', { subject: 'usr_01J9DAVE000000000000000AAA' });
++ h.netModules.subjects.add('usr_01J9DAVE000000000000000AAA');
++ r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [cat.id, dave.id], name: 'trio' } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await h.http('POST', `/api/dm/conversations/${r.body.conversation.id}/participants`, { token: ann.token, body: { user_id: bob.id } });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.no_group_invites');
++});
++
++t('chat.presence_prefs: someone hidden is counted, not named; a change elsewhere refreshes the list', async () => {
++ let r = await h.http('PUT', '/api/chat/presence', { token: cat.token, body: { settings: { show_in_user_list: false } } });
++ assert.strictEqual(r.status, 200, r.text);
++ const wsCat = await h.ws({ ip: '198.51.100.21', token: cat.token });
++ wsCat.sendJson({ type: 'join', token: cat.token });
++ await wsCat.next((m) => m.type === 'auth');
++ const wsAnn = await h.ws({ ip: '198.51.100.22', token: ann.token });
++ wsAnn.sendJson({ type: 'join', token: ann.token });
++ await wsAnn.next((m) => m.type === 'auth');
++ wsAnn.sendJson({ type: 'get-users' });
++ let list = (await wsAnn.next((m) => m.type === 'users-list')).users;
++ assert.ok(list.logged.some((u) => u.username === 'ann'));
++ assert.ok(!list.logged.some((u) => u.username === 'cat'), 'cat is not named');
++ assert.strictEqual(list.hiddenCount, 1, 'cat is counted');
++
++ // Cat turns it back on somewhere else (Network announces it): the list names them again.
++ const rec = h.netModules.records.get(`chat.presence_prefs|${CAT}`);
++ rec.data = { show_in_user_list: true }; rec.revision += 1;
++ const stores = require('../server/prefs/stores');
++ const chatServer = require('../server/chat/chat-server');
++ assert.ok(stores.presence.handleEvent({ event_type: 'network.module.updated', source: 'network', payload: { namespace: 'chat.presence_prefs', owner: { type: 'user', id: CAT }, revision: rec.revision } }));
++ chatServer.presenceChanged(CAT);
++ list = (await wsAnn.next((m) => m.type === 'users-list' && m.users.logged.some((u) => u.username === 'cat'), 4000)).users;
++ assert.strictEqual(list.hiddenCount, 0);
++ wsCat.close(); wsAnn.close();
++});
++
++t.run(async () => { if (h) await h.close(); });