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
Changes
@@@ -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;@@@ -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');@@@ -97,4 +97,34 @@ t('chat.presence_prefs: someone hidden is counted, not named; a change elsewherewsCat.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(); });
@@ -634,6 +634,38 @@ class ChatServer {
@@ -634,6 +634,38 @@ class ChatServer {
return false;
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. */
/** Someone changed chat.presence_prefs elsewhere: read it again, then refresh the user lists they are in. */
presenceChanged(subject) {
presenceChanged(subject) {
if (!subject) return;
if (!subject) return;
@@ -637,6 +637,19 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
@@ -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 ──────────────────────────────────────────
// ── Chat User Count ──────────────────────────────────────────
router.get('/:streamId/users', (req, res) => {
router.get('/:streamId/users', (req, res) => {
const chatServer = require('./chat-server');
const chatServer = require('./chat-server');
@@ -97,4 +97,34 @@ t('chat.presence_prefs: someone hidden is counted, not named; a change elsewhere
@@ -97,4 +97,34 @@ t('chat.presence_prefs: someone hidden is counted, not named; a change elsewhere
wsCat.close(); wsAnn.close();
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(); });
t.run(async () => { if (h) await h.close(); });