openvibers/openvibe.chat Commits
Presence: draw the dot on DMs, headers and member lists (#380)
Changes
@@@ -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 -->@@@ -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",@@@ -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",@@@ -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"},@@@ -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); }@@@ -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) {@@@ -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 };}/**@@@ -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_idWHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);}@@@ -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 mLEFT 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))]);}@@@ -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>', htmlconst 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}@@@ -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 };@@@ -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; unassert.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);@@@ -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(); }});nnew file mode 100644@@@ -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>/);++});@@@ -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 privatassert.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');
@@ -970,5 +970,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -970,5 +970,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.315.0
- openvibe-contracts: v0.315.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
-- openvibe-shared: v4.129.0
+- openvibe-shared: v4.131.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,7 +9,7 @@
@@ -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)",
"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",
"contracts": "openvibe-contracts v0.315.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
- "shared": "openvibe-shared v4.129.0",
+ "shared": "openvibe-shared v4.131.0",
"capabilities": [
"capabilities": [
"chat.message.send",
"chat.message.send",
"chat.event.publish",
"chat.event.publish",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
"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-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",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1601,9 +1601,9 @@
@@ -1601,9 +1601,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
"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-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",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -522,7 +522,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -522,7 +522,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-unread .oc-row-sub { color: var(--oc-text); }
.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-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-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 { 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-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); }
.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); }
@@ -23,6 +23,38 @@
@@ -23,6 +23,38 @@
function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
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; }
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) {
function clock(ts) {
var d = new Date(ts && String(ts).indexOf('T') < 0 ? String(ts).replace(' ', 'T') + 'Z' : 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));
var t = el('time', 'oc-time', isNaN(d) ? '' : d.toISOString().slice(11, 16));
@@ -212,6 +244,25 @@
@@ -212,6 +244,25 @@
}
}
setInterval(poll, 4000);
setInterval(poll, 4000);
document.addEventListener('visibilitychange', poll);
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) {
if (form && input) {
form.addEventListener('submit', function (e) {
form.addEventListener('submit', function (e) {
var text = input.value.trim();
var text = input.value.trim();
@@ -383,12 +434,12 @@
@@ -383,12 +434,12 @@
return d.toISOString().slice(0, 10);
return d.toISOString().slice(0, 10);
}
}
// A person's initials in a round mark, its colour from the name (server/web/ui.js initials()).
// 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 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 letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
var h = 0;
var h = 0;
for (var ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;
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;
return m;
}
}
function convItem(c, me) {
function convItem(c, me) {
@@ -397,7 +448,8 @@
@@ -397,7 +448,8 @@
var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
var others = (c.participants || []).filter(function (p) { return p.id !== me; });
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';
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');
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));
top.appendChild(el('strong', null, title));
if (c.last_message_at) {
if (c.last_message_at) {
@@ -730,7 +730,8 @@ class ChatServer {
@@ -730,7 +730,8 @@ class ChatServer {
/**
/**
* Build a deduplicated list of users in a given stream (or global if null).
* 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) {
async getUserList(streamId, viewerSubject = null) {
const seen = new Set();
const seen = new Set();
@@ -758,14 +759,19 @@ class ChatServer {
@@ -758,14 +759,19 @@ class ChatServer {
anonCount++;
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
// 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.
// 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()]);
const seenPresence = await presence.forViewer(viewerSubject, [...subjects.values()]);
for (const u of logged) u.presence = seenPresence.get(subjects.get(u.username)) || null;
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 };
}
}
/**
/**
@@ -458,16 +458,16 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
@@ -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). */
/** 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.
// A server's channel: the server's people, with the role each has in the channel.
if (room.server_id) {
if (room.server_id) {
const server = await require('./servers').byId(room.server_id);
const server = await require('./servers').byId(room.server_id);
if (!server) return [];
if (!server) return [];
const as = (r) => (r === 'owner' ? 'owner' : r === 'admin' || r === 'mod' ? 'mod' : kindOf(room) === 'call' ? joinRoleOf(room) : 'member');
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`;
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]);
WHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);
}
}
@@ -301,9 +301,9 @@ async function setRole(server, actor, targetUserId, role) {
@@ -301,9 +301,9 @@ async function setRole(server, actor, targetUserId, role) {
}
}
/** The server's people by rank (blocked last, only for moderators). */
/** 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`;
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'"}
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))]);
ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
}
}
@@ -49,6 +49,7 @@ const prefs = require('../prefs/chat-preferences');
@@ -49,6 +49,7 @@ const prefs = require('../prefs/chat-preferences');
const prefStores = require('../prefs/stores');
const prefStores = require('../prefs/stores');
const ctx = require('../live-context');
const ctx = require('../live-context');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
+const presence = require('../presence');
const db = require('../db/database');
const db = require('../db/database');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
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
@@ -347,20 +348,25 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
const ui = require('./ui');
const ui = require('./ui');
const { ICONS: IC } = require('./icons');
const { ICONS: IC } = require('./icons');
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
/** 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);
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>`;
+ `<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) => {
router.get('/messages', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
const convs = await dm.getConversations(me.id);
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';
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 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 done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
const blocked = await dm.getBlockedUsers(me.id);
const blocked = await dm.getBlockedUsers(me.id);
@@ -409,6 +415,7 @@ ${blockedList}`,
@@ -409,6 +415,7 @@ ${blockedList}`,
const id = parseInt(req.params.id, 10);
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>' });
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);
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 conv = await dm.getConversation(id);
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
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);
const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);
@@ -419,6 +426,12 @@ ${blockedList}`,
@@ -419,6 +426,12 @@ ${blockedList}`,
const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;
const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;
const closed = other ? await dm.isBlockedEither(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) : '';
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>`;
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>`;
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.');
else if (closed) composer = notice('error', 'You cannot message this person.');
@@ -429,8 +442,8 @@ ${blockedList}`,
@@ -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>` : '';
? `<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, {
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
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>
<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}`,
${composer}`,
});
});
@@ -960,7 +973,11 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
@@ -960,7 +973,11 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
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 kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
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>`;
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>` : ''}`,
@@ -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>'
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>'
: (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>`;
: `<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 addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
const attachments = can.manage ? await rooms.attachments(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(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=
@@ -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>
<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>
<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>
<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>
<ul class="oc-list oc-list-flush">${members.map(memberRow).join('')}</ul>
</section>
</section>
${hookSection}
${hookSection}
@@ -4,18 +4,19 @@
@@ -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
* 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.
* 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
* 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
* 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)
* colour as in the server rail, wherever the server is listed)
*/
*/
const { esc } = require('./layout');
const { esc } = require('./layout');
const { ICONS } = require('./icons');
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">
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>` : ''}
${actions.length ? `<div class="oc-page-tools">${actions.join('')}</div>` : ''}
</header>`;
</header>`;
}
}
@@ -38,16 +39,57 @@ function when(ts, now = Date.now()) {
@@ -38,16 +39,57 @@ function when(ts, now = Date.now()) {
return { iso: d.toISOString(), text };
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 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();
const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
let h = 0;
let h = 0;
for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;
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 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 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>`;
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 };
@@ -21,6 +21,7 @@ const req = async (method, path, { headers = {}, raw } = {}) => {
@@ -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 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 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 navLabel = (page, key) => {
const cfg = JSON.parse(page.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(page.match(/window\.__OV_PAGE = (.*);\n/)[1]);
return cfg.navbar.links.find((l) => l.href === key).label;
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
@@ -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/);
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 () => {
t('groups have no one to block', async () => {
const r = await dmApi('POST', '/conversations', alice, { user_ids: [bob.id, carol.id], name: 'Trio' });
const r = await dmApi('POST', '/conversations', alice, { user_ids: [bob.id, carol.id], name: 'Trio' });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -97,6 +97,8 @@ t('room member lists carry presence, built for the viewer', async () => {
@@ -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');
const listed = list.users.logged.find((u) => u.username === 'bob');
assert.strictEqual(listed.presence.state, 'away');
assert.strictEqual(listed.presence.state, 'away');
assert.strictEqual(listed.presence.live, true);
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');
assert.strictEqual(h.netPresence.calls.at(-1).for, ANN, 'the list was built for Ann');
} finally { a.close(); b.close(); }
} finally { a.close(); b.close(); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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>/);
+});
@@ -19,6 +19,7 @@ const req = async (method, path, { headers = {}, raw } = {}) => {
@@ -19,6 +19,7 @@ const req = async (method, path, { headers = {}, raw } = {}) => {
return { status: r.status, headers: r.headers, text: await r.text() };
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 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 () => {
t('boot', async () => {
h = await boot({ env: { CHAT_WEB_URL: SITE } });
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
@@ -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');
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 () => {
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');
const guest = await req('GET', '/auth/me');
assert.strictEqual(guest.status, 200, 'no cookie or token at all: not an error');
assert.strictEqual(guest.status, 200, 'no cookie or token at all: not an error');