From c2011c91faf5653d8143acaaaf44f30cc8bf2a42 Mon Sep 17 00:00:00 2001 From: ancsemi Date: Wed, 9 Sep 2026 19:37:42 -0400 Subject: [PATCH] DM PiP header shows the partner's avatar and live status (#5574, #5600) The header read presence from the online list of whichever channel was on screen, so a partner outside that channel showed as away with an initial until the DM was opened full screen. The DM target now carries avatar fields, the client keeps every channel's online list and reads the DM's own, asks for it when the PiP opens, and the server refreshes DM rooms whenever a member connects, disconnects for good, or changes status. --- CHANGELOG.md | 7 +++++++ public/js/modules/app-socket.js | 6 ++++++ public/js/modules/app-utilities.js | 12 ++++++++++-- src/socketHandlers/channels.js | 4 +++- src/socketHandlers/index.js | 31 +++++++++++++++++++++++++++--- src/socketHandlers/users.js | 4 +++- 6 files changed, 57 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e840d7..01dc8444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema - **Forum topic cards show the protection shield (#5622).** A protected topic now carries the shield on its card, and its right-click menu offers Unprotect instead of Protect a second time. Reported by @quakeman00. +- **The DM PiP shows the partner's avatar and live status (#5574, #5600).** + The PiP header only knew about people in whatever channel was on screen, so + a partner who was not in that channel showed as a grey dot with an initial + until the DM was opened full screen. The DM now carries its partner's avatar, + and presence for a DM is refreshed whenever either side connects, disconnects + or changes status, with the DM not on screen. Reported by @birdcrazy and + @TianLaiEric. ## [4.6.0] - 2026-09-09 diff --git a/public/js/modules/app-socket.js b/public/js/modules/app-socket.js index fdcd4a81..e8cd391a 100644 --- a/public/js/modules/app-socket.js +++ b/public/js/modules/app-socket.js @@ -1335,6 +1335,12 @@ _setupSocketListeners() { }); this.socket.on('online-users', (data) => { + // Every list is kept by channel (the socket sits in every room it + // belongs to), so a DM PiP can read its own partner's presence instead + // of the list for whatever channel is on screen (#5574). + if (!this._onlineByChannel) this._onlineByChannel = new Map(); + this._onlineByChannel.set(data.channelCode, data.users || []); + if (this._activeDMPip && data.channelCode === this._activeDMPip) this._refreshDMPipHeader?.(); if (data.channelCode === this.currentChannel) { // In 'all' mode the list includes offline members too; only count truly online users const trueOnlineCount = data.visibilityMode === 'all' diff --git a/public/js/modules/app-utilities.js b/public/js/modules/app-utilities.js index 49e94260..c0ca3925 100644 --- a/public/js/modules/app-utilities.js +++ b/public/js/modules/app-utilities.js @@ -3208,6 +3208,9 @@ _openDMPiP(code) { if (titleEl) titleEl.textContent = ch.is_self_dm ? `📝 ${t('dm_runtime.self_title', { name: partnerName })}` : `@ ${partnerName}`; this._refreshDMPipHeader(ch, partnerName); + // Ask for the DM's own online list so the header is right straight away, + // not only after the next presence change (#5574). + this.socket.emit('request-online-users', { code }); // Banner background: use server banner as a subtle backdrop const bannerEl = document.getElementById('dm-pip-banner'); @@ -3239,8 +3242,13 @@ _refreshDMPipHeader(ch, partnerName) { const avatarWrap = document.getElementById('dm-pip-avatar-wrap'); if (avatarWrap) { const partnerId = ch.dm_target && ch.dm_target.id; - const onlinePartner = partnerId && this._lastOnlineUsers - ? this._lastOnlineUsers.find(u => u.id === partnerId) + // The DM's own list first: the list for the channel on screen only has + // the partner in it when they happen to share that channel (#5574). + const dmList = this._onlineByChannel && this._onlineByChannel.get(ch.code); + const onlinePartner = partnerId + ? ((dmList && dmList.find(u => u.id === partnerId)) + || (this._lastOnlineUsers ? this._lastOnlineUsers.find(u => u.id === partnerId) : null) + || null) : null; const avatarUrl = (onlinePartner && onlinePartner.avatar) || (ch.dm_target && ch.dm_target.avatar); const shape = (onlinePartner && onlinePartner.avatarShape) diff --git a/src/socketHandlers/channels.js b/src/socketHandlers/channels.js index ca55f703..34ebb69b 100644 --- a/src/socketHandlers/channels.js +++ b/src/socketHandlers/channels.js @@ -37,7 +37,7 @@ const { clearChannelRuntimeState } = require('../channelRotation'); module.exports = function register(socket, ctx) { const { io, db, state, userHasPermission, getUserEffectiveLevel, - broadcastChannelLists, getEnrichedChannels, emitOnlineUsers, + broadcastChannelLists, getEnrichedChannels, emitOnlineUsers, emitDmPresence, handleVoiceLeave, broadcastVoiceUsers, generateUniqueSharedCode, applyRoleChannelAccess, logAudit, fireWebhookEvent, enforceAutomod, rotateChannelCode, botAudioManager @@ -118,6 +118,8 @@ module.exports = function register(socket, ctx) { (room) => socket.join(room) ); socket.emit('channels-list', channels); + // Now in every DM room: let partners know this user is here (#5574). + if (emitDmPresence) emitDmPresence(socket.user.id); }); // ── Create channel (permission-based) ───────────────── diff --git a/src/socketHandlers/index.js b/src/socketHandlers/index.js index 7d48968e..b68cda9c 100644 --- a/src/socketHandlers/index.js +++ b/src/socketHandlers/index.js @@ -558,7 +558,9 @@ function setupSocketHandlers(io, db, opts = {}) { if (ch.is_dm) { const otherUser = db.prepare(` - SELECT u.id, COALESCE(u.display_name, u.username) as username FROM users u + SELECT u.id, COALESCE(u.display_name, u.username) as username, + u.avatar, u.avatar_shape AS avatarShape + FROM users u JOIN channel_members cm ON u.id = cm.user_id WHERE cm.channel_id = ? AND u.id != ? `).get(ch.id, userId); @@ -567,7 +569,7 @@ function setupSocketHandlers(io, db, opts = {}) { } else { // Self-DM: only one channel_members row, no "other" user. Use self as the partner. const self = db.prepare( - 'SELECT id, COALESCE(display_name, username) as username FROM users WHERE id = ?' + 'SELECT id, COALESCE(display_name, username) as username, avatar, avatar_shape AS avatarShape FROM users WHERE id = ?' ).get(userId); ch.dm_target = self || null; ch.is_self_dm = 1; @@ -790,6 +792,21 @@ function setupSocketHandlers(io, db, opts = {}) { } // ── emitOnlineUsers ───────────────────────────────────── + // A DM room only got a fresh online list while somebody was looking at it, + // so a DM PiP opened from another channel showed its partner as away and + // never caught them coming online (#5574). Every presence change now also + // refreshes the user's DM rooms. A DM has two members, so each list is tiny. + function emitDmPresence(userId) { + try { + const rows = db.prepare(` + SELECT c.code FROM channels c + JOIN channel_members cm ON cm.channel_id = c.id + WHERE c.is_dm = 1 AND cm.user_id = ? + `).all(userId); + for (const r of rows) emitOnlineUsers(r.code); + } catch { /* presence is best-effort */ } + } + function emitOnlineUsers(code) { const room = channelUsers.get(code); @@ -2188,7 +2205,7 @@ function setupSocketHandlers(io, db, opts = {}) { getChannelRoleChain, getUserEffectiveLevel, getPermissionThresholds, userHasPermission, getUserPermissions, getUserGlobalPermissions, getUserRoles, getUserHighestRole, getUserAllRoles, getAdminRoleDisplay, // Broadcast helpers - broadcastChannelLists, broadcastVoiceUsers, emitOnlineUsers, + broadcastChannelLists, broadcastVoiceUsers, emitOnlineUsers, emitDmPresence, getEnrichedChannels, handleVoiceLeave, pruneStaleVoiceUsers, broadcastStreamInfo, touchVoiceActivity, rotateChannelCode, // Push / webhooks @@ -2318,6 +2335,14 @@ function setupSocketHandlers(io, db, opts = {}) { emitOnlineUsers(code); } + // Gone for good (no other tab or device still connected): tell DM + // partners, whether or not either side was looking at the DM (#5574). + let stillConnected = false; + for (const [, s] of io.of('/').sockets) { + if (s.user && s.user.id === socket.user.id && s.id !== socket.id) { stillConnected = true; break; } + } + if (!stillConnected) emitDmPresence(socket.user.id); + for (const code of Array.from(voiceUsers.keys())) { const room = voiceUsers.get(code); if (!room) continue; diff --git a/src/socketHandlers/users.js b/src/socketHandlers/users.js index 221dc66d..3b77f398 100644 --- a/src/socketHandlers/users.js +++ b/src/socketHandlers/users.js @@ -8,7 +8,7 @@ const { setEnvValue, clearEnvValue, isWritableKey } = require('../envStore'); module.exports = function register(socket, ctx) { const { io, db, state, getChannelRoleChain, userHasPermission, getUserEffectiveLevel, - emitOnlineUsers, broadcastVoiceUsers, generateToken, + emitOnlineUsers, emitDmPresence, broadcastVoiceUsers, generateToken, touchVoiceActivity, enforceAutomod, DATA_DIR, logAudit, getAdminRoleDisplay } = ctx; const { channelUsers, voiceUsers } = state; const _audit = (typeof logAudit === 'function') ? logAudit : () => {}; @@ -245,6 +245,8 @@ module.exports = function register(socket, ctx) { emitOnlineUsers(code); } } + // DM partners see the new status too, with the DM not on screen (#5574). + if (emitDmPresence) emitDmPresence(socket.user.id); socket.emit('status-updated', { status, statusText }); });