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.
This commit is contained in:
ancsemi committed 2026-09-09 19:37:42 -04:00
1 parent 3894ea03aa
commit c2011c91fa
6 files changed
+57 -7

No files matched your search

+7
View File
@@ -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
+6
View File
@@ -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'
+10 -2
View File
@@ -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)
+3 -1
View File
@@ -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) ─────────────────
+28 -3
View File
@@ -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;
+3 -1
View File
@@ -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 });
});