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 }); });