openvibers/openvibe.chat Commits

Presence: draw the dot on DMs, headers and member lists (#380)

OP OpenVibers committed 6 days ago · 3c95aab121 · 15 files, +293 −38

The DM list, a 1:1 DM header and room member lists now carry Network's presence as the same dot openvibe-shared draws on your own picture: 11 px (14 px in the DM header), a 2.5 px surface ring, online solid, away a crescent, dnd a bar, live solid red; offline and invisible draw nothing. The DM header gets one muted state line (with a Live link when the payload has one), member lists put online/live first and show "N online", and avatars carry the state in their accessible name. The DM header refreshes while visible; the inbox's existing 15 s poll already carries presence. The WS users-list is sorted the same way and returns its online count. openvibe-shared 4.131.0 (was 4.129.0).

Changes

ddiff --git a/README.md b/README.md
iindex e0b7ed6..0674240 100644
---- a/README.md
++++ b/README.md
@@@ -970,5 +970,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.315.0
- openvibe-sdk: v0.94.0
--- openvibe-shared: v4.129.0
++- openvibe-shared: v4.131.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 6e6a6f6..fa3493f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.315.0",
"sdk": "openvibe-sdk v0.94.0",
-- "shared": "openvibe-shared v4.129.0",
++ "shared": "openvibe-shared v4.131.0",
"capabilities": [
"chat.message.send",
"chat.event.publish",
ddiff --git a/package-lock.json b/package-lock.json
iindex 4f9d49a..edd765d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.131.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
@@@ -1601,9 +1601,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.129.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
-- "integrity": "sha512-wkBHrcR5C2mc+tmnIE4UVQaLztVNZtmg3xq1DQpaK1lqx3QshiVpBBAWkTvsaBMD5wOHxrXwfZ69pK6zCoFofQ==",
++ "version": "4.131.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.131.0",
++ "integrity": "sha512-b6mvqjGf/FBu1cSVay3krqy4wZgn2IaoRTTROBRXN135yXm2qEl4KzWtELqBhDZ0hjUyAryOicb/cIPNwY+yGw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex d8e086a..63b4056 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.131.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 97a5d99..3588a92 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -522,7 +522,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-unread .oc-row-sub { color: var(--oc-text); }
.oc-dot { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); font-size: .72rem; font-weight: 800; }
.oc-row-empty { padding: 16px 14px; }
--.oc-av { flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; font-size: .85rem; font-weight: 800; letter-spacing: .02em; color: #fff; background: hsl(var(--av, 210) 45% 38%); }
++.oc-av { flex: none; position: relative; display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; font-size: .85rem; font-weight: 800; letter-spacing: .02em; color: #fff; background: hsl(var(--av, 210) 45% 38%); }
++/* The Network presence dot: openvibe-shared/presence.js .ovp-dot's look (11 px, 2.5 px surface ring, bottom-right;
++ 14 px beside a page header). Offline and invisible draw nothing. */
++.oc-pdot { position: absolute; right: -1px; bottom: -1px; width: 11px; height: 11px; border-radius: 50%; box-sizing: border-box; pointer-events: none; background: var(--ovp, #22c55e); border: 2.5px solid var(--oc-pdot-ring, var(--oc-panel)); }
++.oc-pdot[data-state="dnd"]::after { content: ""; position: absolute; left: 1.5px; right: 1.5px; top: 50%; height: 1.5px; margin-top: -.75px; border-radius: 2px; background: var(--oc-pdot-ring, var(--oc-panel)); }
++.oc-pdot[data-state="away"] { background: radial-gradient(circle at 70% 30%, var(--oc-pdot-ring, var(--oc-panel)) 0 2.2px, var(--ovp) 2.6px); }
++.oc-av-lg .oc-pdot { width: 14px; height: 14px; }
++.oc-page-head .oc-av { --oc-pdot-ring: var(--oc-bg); }
++.oc-members-online { margin: 0 0 6px; font-size: .86rem; }
.oc-av-feed { background: var(--oc-panel-2); color: var(--oc-muted); border: 1px solid var(--oc-line); }
.oc-av-feed.is-on { color: var(--oc-ok); border-color: color-mix(in srgb, var(--oc-ok) 45%, transparent); }
.oc-find { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 4px 4px 4px 14px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel); }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex d17285e..8c49d78 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -23,6 +23,38 @@
function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
++ // The presence dot (openvibe-shared/presence.js .ovp-dot's look). The avatar's accessible name carries the state;
++ // the dot is aria-hidden. Offline and invisible have no dot at all.
++ var PRESENCE = {
++ online: { label: 'Online', a11y: 'online', color: '#22c55e' },
++ away: { label: 'Away', a11y: 'away', color: '#f59e0b' },
++ dnd: { label: 'Do not disturb', a11y: 'do not disturb', color: '#ef4444' },
++ live: { label: 'Live now', a11y: 'live', color: '#ef4444' }
++ };
++ function presenceState(p) { if (!p) return null; var s = p.live ? 'live' : String(p.state || ''); return PRESENCE[s] ? s : null; }
++ function presenceLine(p) { var s = presenceState(p); return s ? PRESENCE[s].label + (p.status_text ? ' \u00b7 ' + String(p.status_text).slice(0, 80) : '') : ''; }
++ function presenceHref(p) {
++ var href = p && (p.channel_url || p.url);
++ return typeof href === 'string' && (/^https:\/\//i.test(href) || /^\/(?!\/)/.test(href)) ? href : null;
++ }
++ function setPresenceLine(box, p, username) {
++ if (!box) return;
++ var s = presenceState(p);
++ if (!s) { box.textContent = username ? '@' + username : ''; return; }
++ var href = s === 'live' ? presenceHref(p) : null;
++ if (!href) { box.textContent = presenceLine(p); return; }
++ box.textContent = '';
++ var a = el('a', null, PRESENCE[s].label); a.href = href; box.appendChild(a);
++ if (p.status_text) box.appendChild(document.createTextNode(' \u00b7 ' + String(p.status_text).slice(0, 80)));
++ }
++ function paintPresence(av, name, p) {
++ if (!av) return;
++ var old = av.querySelector('.oc-pdot'); if (old) old.remove();
++ var s = presenceState(p);
++ if (!s) { av.removeAttribute('role'); av.removeAttribute('aria-label'); av.setAttribute('aria-hidden', 'true'); return; }
++ av.removeAttribute('aria-hidden'); av.setAttribute('role', 'img'); av.setAttribute('aria-label', String(name || '?') + ', ' + PRESENCE[s].a11y);
++ var dot = el('span', 'oc-pdot'); dot.dataset.state = s; dot.style.setProperty('--ovp', PRESENCE[s].color); dot.setAttribute('aria-hidden', 'true'); av.appendChild(dot);
++ }
function clock(ts) {
var d = new Date(ts && String(ts).indexOf('T') < 0 ? String(ts).replace(' ', 'T') + 'Z' : ts);
var t = el('time', 'oc-time', isNaN(d) ? '' : d.toISOString().slice(11, 16));
@@@ -212,6 +244,25 @@
}
setInterval(poll, 4000);
document.addEventListener('visibilitychange', poll);
++ // The header's dot and state line refresh every 60 s while the tab is visible (Network's read is cached 30 s).
++ if (cfg.other) {
++ var refreshPresence = function () {
++ if (document.hidden) return;
++ fetch(base, { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) {
++ var others = d && d.conversation && Array.isArray(d.conversation.participants) ? d.conversation.participants.filter(function (p) { return p.id !== cfg.me; }) : [];
++ var p = others.length === 1 ? others[0] : null;
++ if (!p) return;
++ var name = p.display_name || p.username || '?';
++ paintPresence(document.getElementById('oc-dm-avatar'), name, p.presence);
++ var status = document.getElementById('oc-dm-status');
++ setPresenceLine(status, p.presence, p.username);
++ }).catch(function () {});
++ };
++ setInterval(refreshPresence, 60000);
++ document.addEventListener('visibilitychange', function () { if (!document.hidden) refreshPresence(); });
++ }
if (form && input) {
form.addEventListener('submit', function (e) {
var text = input.value.trim();
@@@ -383,12 +434,12 @@
return d.toISOString().slice(0, 10);
}
// A person's initials in a round mark, its colour from the name (server/web/ui.js initials()).
-- function initialsMark(name) {
++ function initialsMark(name, personPresence) {
var words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
var letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
var h = 0;
for (var ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;
-- var m = el('span', 'oc-av', letters); m.style.setProperty('--av', String(h)); m.setAttribute('aria-hidden', 'true');
++ var m = el('span', 'oc-av', letters); m.style.setProperty('--av', String(h)); paintPresence(m, name, personPresence);
return m;
}
function convItem(c, me) {
@@@ -397,7 +448,8 @@
var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
var others = (c.participants || []).filter(function (p) { return p.id !== me; });
var title = c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you';
-- a.appendChild(initialsMark(title));
++ var person = others.length === 1 ? others[0] : null;
++ a.appendChild(initialsMark(title, person && person.presence));
var main = el('span', 'oc-row-main'), top = el('span', 'oc-row-top'), sub = el('span', 'oc-row-sub', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet');
top.appendChild(el('strong', null, title));
if (c.last_message_at) {
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 0a0a664..d5d20ff 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -730,7 +730,8 @@ class ChatServer {
/**
* Build a deduplicated list of users in a given stream (or global if null).
-- * Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }
++ * Returns { logged: [{username, display_name, avatar_url, role, presence}], anonCount: N,
++ * hiddenCount: N, online: N } (online counts the named people who are online or live).
*/
async getUserList(streamId, viewerSubject = null) {
const seen = new Set();
@@@ -758,14 +759,19 @@ class ChatServer {
anonCount++;
}
}
-- // 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));
// The Network answers each viewer separately (a stranger's state is never disclosed), so a member
// list is built for the person who asked; the read is cached 30 s per viewer and member set.
const seenPresence = await presence.forViewer(viewerSubject, [...subjects.values()]);
for (const u of logged) u.presence = seenPresence.get(subjects.get(u.username)) || null;
-- return { logged, anonCount, hiddenCount };
++ // Online and live first, then away/dnd, then everyone else; roles and names keep the order inside a group.
++ const stateRank = (u) => {
++ const state = u.presence ? (u.presence.live ? 'live' : u.presence.state) : null;
++ return state === 'online' || state === 'live' ? 0 : state === 'away' || state === 'dnd' ? 1 : 2;
++ };
++ const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };
++ logged.sort((a, b) => stateRank(a) - stateRank(b) || (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));
++ const online = logged.filter((u) => stateRank(u) === 0).length;
++ return { logged, anonCount, hiddenCount, online };
}
/**
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 8d25cf0..c87bd8e 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -458,16 +458,16 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
}
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
--async function members(room, { seen = false } = {}) {
++async function members(room, { seen = false, subjects = false } = {}) {
// A server's channel: the server's people, with the role each has in the channel.
if (room.server_id) {
const server = await require('./servers').byId(room.server_id);
if (!server) return [];
const as = (r) => (r === 'owner' ? 'owner' : r === 'admin' || r === 'mod' ? 'mod' : kindOf(room) === 'call' ? joinRoleOf(room) : 'member');
-- return (await require('./servers').members(server)).map((m) => ({ user_id: m.user_id, role: as(m.role), server_role: m.role, username: m.username, display_name: m.display_name }));
++ return (await require('./servers').members(server, { subjects })).map((m) => ({ user_id: m.user_id, subject_id: m.subject_id, role: as(m.role), server_role: m.role, username: m.username, display_name: m.display_name }));
}
const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
-- return await db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
++ return await db.all(`SELECT m.user_id${subjects ? ', u.subject_id' : ''}, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
WHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);
}
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex 1b816c5..55d6742 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -301,9 +301,9 @@ async function setRole(server, actor, targetUserId, role) {
}
/** The server's people by rank (blocked last, only for moderators). */
--async function members(server, { blocked = false, limit = 1000 } = {}) {
++async function members(server, { blocked = false, limit = 1000, subjects = false } = {}) {
const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
-- return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m
++ return await db.all(`SELECT m.user_id${subjects ? ', u.subject_id' : ''}, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m
LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex e0b21da..157af8c 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -49,6 +49,7 @@ const prefs = require('../prefs/chat-preferences');
const prefStores = require('../prefs/stores');
const ctx = require('../live-context');
const permissions = require('../auth/permissions');
++const presence = require('../presence');
const db = require('../db/database');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
@@@ -347,20 +348,25 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
const ui = require('./ui');
const { ICONS: IC } = require('./icons');
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
-- const convRow = ({ href, title, preview, at, unread = 0, badge = '' }) => {
++ const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null }) => {
const t = ui.when(at);
-- return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`
++ return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title, personPresence)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`
+ `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;
};
router.get('/messages', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const convs = await dm.getConversations(me.id);
-- const rows = (await Promise.all(convs.map(async (c) => {
-- const others = (await dm.getParticipants(c.id)).filter((p) => p.id !== me.id);
++ // One Network read for every person in the list (cached 30 s, as the viewer). A group's mark is the
++ // conversation's, not a person's, so only a one-to-one chat takes a dot.
++ const participants = await Promise.all(convs.map((c) => dm.getParticipants(c.id)));
++ await presence.attachTo(me.subject_id, participants.flat());
++ const rows = convs.map((c, i) => {
++ const others = participants[i].filter((p) => p.id !== me.id);
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
-- return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0 });
-- })));
++ const personPresence = others.length === 1 ? others[0].presence : null;
++ return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0, personPresence });
++ });
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
const blocked = await dm.getBlockedUsers(me.id);
@@@ -409,6 +415,7 @@ ${blockedList}`,
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return await page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);
++ await presence.attachTo(me.subject_id, others);
const conv = await dm.getConversation(id);
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);
@@@ -419,6 +426,12 @@ ${blockedList}`,
const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;
const closed = other ? await dm.isBlockedEither(me.id, other.id) : false;
const otherName = other ? esc(other.display_name || other.username) : '';
++ // A 1:1 header: their picture with their dot, and one muted line with their state. Offline is the
++ // absence of a state: the line falls back to @username, never to "last seen". Groups keep the count.
++ const otherPresence = other ? other.presence : null;
++ const liveHref = otherPresence && (otherPresence.channel_url || otherPresence.url);
++ const subLine = other ? (ui.presenceLine(otherPresence, { liveHref }) || `@${esc(other.username || '')}`) : `${others.length + 1} people`;
++ const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar' }) : '';
let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
else if (closed) composer = notice('error', 'You cannot message this person.');
@@@ -429,8 +442,8 @@ ${blockedList}`,
? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}</button></form>` : '';
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
-- page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
-- body: `${ui.head({ title: esc(title), sub: other ? `@${esc(other.username || '')}` : `${others.length + 1} people`, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}
++ page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },
++ body: `${ui.head({ title: esc(title), sub: `<span id="oc-dm-status">${subLine}</span>`, avatar, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}
<ol class="oc-feed oc-room-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
${composer}`,
});
@@@ -960,7 +973,11 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
-- const members = await rooms.members(room, { seen: true });
++ const members = await rooms.members(room, { seen: true, subjects: true });
++ await presence.attachTo(actor.user.subject_id, members);
++ // Online and live first, then away/dnd, then everyone else; the stable sort keeps roles in order inside a group.
++ members.sort((a, b) => ui.presenceOrder(a.presence) - ui.presenceOrder(b.presence));
++ const online = members.filter((m) => ui.presenceOrder(m.presence) === 0).length;
const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;
@@@ -968,7 +985,7 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted oc-role">owner</span>'
: (m.role === 'mod' && !can.manage) ? '<span class="oc-muted oc-role">moderator</span>'
: `<form class="oc-role-form" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select><button type="submit" class="oc-icon-btn" aria-label="Set the role of ${esc(m.username || '')}" title="Set">${IC.check}</button></form>`;
-- const memberRow = (m) => `<li class="oc-row oc-row-static">${ui.initials(m.display_name || m.username || '?')}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(m.display_name || m.username || '?')}</strong></span><span class="oc-row-sub">@${esc(m.username || '')}${seen(m)}</span></span>${roleForm(m)}</li>`;
++ const memberRow = (m) => `<li class="oc-row oc-row-static">${ui.initials(m.display_name || m.username || '?', m.presence)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(m.display_name || m.username || '?')}</strong></span><span class="oc-row-sub">@${esc(m.username || '')}${seen(m)}</span></span>${roleForm(m)}</li>`;
const addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
@@@ -1026,6 +1043,7 @@ ${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type=
<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2>
<p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p>
<form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>
++<p class="oc-muted oc-members-online">${online} online</p>
<ul class="oc-list oc-list-flush">${members.map(memberRow).join('')}</ul>
</section>
${hookSection}
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex 0b866de..cb7faf0 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -4,18 +4,19 @@
* alignment's kind of bad … not a focus on the real content"): one compact page header with its actions as icons, times
* people read at a glance, and a person's initials when there is no picture.
*
-- * head({ title, sub, back, actions }) <header class="oc-page-head">: back icon, title and one muted line, icon actions
++ * head({ title, sub, back, actions, avatar }) <header class="oc-page-head">: back icon, avatar, title and one muted line, icon actions
* when(ts, now) { iso, text }: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20
-- * initials(name) a round mark with up to two letters, its colour from the name
++ * initials(name, presence) a round mark with up to two letters, its colour from the name, and the
++ * Network presence dot (Shared's .ovp-dot look) when there is a visible state
* hueOf(slug) / serverMark(server) a server's steady hue, and its rounded-square mark in a list row (the same
* colour as in the server rail, wherever the server is listed)
*/
const { esc } = require('./layout');
const { ICONS } = require('./icons');
--function head({ title, sub = '', back = null, actions = [] }) {
++function head({ title, sub = '', back = null, actions = [], avatar = '' }) {
return `<header class="oc-page-head">
--${back ? `<a class="oc-icon-btn oc-back" href="${esc(back.href)}" aria-label="${esc(back.label)}" title="${esc(back.label)}">${ICONS.back}</a>` : ''}<div class="oc-page-id"><h1>${title}</h1>${sub ? `<p class="oc-page-sub">${sub}</p>` : ''}</div>
++${back ? `<a class="oc-icon-btn oc-back" href="${esc(back.href)}" aria-label="${esc(back.label)}" title="${esc(back.label)}">${ICONS.back}</a>` : ''}${avatar}<div class="oc-page-id"><h1>${title}</h1>${sub ? `<p class="oc-page-sub">${sub}</p>` : ''}</div>
${actions.length ? `<div class="oc-page-tools">${actions.join('')}</div>` : ''}
</header>`;
}
@@@ -38,16 +39,57 @@ function when(ts, now = Date.now()) {
return { iso: d.toISOString(), text };
}
--function initials(name) {
++// ── Presence (network.presence@1) ──
++// The same look as openvibe-shared/presence.js .ovp-dot: 11 px (14 px in a page header), bottom-right, a 2.5 px
++// ring in the surface behind it; online solid, away a crescent, dnd a bar, live solid red. Offline and invisible
++// are the absence of a dot: never a grey one on someone else's picture.
++const PRESENCE = {
++ online: { label: 'Online', a11y: 'online', color: '#22c55e' },
++ away: { label: 'Away', a11y: 'away', color: '#f59e0b' },
++ dnd: { label: 'Do not disturb', a11y: 'do not disturb', color: '#ef4444' },
++ live: { label: 'Live now', a11y: 'live', color: '#ef4444' },
++};
++/** The state the dot draws, or null when the person is offline/invisible/unknown. */
++function presenceState(presence) {
++ if (!presence) return null;
++ const state = presence.live ? 'live' : String(presence.state || '');
++ return PRESENCE[state] ? state : null;
++}
++/** The dot itself (aria-hidden; the avatar carries the accessible state). */
++function presenceDot(presence) {
++ const state = presenceState(presence);
++ if (!state) return '';
++ return `<span class="oc-pdot" data-state="${state}" style="--ovp:${PRESENCE[state].color}" aria-hidden="true"></span>`;
++}
++/** A member list's order: online and live first, then away/dnd, then everyone else. */
++function presenceOrder(presence) {
++ const state = presenceState(presence);
++ return state === 'online' || state === 'live' ? 0 : state === 'away' || state === 'dnd' ? 1 : 2;
++}
++/** "Online", "Live now" and — when there is one — " · their status line". Nothing for offline. */
++function presenceLine(presence, { liveHref = null } = {}) {
++ const state = presenceState(presence);
++ if (!state) return '';
++ const label = esc(PRESENCE[state].label);
++ const href = typeof liveHref === 'string' && (/^https:\/\//i.test(liveHref) || /^\/(?!\/)/.test(liveHref)) ? liveHref : null;
++ const shown = state === 'live' && href ? `<a href="${esc(href)}">${label}</a>` : label;
++ return `${shown}${presence.status_text ? ` · ${esc(String(presence.status_text).slice(0, 80))}` : ''}`;
++}
++
++function initials(name, presence = null, { large = false, id = '' } = {}) {
const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
let h = 0;
for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;
-- return `<span class="oc-av" style="--av:${h}" aria-hidden="true">${esc(letters)}</span>`;
++ const cls = `oc-av${large ? ' oc-av-lg' : ''}`;
++ const idAttr = id ? ` id="${esc(id)}"` : '';
++ const state = presenceState(presence);
++ if (!state) return `<span class="${cls}" style="--av:${h}"${idAttr} aria-hidden="true">${esc(letters)}</span>`;
++ return `<span class="${cls}" style="--av:${h}"${idAttr} role="img" aria-label="${esc(`${String(name || '?')}, ${PRESENCE[state].a11y}`)}">${esc(letters)}${presenceDot(presence)}</span>`;
}
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
--module.exports = { head, when, initials, hueOf, serverLetters, serverMark };
++module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark };
ddiff --git a/test/dm-inbox.test.js b/test/dm-inbox.test.js
iindex a4f6d77..d15e58a 100644
---- a/test/dm-inbox.test.js
++++ b/test/dm-inbox.test.js
@@@ -21,6 +21,7 @@ const req = async (method, path, { headers = {}, raw } = {}) => {
};
const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });
const dmApi = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });
++const view = (subject, state, extra = {}) => ({ subject, state, status_text: extra.status_text || null, emoji: null, live: !!extra.live, updated_at: '2026-11-07T09:00:00.000Z' });
const navLabel = (page, key) => {
const cfg = JSON.parse(page.match(/window\.__OV_PAGE = (.*);\n/)[1]);
return cfg.navbar.links.find((l) => l.href === key).label;
@@@ -115,6 +116,40 @@ t('block: the conversation closes both ways; the inbox lists who you blocked; un
assert.match((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).text, /back again/);
});
++t('presence: a dot on the DM picture, the state under the name, and no dot when there is nothing to show', async () => {
++ const presence = require('../server/presence');
++ h.netPresence.rows.set(bob.subject_id, view(bob.subject_id, 'online', { status_text: 'Around' }));
++ presence.clearCache();
++ let inbox = await req('GET', '/messages', { headers: cookie(alice) });
++ assert.match(inbox.text, /aria-label="Bob, online"/);
++ assert.match(inbox.text, /<span class="oc-pdot" data-state="online"/);
++ let page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
++ assert.match(page.text, /id="oc-dm-avatar"[^>]*aria-label="Bob, online"/);
++ assert.match(page.text, /id="oc-dm-status">Online · Around</);
++
++ const cases = [['away', 'Away', false], ['dnd', 'Do not disturb', false], ['online', 'Live now', true]];
++ for (const [state, label, live] of cases) {
++ h.netPresence.rows.set(bob.subject_id, view(bob.subject_id, state, { live }));
++ presence.clearCache();
++ inbox = await req('GET', '/messages', { headers: cookie(alice) });
++ assert.match(inbox.text, new RegExp(`<span class="oc-pdot" data-state="${live ? 'live' : state}"`));
++ page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
++ assert.match(page.text, new RegExp(`id="oc-dm-status">${label}<`));
++ }
++
++ // Offline and invisible draw no dot at all, and the line falls back to the username (never "last seen").
++ for (const state of ['offline', 'invisible']) {
++ h.netPresence.rows.set(bob.subject_id, view(bob.subject_id, state));
++ presence.clearCache();
++ inbox = await req('GET', '/messages', { headers: cookie(alice) });
++ assert.doesNotMatch(inbox.text, /class="oc-pdot"/);
++ page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
++ assert.doesNotMatch(page.text, /class="oc-pdot"/);
++ assert.match(page.text, /id="oc-dm-status">@bob</);
++ assert.doesNotMatch(page.text, /last seen/i);
++ }
++});
++
t('groups have no one to block', async () => {
const r = await dmApi('POST', '/conversations', alice, { user_ids: [bob.id, carol.id], name: 'Trio' });
assert.strictEqual(r.status, 200, r.text);
ddiff --git a/test/presence.test.js b/test/presence.test.js
iindex 3659efc..36551f5 100644
---- a/test/presence.test.js
++++ b/test/presence.test.js
@@@ -97,6 +97,8 @@ t('room member lists carry presence, built for the viewer', async () => {
const listed = list.users.logged.find((u) => u.username === 'bob');
assert.strictEqual(listed.presence.state, 'away');
assert.strictEqual(listed.presence.live, true);
++ assert.strictEqual(list.users.online, 1, 'live counts as online');
++ assert.strictEqual(list.users.logged[0].username, 'bob', 'online and live sort first');
assert.strictEqual(h.netPresence.calls.at(-1).for, ANN, 'the list was built for Ann');
} finally { a.close(); b.close(); }
});
ddiff --git a/test/ui.test.js b/test/ui.test.js
nnew file mode 100644
iindex 0000000..2ce3ee6
---- /dev/null
++++ b/test/ui.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * server/web/ui.js draws the Network presence dot (network.presence@1) the same way openvibe-shared's
++ * .ovp-dot does: an 11 px dot (14 px in a page header), the state in the avatar's accessible name, and
++ * no dot at all for offline or invisible. The server-rendered lists and the client (public/web/chat.js)
++ * draw the same markup, so this is the one place the look and the names are pinned.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const ui = require('../server/web/ui');
++const { suite } = require('./helpers');
++
++const t = suite('ui presence');
++
++t('online, away, dnd and live draw the dot; offline and invisible draw nothing', () => {
++ const cases = [
++ ['online', { state: 'online' }, 'online', '#22c55e'],
++ ['away', { state: 'away' }, 'away', '#f59e0b'],
++ ['dnd', { state: 'dnd' }, 'do not disturb', '#ef4444'],
++ ['live', { state: 'online', live: true }, 'live', '#ef4444'],
++ ];
++ for (const [state, presence, name, color] of cases) {
++ const html = ui.initials('Alice', presence);
++ assert.match(html, new RegExp(`role="img" aria-label="Alice, ${name}"`), state);
++ assert.match(html, new RegExp(`<span class="oc-pdot" data-state="${state}" style="--ovp:${color}" aria-hidden="true"></span>`), state);
++ assert.match(html, /aria-hidden="true"><\/span></, 'the dot is hidden from assistive technology');
++ }
++ for (const presence of [null, { state: 'offline' }, { state: 'invisible' }]) {
++ const html = ui.initials('Alice', presence);
++ assert.doesNotMatch(html, /oc-pdot/);
++ assert.doesNotMatch(html, /role="img"/);
++ assert.match(html, /aria-hidden="true">AL<\/span>/, 'a plain picture is decorative');
++ }
++});
++
++t('the state line and the member-list order', () => {
++ assert.strictEqual(ui.presenceLine({ state: 'online' }), 'Online');
++ assert.strictEqual(ui.presenceLine({ state: 'online', status_text: 'Around' }), 'Online · Around');
++ assert.strictEqual(ui.presenceLine({ state: 'away' }), 'Away');
++ assert.strictEqual(ui.presenceLine({ state: 'dnd' }), 'Do not disturb');
++ assert.strictEqual(ui.presenceLine({ state: 'online', live: true }), 'Live now');
++ assert.strictEqual(ui.presenceLine({ state: 'online', live: true }, { liveHref: 'https://openvibe.live/@alice' }), '<a href="https://openvibe.live/@alice">Live now</a>');
++ assert.strictEqual(ui.presenceLine({ state: 'online' }, { liveHref: 'javascript:alert(1)' }), 'Online');
++ assert.strictEqual(ui.presenceLine({ state: 'offline' }), '');
++ assert.strictEqual(ui.presenceLine({ state: 'invisible' }), '');
++ assert.deepStrictEqual(
++ [{ state: 'online' }, { state: 'away' }, { state: 'offline' }, null, { state: 'online', live: true }].map(ui.presenceOrder),
++ [0, 1, 2, 2, 0],
++ );
++});
++
++t('the dot CSS copies Shared\'s .ovp-dot', () => {
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'chat.css'), 'utf8');
++ for (const rule of [
++ '.oc-pdot { position: absolute; right: -1px; bottom: -1px; width: 11px; height: 11px; border-radius: 50%; box-sizing: border-box;',
++ 'border: 2.5px solid var(--oc-pdot-ring, var(--oc-panel));',
++ '.oc-pdot[data-state="dnd"]::after',
++ '.oc-pdot[data-state="away"] { background: radial-gradient(circle at 70% 30%, var(--oc-pdot-ring, var(--oc-panel)) 0 2.2px, var(--ovp) 2.6px); }',
++ '.oc-av-lg .oc-pdot { width: 14px; height: 14px; }',
++ ]) assert.ok(css.includes(rule), rule);
++});
++
++t('a page header takes the avatar beside its title', () => {
++ const avatar = ui.initials('Alice', { state: 'online' }, { large: true, id: 'oc-dm-avatar' });
++ const html = ui.head({ title: 'Alice', sub: '<span id="oc-dm-status">Online</span>', avatar });
++ assert.match(html, /<span class="oc-av oc-av-lg"[^>]*id="oc-dm-avatar"[^>]*role="img" aria-label="Alice, online">/);
++ assert.ok(html.indexOf('id="oc-dm-avatar"') < html.indexOf('class="oc-page-id"'), 'the picture comes before the title');
++ assert.match(html, /<p class="oc-page-sub"><span id="oc-dm-status">Online<\/span><\/p>/);
++});
ddiff --git a/test/web.test.js b/test/web.test.js
iindex a0abda3..5e95a74 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -19,6 +19,7 @@ const req = async (method, path, { headers = {}, raw } = {}) => {
return { status: r.status, headers: r.headers, text: await r.text() };
};
const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });
++const view = (subject, state, extra = {}) => ({ subject, state, status_text: extra.status_text || null, emoji: null, live: !!extra.live, updated_at: '2026-11-07T09:00:00.000Z' });
t('boot', async () => {
h = await boot({ env: { CHAT_WEB_URL: SITE } });
@@@ -173,6 +174,27 @@ t('rooms: start one, post and join without JavaScript, private rooms stay privat
assert.ok(sitemap.includes('/r/coffee-talk') && !sitemap.includes('inner-circle'), 'public rooms only');
});
++t('a room member list draws presence: online first, then away, a count line and no dot for offline', async () => {
++ const presence = require('../server/presence');
++ let r = await req('POST', '/r/coffee-talk/members', form(alice, { username: 'carol', role: 'member' }));
++ assert.strictEqual(r.headers.get('location'), '/r/coffee-talk/settings?saved=1');
++ h.netPresence.rows.set(bob.subject_id, view(bob.subject_id, 'online'));
++ h.netPresence.rows.set(carol.subject_id, view(carol.subject_id, 'away'));
++ presence.clearCache();
++ const settings = await req('GET', '/r/coffee-talk/settings', { headers: cookie(alice) });
++ assert.strictEqual(settings.status, 200);
++ assert.match(settings.text, /<p class="oc-muted oc-members-online">1 online<\/p>/);
++ const list = (/<ul class="oc-list oc-list-flush">([\s\S]*?)<\/ul>/.exec(settings.text) || [])[1] || '';
++ const order = ['Bob', 'Carol', 'Alice'].map((name) => list.indexOf(`<strong>${name}</strong>`));
++ assert.ok(order.every((i) => i > 0) && order[0] < order[1] && order[1] < order[2], `online, then away, then offline: ${order}`);
++ assert.match(list, /aria-label="Bob, online"/);
++ assert.match(list, /aria-label="Carol, away"/);
++ assert.match(list, /<span class="oc-pdot" data-state="online"/);
++ assert.match(list, /<span class="oc-pdot" data-state="away"/);
++ assert.doesNotMatch(list, /aria-label="Alice, /, 'offline is the absence of a dot');
++ assert.doesNotMatch(list, /data-state="(offline|invisible)"/);
++});
++
t('the session probe: a guest is signed out (200 { user: null }), a bad credential is 401', async () => {
const guest = await req('GET', '/auth/me');
assert.strictEqual(guest.status, 200, 'no cookie or token at all: not an error');