openvibers/openvibe.chat Commits

Online now (WS-F task 2, ADR-005 amendment 1): GET /api/chat/online?users=a,b answers online or offline for up to 50 usernames from Chat's open connections (global chat, rooms, DMs); nothing stored, no Events topic; someone hidden by chat.presence_prefs, or whose preference cannot be read within a second, reads offline

OP OpenVibers committed 26 Sept 2026 · 0b68fb654e · 3 files, +75 −0


Changes

ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 88b5115..93638f3 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -634,6 +634,38 @@ class ChatServer {
return false;
}
++ /**
++ * Who of `usernames` is connected to Chat right now, anywhere (global chat, a room, a DM tab):
++ * { name: 'online' | 'offline' }. Presence is ephemeral and lives here, in Chat's delivery plane
++ * (ADR-005 amendment 1): nothing is stored and no Events topic carries it. Someone who turned off
++ * "show my name in user lists" (chat.presence_prefs) reads as offline, and so does anyone whose
++ * preference cannot be read within a second: never shown when they may have asked not to be.
++ */
++ async presenceOf(usernames) {
++ const connected = new Map();
++ for (const [, c] of this.clients) if (c.user && c.user.username) connected.set(String(c.user.username).toLowerCase(), c.user);
++ const { presence } = require('../prefs/stores');
++ const out = {};
++ for (const name of usernames) {
++ const user = connected.get(String(name).toLowerCase());
++ let state = 'offline';
++ if (user) {
++ const subject = this._subjectOfUser(user);
++ if (!subject) state = 'online';
++ else {
++ let prefs = presence.peek(subject);
++ if (!prefs) {
++ await Promise.race([presence.get(subject).catch(() => {}), new Promise((r) => setTimeout(r, 1000).unref())]);
++ prefs = presence.peek(subject);
++ }
++ if (prefs && prefs.show_in_user_list !== false) state = 'online';
++ }
++ }
++ out[name] = state;
++ }
++ return out;
++ }
++
/** Someone changed chat.presence_prefs elsewhere: read it again, then refresh the user lists they are in. */
presenceChanged(subject) {
if (!subject) return;
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex 756515e..8ab7c7c 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -637,6 +637,19 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
}
});
++// ── Online now (ADR-005 amendment 1: presence is ephemeral, in Chat's delivery plane) ──
++// GET /api/chat/online?users=ann,bob → { users: { ann: 'online', bob: 'offline' }, as_of }. Anyone may
++// ask about up to 50 usernames; people who hid themselves from user lists always read as offline.
++router.get('/online', async (req, res) => {
++ const names = [...new Set(String(req.query.users || '').split(',').map((n) => n.trim()).filter(Boolean))];
++ if (!names.length) return res.status(400).json({ error: 'users is required: a comma-separated list of usernames', code: 'presence.users_required' });
++ if (names.length > 50) return res.status(400).json({ error: 'at most 50 usernames at a time', code: 'presence.too_many' });
++ if (names.some((n) => !/^[A-Za-z0-9_.-]{1,40}$/.test(n))) return res.status(400).json({ error: 'invalid username', code: 'presence.invalid_username' });
++ const chatServer = require('./chat-server');
++ res.set('Cache-Control', 'no-store');
++ res.json({ users: await chatServer.presenceOf(names), as_of: new Date().toISOString() });
++});
++
// ── Chat User Count ──────────────────────────────────────────
router.get('/:streamId/users', (req, res) => {
const chatServer = require('./chat-server');
ddiff --git a/test/chat-modules.test.js b/test/chat-modules.test.js
iindex 44d07cc..04ac303 100644
---- a/test/chat-modules.test.js
++++ b/test/chat-modules.test.js
@@@ -97,4 +97,34 @@ t('chat.presence_prefs: someone hidden is counted, not named; a change elsewhere
wsCat.close(); wsAnn.close();
});
++t('online now (ADR-005 amendment 1): connected people read online, hidden ones and unknown preferences offline', 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 wsAnn = await h.ws({ ip: '198.51.100.31', token: ann.token });
++ wsAnn.sendJson({ type: 'join', token: ann.token });
++ await wsAnn.next((m) => m.type === 'auth');
++ const wsCat = await h.ws({ ip: '198.51.100.32', token: cat.token });
++ wsCat.sendJson({ type: 'join', token: cat.token });
++ await wsCat.next((m) => m.type === 'auth');
++ r = await h.http('GET', '/api/chat/online?users=ann,cat,bob,ann');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.users, { ann: 'online', cat: 'offline', bob: 'offline' }, 'cat is connected but hidden; bob is not connected');
++ assert.match(r.headers.get('cache-control'), /no-store/);
++ // A connected person whose preference cannot be read (Network down, nothing cached) reads offline.
++ const eve = h.addUser('eve', { subject: 'usr_01J9EVE0000000000000000AAA' });
++ h.netModules.subjects.add('usr_01J9EVE0000000000000000AAA');
++ h.netModules.down = true;
++ const wsEve = await h.ws({ ip: '198.51.100.33', token: eve.token });
++ wsEve.sendJson({ type: 'join', token: eve.token });
++ await wsEve.next((m) => m.type === 'auth');
++ r = await h.http('GET', '/api/chat/online?users=eve');
++ assert.deepStrictEqual(r.body.users, { eve: 'offline' }, 'never shown when their preference is unknown');
++ h.netModules.down = false;
++ // Input limits.
++ assert.strictEqual((await h.http('GET', '/api/chat/online')).status, 400);
++ assert.strictEqual((await h.http('GET', `/api/chat/online?users=${Array.from({ length: 51 }, (_, i) => `u${i}`).join(',')}`)).status, 400);
++ assert.strictEqual((await h.http('GET', '/api/chat/online?users=bad%20name')).status, 400);
++ wsAnn.close(); wsCat.close(); wsEve.close();
++});
++
t.run(async () => { if (h) await h.close(); });