Files
Haven/public/js/modules/app-users.js
T

1561 lines
75 KiB
JavaScript

export default {
// ── Users ─────────────────────────────────────────────
_renderOnlineUsers(users) {
this._lastOnlineUsers = users;
try { this._restyleMessageAuthors?.(); this._syncBirthdayMarks?.(); } catch { /* cosmetic */ }
this._refreshOpenProfileCard();
if (this._activeDMPip) this._refreshDMPipHeader?.(); // (#5574)
const el = document.getElementById('online-users');
const searchWrap = document.getElementById('user-search-wrap');
if (searchWrap) searchWrap.style.display = users.length ? '' : 'none';
if (users.length === 0) {
el.innerHTML = `<p class="muted-text">${t('users.no_one_here')}</p>`;
return;
}
// Member search. Filtering here rather than hiding rows in the DOM keeps the
// group counts honest, so "Online 3" means three matches and not three people
// of whom you can see one. The roster re-renders on every presence change, so
// the term lives on the instance to survive that.
const term = (this._userFilter || '').trim().toLowerCase();
if (term) users = users.filter(u => (u.username || '').toLowerCase().includes(term));
if (users.length === 0) {
el.innerHTML = `<p class="muted-text">${t('users.no_search_matches')}</p>`;
return;
}
// Build a score lookup from high scores data
const scoreLookup = {};
if (this.highScores.flappy) {
this.highScores.flappy.forEach(s => { scoreLookup[s.user_id] = s.score; });
}
// Also use highScore from server-sent user data
users.forEach(u => {
if (u.highScore && u.highScore > (scoreLookup[u.id] || 0)) {
scoreLookup[u.id] = u.highScore;
}
});
// Sort: online first, then by role level, then alphabetically inside each
// level. Straight alphabetical buried whoever is actually in charge somewhere
// in the middle of the list, which is the opposite of what you want when you
// are looking for someone who can help. (#5470 follow-up, asked by @birdcrazy)
//
// The level already accounts for the channel you are in: the server sends the
// highest role that applies here, merging server-wide and channel-scoped ones.
// A user whose role badge is hidden reports no role and sorts as level 0, so
// hiding the admin badge does not out them by position either.
const levelOf = (u) => (u.role && Number.isFinite(u.role.level)) ? u.role.level : 0;
const sorted = [...users].sort((a, b) => {
const aOn = a.online !== false;
const bOn = b.online !== false;
if (aOn !== bOn) return aOn ? -1 : 1;
const lv = levelOf(b) - levelOf(a);
if (lv !== 0) return lv;
return a.username.toLowerCase().localeCompare(b.username.toLowerCase());
});
// Separate into online/offline groups
const onlineUsers = sorted.filter(u => u.online !== false);
const offlineUsers = sorted.filter(u => u.online === false);
let html = '';
if (onlineUsers.length > 0) {
html += `<div class="user-group-label">${t('users.online_count', { count: onlineUsers.length })}</div>`;
html += onlineUsers.map(u => this._renderUserItem(u, scoreLookup)).join('');
}
if (offlineUsers.length > 0) {
html += `<div class="user-group-label offline-label">${t('users.offline_count', { count: offlineUsers.length })}</div>`;
html += offlineUsers.map(u => this._renderUserItem(u, scoreLookup)).join('');
}
if (!onlineUsers.length && !offlineUsers.length) {
html = `<p class="muted-text">${t('users.no_one_here')}</p>`;
}
el.innerHTML = html;
// Re-point the hover card at the rebuilt row. This list is redrawn on every
// presence update (someone talking in voice triggers one every few seconds)
// and the card's safety net reads a detached trigger as "pointer left", so
// without this the card closed by itself under a resting mouse (#5608).
const hovered = this._hoverTarget;
if (hovered && !hovered.isConnected && hovered.classList && hovered.classList.contains('user-item')) {
const uid = hovered.dataset.userId;
const fresh = uid ? el.querySelector(`.user-item[data-user-id="${uid}"]`) : null;
if (fresh) {
this._hoverTarget = fresh;
if (this._profilePopupAnchor === hovered) this._profilePopupAnchor = fresh;
}
}
// Bind gear buttons: same unified menu as right-click, anchored to the gear
el.querySelectorAll('.user-gear-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._openUserMenuFromButton(btn, parseInt(btn.dataset.uid), btn.dataset.uname);
});
});
// Bind DM buttons
el.querySelectorAll('.user-dm-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const targetId = parseInt(btn.dataset.dmUid);
if (isNaN(targetId)) return;
const targetName = btn.closest('.user-item')?.querySelector('.user-item-name')?.textContent || 'user';
this._showToast(t('users.opening_dm', { name: targetName }), 'info');
btn.disabled = true;
btn.style.opacity = '0.5';
this.socket.emit('start-dm', { targetUserId: targetId });
// Re-enable after a timeout in case no response
setTimeout(() => { btn.disabled = false; btn.style.opacity = ''; }, 5000);
});
});
},
_renderUserItem(u, scoreLookup) {
const onlineClass = u.online === false ? ' offline' : '';
const score = scoreLookup[u.id] || 0;
// Per-device "hide other players' badges" preference. Only suppress badges
// for users other than self. Own badge stays visible to me unless the
// server-side "hide own from server" preference also stripped it.
const hideOthers = localStorage.getItem('haven_hide_other_scores') === 'true';
const hideOwn = localStorage.getItem('haven_hide_own_score') === 'true';
const isOwnUser = u.id === this.user?.id;
// Show badge unless: hideOthers is on and this is someone else,
// OR hideOwn is on and this is the current user.
const showBadge = score > 0 && (!hideOthers || isOwnUser) && !(hideOwn && isOwnUser);
const scoreBadge = showBadge
? `<span class="user-score-badge" title="${t('users.flappy_score_title', { score })}">🚢${score}</span>`
: '';
// Status dot color
const statusClass = u.status === 'dnd' ? 'dnd' : u.status === 'away' ? 'away'
: u.status === 'invisible' ? 'invisible' : (u.online === false ? 'away' : '');
const statusTextHtml = u.statusText
? `<span class="user-status-text" title="${this._escapeHtml(u.statusText)}">${this._escapeHtml(u.statusText)}</span>`
: '';
// Rich presence: sidebar shows at most ONE activity to keep the list
// scannable; a game outranks music. The profile card is where both show.
const activityHtml = this._sidebarActivityHtml(u.activity);
// Avatar: image or letter fallback
const color = this._getUserColor(u.username);
const initial = this._escapeHtml(u.username.charAt(0).toUpperCase());
const shapeClass = 'avatar-' + (u.avatarShape || 'circle');
const avatarImg = u.avatar
? `<img class="user-item-avatar user-item-avatar-img ${shapeClass}"${this._animAttr(u.animateProfile)} src="${this._escapeHtml(u.avatar)}" alt="${initial}"><div class="user-item-avatar ${shapeClass}" style="background-color:${color};display:none">${initial}</div>`
: `<div class="user-item-avatar ${shapeClass}" style="background-color:${color}">${initial}</div>`;
// Wrap avatar + status dot together (Discord-style overlay)
const avatarHtml = `<div class="user-avatar-wrapper">${avatarImg}${this._pfpBorderMarker(u.border, u.borderTransform, u.animateProfile)}<span class="user-status-dot${statusClass ? ' ' + statusClass : ''}"></span></div>`;
// Role: color dot to the left of name + tooltip on hover
// Role display mode
const roleDisplayMode = localStorage.getItem('haven-role-display') || 'colored-name';
const roleColor = u.role ? this._safeColor(u.role.color, 'var(--text-muted)') : '';
const showIconSidebar = (this.serverSettings.role_icon_sidebar || 'true') === 'true';
const iconAfterName = this.serverSettings.role_icon_after_name === 'true';
const roleIconHtml = showIconSidebar && u.role && u.role.icon
? `<img class="role-icon" src="${this._escapeHtml(u.role.icon)}" alt="" title="${this._escapeHtml(u.role.name)}">`
: '';
const roleIconBefore = roleIconHtml && !iconAfterName ? roleIconHtml : '';
const roleIconAfter = roleIconHtml && iconAfterName ? roleIconHtml : '';
const roleDot = (roleDisplayMode === 'dot' && u.role)
? `<span class="user-role-dot" style="background:${this._roleFill(u.role, roleColor)}" title="${this._escapeHtml(u.role.name)}"></span>`
: '';
// In colored-name mode, apply role color to the username
const nameStyle = (roleDisplayMode === 'colored-name' && u.role && roleColor)
? ` style="color:${roleColor}"`
: '';
// A gradient role paints the name itself (see _roleNameHtml).
const nameText = this._getNickname(u.id, u.username);
const nameHtml = nameStyle ? this._roleNameHtml(u.role, nameText) : this._escapeHtml(nameText);
// Keep the old badge for message area (msg-role-badge) but hide in sidebar
const roleBadge = u.role
? `<span class="user-role-badge" style="color:${this._safeColor(u.role.color, 'var(--text-muted)')}" title="${this._escapeHtml(u.role.name)}">${this._roleNameHtml(u.role, u.role.name)}</span>`
: '';
// (#5381) Mark guest accounts with a small badge so people know not to
// expect long-term presence.
const guestBadge = u.isGuest
? `<span class="user-role-badge guest-badge" style="color:#888;border:1px solid #555" title="${t('users.guest_hint')}">${t('users.guest_badge')}</span>`
: '';
// Build tooltip
const tooltipRole = u.role ? `<div class="tooltip-role" style="color:${roleColor}">● ${this._escapeHtml(u.role.name)}</div>` : '';
const tooltipStatus = u.statusText ? `<div class="tooltip-status">${this._escapeHtml(u.statusText)}</div>` : '';
const tooltipOnline = u.online === false ? `<div class="tooltip-status">${t('app.profile.offline')}</div>` : '';
// Tooltip removed: the full profile popup (hover/click) provides this info.
const dmBtn = u.id === this.user.id
? `<button class="user-action-btn user-dm-btn" data-dm-uid="${u.id}" title="${t('users.notes_to_self_hint')}">📝</button>`
: `<button class="user-action-btn user-dm-btn" data-dm-uid="${u.id}" title="${t('users.direct_message')}">💬</button>`;
// DM + gear. The gear opens the same unified menu as right-click (mod actions
// sit behind a divider there); it stays visible so touch and keyboard users
// still have a discoverable way in.
const canModThis = (this.user.isAdmin || this._canModerate()) && u.id !== this.user.id;
const canPromote = this._hasPerm('promote_user') && u.id !== this.user.id;
// A moderator whose role grants ban_user but who sits below the level-25
// _canModerate() threshold still needs the gear to appear. (v3.43.0)
const canBanThis = this._hasPerm('ban_user') && u.id !== this.user.id;
const hasGear = canModThis || canPromote || canBanThis;
const gearBtn = hasGear
? `<button class="user-action-btn user-gear-btn" data-uid="${u.id}" data-uname="${this._escapeHtml(u.username)}" title="${t('users.more_actions')}">⚙️</button>`
: '';
const modBtns = (dmBtn || gearBtn)
? `<div class="user-admin-actions">${dmBtn}${gearBtn}</div>`
: '';
// The name gets a line to itself. Everything used to sit on one row, so a
// custom status and an activity together squeezed the username down to a
// couple of characters and an ellipsis. Whatever the person is doing now
// goes on a smaller second line underneath, and only one thing is shown
// there: a game beats music (picked in _sidebarActivityHtml), and any
// activity beats a custom status, which is the least specific of the three.
const subLine = activityHtml || statusTextHtml;
const subLineHtml = subLine ? `<div class="user-item-sub">${subLine}</div>` : '';
return `
<div class="user-item${onlineClass}${subLineHtml ? ' has-sub' : ''}" data-user-id="${u.id}">
${avatarHtml}
<div class="user-item-text">
<div class="user-item-line">
${roleDot}${roleIconBefore}${this._birthdayMarkHtml(u.birthday)}
<span class="user-item-name"${nameStyle}${this._nicknames[u.id] ? ` title="${this._escapeHtml(u.username)}"` : ''}>${nameHtml}</span>
${roleIconAfter}
${roleBadge}
${guestBadge}
</div>
${subLineHtml}
</div>
${scoreBadge}
${modBtns}
</div>
`;
},
// ── Rich presence: Settings → Connections ─────────────
/**
* Render the linked-account rows. Providers the server has no credentials for
* are shown greyed out with the reason, rather than hidden; otherwise a user
* whose admin hasn't set up Spotify just sees an unexplained gap and files a
* bug about the missing button.
*/
_renderConnections() {
const host = document.getElementById('connections-list');
if (!host) return;
const data = this._connections || { connections: [], available: {} };
const linked = new Map((data.connections || []).map(c => [c.provider, c]));
const available = data.available || {};
// Steam and Spotify both require per-deployment credentials that cannot ship
// with Haven: a Steam key is tied to one person's Steam account, and a
// bundled Spotify client secret would be extractable by anyone who downloads
// the source. So "not configured" is the correct default state, and the admin
// needs to know exactly which env vars fix it rather than just seeing a dead row.
const PROVIDERS = [
// Last.fm first: it's the recommended music source. No OAuth, no user cap,
// and it reports whatever the person actually listens with.
{ id: 'lastfm', icon: '🎵', name: 'Last.fm',
// Most people have never heard of Last.fm, so the row has to explain
// what it is before asking them to link it.
blurb: t('users.connections.lastfm_blurb'),
linkType: 'username',
usernameLabel: t('users.connections.lastfm_username'),
// People reliably assume linking the username is the whole job. It isn't:
// Last.fm only knows what something sends it ("scrobbling"), and that is
// set up on Last.fm's side, not here. Spell out both paths.
note: t('users.connections.lastfm_note'),
help: 'https://www.last.fm/api/account/create',
helpLabel: t('users.connections.lastfm_help'),
steps: [
t('users.connections.lastfm_step_1'),
t('users.connections.lastfm_step_2'),
],
fields: [{ key: 'LASTFM_API_KEY', label: t('users.connections.api_key') }] },
{ id: 'steam', icon: '🎮', name: 'Steam', blurb: t('users.connections.steam_blurb'),
help: 'https://steamcommunity.com/dev/apikey',
helpLabel: t('users.connections.steam_help'),
steps: [
t('users.connections.steam_step_1'),
t('users.connections.steam_step_2'),
t('users.connections.steam_step_3'),
],
fields: [{ key: 'STEAM_API_KEY', label: t('users.connections.api_key') }] },
// Spotify is collapsed behind a disclosure. It needs a registered developer
// app and its development-mode user allowlist caps it at roughly 25 people,
// so steering everyone here by default sends them down the hardest path for
// a worse result than Last.fm. Spotify then restricted the Web API to
// Premium accounts (#5528), which makes that even more true: the steps used
// to say a free account was fine, and following them on one now dead-ends.
{ id: 'spotify', icon: '🎧', name: 'Spotify', advanced: true,
blurb: t('users.connections.spotify_blurb'),
help: 'https://developer.spotify.com/dashboard',
helpLabel: t('users.connections.spotify_help'),
// Two things trip people up here, both worth stating outright:
// 1. developer.spotify.com is a SEPARATE site from Spotify account
// settings. "Manage apps" under your account lists apps you've
// authorised and has no Create button. It is the wrong page, and
// it's the one people find first when they go looking themselves.
// 2. "Create an app" sounds like software development. It isn't; it's
// registering a name so Spotify knows who is asking.
steps: [
t('users.connections.spotify_step_1'),
t('users.connections.spotify_step_2'),
t('users.connections.spotify_step_3'),
t('users.connections.spotify_step_4', { uri: location.origin + '/connect/spotify/callback' }),
t('users.connections.spotify_step_5'),
t('users.connections.spotify_step_6'),
],
fields: [
{ key: 'SPOTIFY_CLIENT_ID', label: t('users.connections.client_id') },
{ key: 'SPOTIFY_CLIENT_SECRET', label: t('users.connections.client_secret') },
] },
];
const isAdmin = !!this.user?.isAdmin;
// Placeholder is a hint, not a default, so derive it from the viewer's own
// Haven name. It was hardcoded to a real username, which meant every user on
// every Haven server was shown one specific person's handle as the example.
const placeholderName = this._escapeHtml(
(this.user?.username || this.user?.displayName || t('users.connections.username_placeholder'))
.replace(/[^a-zA-Z0-9_-]/g, '')
.slice(0, 15) || t('users.connections.username_placeholder')
);
// Advanced providers stay collapsed unless already linked/configured:
// no point hiding something the user is actively using.
const isAdvancedHidden = (p) => p.advanced && !linked.has(p.id) && !available[p.id];
const renderProvider = (p) => {
const conn = linked.get(p.id);
const configured = !!available[p.id];
let sub, btn = '';
if (!configured) {
// Admins get an inline setup form. Most self-hosters have no idea where
// .env lives, and telling them to "edit .env and restart" is a dead end.
// Everyone else just learns the provider is off.
sub = isAdmin ? t('users.connections.not_setup') : t('users.connections.not_enabled');
if (isAdmin) {
btn += `<button class="btn-sm connection-setup" data-provider="${p.id}">${t('users.connections.setup')}</button>`;
}
} else if (conn) {
sub = conn.displayName ? t('users.connections.linked_as', { name: this._escapeHtml(conn.displayName) }) : t('users.connections.linked');
btn += `<button class="btn-sm connection-unlink" data-provider="${p.id}">${t('users.connections.unlink')}</button>`;
// A working key can still need rotating (it leaked, or Steam revoked it).
// Surface a way to paste a fresh one without hand-editing .env.
if (isAdmin) {
btn += `<button class="btn-sm connection-rekey" data-provider="${p.id}">${t('users.connections.change_key')}</button>`;
// (#5529) Setting a provider up was one-way: the form could replace a
// key but never clear one, so an admin had no way to switch an
// integration back off without editing .env by hand.
if (configured) btn += `<button class="btn-sm connection-forget" data-provider="${p.id}">${t('users.connections.remove_key')}</button>`;
}
} else if (p.linkType === 'username') {
// No OAuth for this provider: the whole link flow is one text field.
sub = p.blurb;
btn += `<button class="btn-sm btn-accent connection-username-toggle" data-provider="${p.id}">${t('users.connections.connect')}</button>`;
if (isAdmin) {
btn += `<button class="btn-sm connection-rekey" data-provider="${p.id}">${t('users.connections.change_key')}</button>`;
// (#5529) Setting a provider up was one-way: the form could replace a
// key but never clear one, so an admin had no way to switch an
// integration back off without editing .env by hand.
if (configured) btn += `<button class="btn-sm connection-forget" data-provider="${p.id}">${t('users.connections.remove_key')}</button>`;
}
} else {
sub = p.blurb;
btn += `<button class="btn-sm btn-accent connection-link" data-provider="${p.id}">${t('users.connections.link')}</button>`;
if (isAdmin) {
btn += `<button class="btn-sm connection-rekey" data-provider="${p.id}">${t('users.connections.change_key')}</button>`;
// (#5529) Setting a provider up was one-way: the form could replace a
// key but never clear one, so an admin had no way to switch an
// integration back off without editing .env by hand.
if (configured) btn += `<button class="btn-sm connection-forget" data-provider="${p.id}">${t('users.connections.remove_key')}</button>`;
}
}
// Rendered whenever an admin is looking, whether or not the provider is
// already configured, so an existing key can be rotated from here instead
// of by editing .env by hand. Hidden until "Set up" (unconfigured) or
// "Change key" (configured) reveals it. Non-admins never see it.
const setupForm = isAdmin ? `
<div class="connection-setup-form" data-provider="${p.id}" hidden>
<a class="connection-help" href="${p.help}" target="_blank" rel="noopener noreferrer">${p.helpLabel} ↗</a>
<ol class="connection-steps">${p.steps.map(s => `<li>${s}</li>`).join('')}</ol>
${p.fields.map(f => `
<label class="connection-field">
<span>${f.label}</span>
<input type="password" autocomplete="off" spellcheck="false"
data-env-key="${f.key}" placeholder="${t('users.connections.key_placeholder')}">
</label>`).join('')}
<div class="connection-setup-actions">
<button class="btn-sm btn-accent connection-save" data-provider="${p.id}">${t('modals.common.save')}</button>
<button class="btn-sm connection-cancel" data-provider="${p.id}">${t('modals.common.cancel')}</button>
</div>
<small class="settings-hint">${t(configured ? 'users.connections.save_hint_replace' : 'users.connections.save_hint')}</small>
</div>` : '';
// Username link form (Last.fm). Collapsed until "Connect" is pressed.
const usernameForm = (configured && !conn && p.linkType === 'username') ? `
<div class="connection-username-form" data-provider="${p.id}" hidden>
<label class="connection-field">
<span>${p.usernameLabel}</span>
<input type="text" autocomplete="off" spellcheck="false"
data-username-for="${p.id}" placeholder="${placeholderName}">
</label>
<div class="connection-setup-actions">
<button class="btn-sm btn-accent connection-username-save" data-provider="${p.id}">${t('users.connections.connect')}</button>
<button class="btn-sm connection-username-cancel" data-provider="${p.id}">${t('modals.common.cancel')}</button>
</div>
${p.note ? `<small class="settings-hint">${p.note}</small>` : ''}
</div>` : '';
return `
<div class="connection-block">
<div class="connection-row${configured ? '' : ' is-unavailable'}">
<span class="connection-icon">${p.icon}</span>
<span class="connection-info">
<span class="connection-name">${p.name}</span>
<span class="connection-sub">${sub}</span>
</span>
${btn}
</div>
${setupForm}
${usernameForm}
</div>`;
};
const primary = PROVIDERS.filter(p => !isAdvancedHidden(p));
const advanced = PROVIDERS.filter(p => isAdvancedHidden(p));
host.innerHTML = primary.map(renderProvider).join('')
+ (advanced.length ? `
<details class="connection-advanced">
<summary>${t('users.connections.other_options')}</summary>
${advanced.map(renderProvider).join('')}
</details>` : '');
const userFormFor = (provider) => host.querySelector(`.connection-username-form[data-provider="${provider}"]`);
host.querySelectorAll('.connection-username-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const form = userFormFor(btn.dataset.provider);
if (form) {
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector('input')?.focus();
}
});
});
host.querySelectorAll('.connection-username-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const form = userFormFor(btn.dataset.provider);
if (form) { form.querySelector('input').value = ''; form.hidden = true; }
});
});
host.querySelectorAll('.connection-username-save').forEach(btn => {
const submit = () => {
const form = userFormFor(btn.dataset.provider);
const input = form?.querySelector('input');
const value = input?.value.trim();
if (!value) return this._showToast(t('users.connections.enter_lastfm'), 'error');
// Server verifies the name against the API and pushes a refreshed
// connections payload, which re-renders this list.
this.socket?.emit('link-lastfm', { username: value });
input.value = '';
form.hidden = true;
};
btn.addEventListener('click', submit);
userFormFor(btn.dataset.provider)?.querySelector('input')
?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
});
const formFor = (provider) => host.querySelector(`.connection-setup-form[data-provider="${provider}"]`);
host.querySelectorAll('.connection-setup').forEach(btn => {
btn.addEventListener('click', () => {
const form = formFor(btn.dataset.provider);
if (form) {
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector('input')?.focus();
}
});
});
// Identical reveal behaviour for the "Change key" button that appears on
// already-configured providers, so an admin can rotate an existing key.
host.querySelectorAll('.connection-rekey').forEach(btn => {
btn.addEventListener('click', () => {
const form = formFor(btn.dataset.provider);
if (form) {
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector('input')?.focus();
}
});
});
// (#5529) Remove the provider's credentials entirely. Confirmed first,
// because it turns the integration off for everyone on the server, and it
// clears every key the provider uses so Spotify cannot be left with an id
// but no secret.
host.querySelectorAll('.connection-forget').forEach(btn => {
btn.addEventListener('click', () => {
const provider = PROVIDERS.find(x => x.id === btn.dataset.provider);
if (!provider) return;
const keys = provider.fields.map(f => f.key);
const label = provider.name;
if (!confirm(t('users.connections.remove_key_confirm', { provider: label }))) return;
this.socket.emit('clear-integration-key', { keys });
});
});
host.querySelectorAll('.connection-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const form = formFor(btn.dataset.provider);
if (form) {
form.querySelectorAll('input').forEach(i => { i.value = ''; });
form.hidden = true;
}
});
});
host.querySelectorAll('.connection-save').forEach(btn => {
btn.addEventListener('click', () => {
const form = formFor(btn.dataset.provider);
if (!form) return;
const inputs = [...form.querySelectorAll('input[data-env-key]')];
if (inputs.some(i => !i.value.trim())) {
this._showToast(t('users.connections.fill_fields'), 'error');
return;
}
// Each key is saved independently; the server validates format and
// replies with a refreshed 'connections' payload. Clear the fields
// immediately; these are secrets and shouldn't linger in the DOM.
inputs.forEach(i => {
this.socket?.emit('set-integration-key', { key: i.dataset.envKey, value: i.value.trim() });
i.value = '';
});
form.hidden = true;
});
});
host.querySelectorAll('.connection-link').forEach(btn => {
btn.addEventListener('click', () => {
// The server replies with 'connect-token', which triggers the redirect.
this.socket?.emit('get-connect-token', { provider: btn.dataset.provider });
});
});
host.querySelectorAll('.connection-unlink').forEach(btn => {
btn.addEventListener('click', () => {
this.socket?.emit('unlink-connection', { provider: btn.dataset.provider });
});
});
},
/**
* The OAuth callback bounces back to /app.html#connect=<provider>:<ok|error>.
* Read it once on load, tell the user how it went, then strip the fragment so
* a refresh doesn't replay the toast.
*/
_handleConnectRedirect() {
const m = (window.location.hash || '').match(/^#connect=([a-z]+):(ok|error)$/);
if (!m) return;
const [, provider, status] = m;
const label = provider.charAt(0).toUpperCase() + provider.slice(1);
if (status === 'ok') {
this._showToast(t('users.connections.link_success', { provider: label }), 'success');
this.socket?.emit('get-connections');
} else {
this._showToast(t('users.connections.link_failed', { provider: label }), 'error');
}
try {
history.replaceState(null, '', window.location.pathname + window.location.search);
} catch { /* fragment stays; harmless */ }
},
// ── Rich presence rendering ───────────────────────────
// The server has already applied the user's privacy preferences before this
// object leaves it, so anything present here is meant to be visible. These
// helpers only decide *how much* to show, never *whether*.
/** Icon + verb for an activity slot. */
_activityMeta(act) {
if (!act) return null;
const isGame = act.type === 'playing';
return {
icon: isGame ? '🎮' : '🎵',
verb: t(isGame ? 'users.activity_playing' : 'users.activity_listening'),
// "Track · Artist" reads better than two separate fields in one line.
label: act.details ? `${act.name} · ${act.details}` : act.name,
};
},
// A Steam game's art address carries its app id, which is all the store page
// needs, so the game on a profile card can link there (#5679).
_activityLink(act) {
const m = act && act.type === 'playing' && typeof act.image === 'string'
? act.image.match(/^https:\/\/[a-z0-9.-]*steamstatic\.com\/steam\/apps\/(\d+)\//)
: null;
return m ? `https://store.steampowered.com/app/${m[1]}/` : '';
},
/** Milliseconds → "m:ss". */
_formatClock(ms) {
const s = Math.max(0, Math.floor(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
},
/** Playback progress bar for a listen that reports duration. */
_activityProgressHtml(act) {
if (!act || act.type !== 'listening' || !act.duration || !act.startedAt) return '';
const elapsed = act.paused
? Math.min(act.duration, Math.max(0, act.positionMs || 0))
: Math.min(act.duration, Math.max(0, Date.now() - act.startedAt));
const pct = act.duration ? (elapsed / act.duration) * 100 : 0;
return `
<span class="profile-activity-progress${act.paused ? ' is-paused' : ''}" data-started="${act.startedAt}" data-duration="${act.duration}" data-paused="${act.paused ? 1 : 0}">
<span class="pap-track"><span class="pap-fill" style="width:${pct}%"></span><span class="pap-dot" style="left:${pct}%"></span></span>
<span class="pap-times"><span class="pap-elapsed">${this._formatClock(elapsed)}</span><span class="pap-total">${this._formatClock(act.duration)}</span></span>
</span>`;
},
/** Presence dot class (shared by the sidebar and the profile card). */
_statusDotClass(u) {
return u.status === 'dnd' ? 'dnd' : u.status === 'away' ? 'away'
: u.status === 'invisible' ? 'invisible' : (u.online === false ? 'away' : '');
},
/** Human label for the presence dot's tooltip. */
_statusLabel(u) {
return u.status === 'dnd' ? t('app.profile.dnd') : u.status === 'away' ? t('app.profile.away')
: u.status === 'invisible' ? t('app.profile.invisible')
: (u.online === false ? t('app.profile.offline') : t('app.profile.online'));
},
/** Custom-status line for the profile card (empty when there's none). */
_profileStatusTextHtml(text) {
return text ? `<div class="profile-popup-status-text">${this._escapeHtml(text)}</div>` : '';
},
/**
* Re-render an open profile card from the latest presence, so status (online/
* away/dnd), custom status text and activity (pause, resume, track change,
* clear) all update live without reopening it. Driven by the online-users
* broadcasts the client already receives, so no new traffic.
*/
_refreshOpenProfileCard() {
if (this._openProfileUserId == null) return;
const popup = document.getElementById('profile-popup');
if (!popup) return;
// The broadcast is scoped to the current channel and can be visibility-
// filtered, so absence does NOT reliably mean offline; the user may just be
// in another channel. Only refresh from a record we actually have; otherwise
// leave the card as-is rather than wrongly flipping it to offline.
const u = (this._lastOnlineUsers || []).find(u => u.id === this._openProfileUserId);
if (!u) return;
const dot = popup.querySelector('.profile-popup-status-dot');
if (dot) {
const cls = this._statusDotClass(u);
dot.className = 'profile-popup-status-dot' + (cls ? ' ' + cls : '');
dot.title = this._statusLabel(u);
}
// online-users fires on every join, leave, status change and activity poll,
// not only when this user changed, so compare before writing. Rewriting the
// activity slot would recreate its cover <img> and restart the progress
// timer on every broadcast.
const statusSlot = popup.querySelector('#profile-popup-status-slot');
const statusKey = u.statusText || '';
if (statusSlot && this._openProfileStatusKey !== statusKey) {
this._openProfileStatusKey = statusKey;
statusSlot.innerHTML = this._profileStatusTextHtml(u.statusText);
}
const slot = popup.querySelector('#profile-popup-activity-slot');
const activityKey = JSON.stringify(u.activity || null);
if (slot && this._openProfileActivityKey !== activityKey) {
this._openProfileActivityKey = activityKey;
slot.innerHTML = this._profileActivityHtml(u.activity);
this._startActivityProgress(slot);
}
},
/** Live-tick the progress bar while the popup is open (frozen when paused). */
_startActivityProgress(root) {
clearInterval(this._activityProgressTimer);
const el = root.querySelector('.profile-activity-progress');
if (!el || el.dataset.paused === '1') return;
const started = Number(el.dataset.started), duration = Number(el.dataset.duration);
const fill = el.querySelector('.pap-fill'), dot = el.querySelector('.pap-dot'), elapsedEl = el.querySelector('.pap-elapsed');
const tick = () => {
const elapsed = Math.min(duration, Math.max(0, Date.now() - started));
const pct = duration ? (elapsed / duration) * 100 : 0;
fill.style.width = pct + '%';
dot.style.left = pct + '%';
elapsedEl.textContent = this._formatClock(elapsed);
};
tick();
this._activityProgressTimer = setInterval(tick, 1000);
},
/**
* Single-line form for the member list. Games win over music when a user is
* doing both, so the sidebar never grows a second line per person.
*/
_sidebarActivityHtml(activity) {
if (!activity) return '';
const act = activity.playing || activity.listening;
const meta = this._activityMeta(act);
if (!meta) return '';
const full = `${meta.verb} ${meta.label}`;
return `<span class="user-activity" title="${this._escapeHtml(full)}">${meta.icon} ${this._escapeHtml(meta.label)}</span>`;
},
/**
* Escaped activity text that scrolls instead of clipping once it runs past 30
* characters. Gated on character count, not pixel width, so a long title or
* artist reads the same for everyone. Two copies scroll as one seamless loop,
* at a constant speed (duration scales with length).
*/
_scrollText(text) {
const t = text || '';
const esc = this._escapeHtml(t);
if (t.length <= 30) return esc;
const dur = Math.max(8, Math.round(t.length * 0.4));
return `<span class="pa-marquee" style="--pa-marquee-dur:${dur}s"><span class="pa-marquee-track"><span class="pa-marquee-seg">${esc}</span><span class="pa-marquee-seg" aria-hidden="true">${esc}</span></span></span>`;
},
/**
* Profile-card form: one row per activity that's actually present, game first.
* A user doing both gets both; a user doing neither (or sharing nothing) gets
* no section at all rather than an empty heading.
*/
_profileActivityHtml(activity) {
if (!activity) return '';
const rows = [activity.playing, activity.listening]
.map(act => {
const meta = this._activityMeta(act);
if (!meta) return '';
const art = act.image
? `<img class="profile-activity-art" src="${this._escapeHtml(act.image)}" alt="" loading="lazy">`
: `<span class="profile-activity-icon">${meta.icon}</span>`;
const details = act.details
? `<span class="profile-activity-details">${this._scrollText(act.details)}</span>`
: '';
const link = this._activityLink(act);
const open = link
? `<a class="profile-activity-row profile-activity-link" href="${this._escapeHtml(link)}" target="_blank" rel="noopener noreferrer" title="${this._escapeHtml(t('users.activity_open_steam'))}">`
: '<div class="profile-activity-row">';
return `
${open}
${art}
<span class="profile-activity-text">
<span class="profile-activity-verb">${act.type === 'listening' && act.paused ? `⏸ ${t('users.activity_paused')}` : meta.verb}</span>
<span class="profile-activity-name">${this._scrollText(act.name)}</span>
${details}
${this._activityProgressHtml(act)}
</span>
${link ? '</a>' : '</div>'}`;
})
.filter(Boolean);
if (rows.length === 0) return '';
return `<div class="profile-popup-section-label">${t('users.activity')}</div>
<div class="profile-popup-activity">${rows.join('')}</div>`;
},
// ── Profile Popup (Discord-style mini profile) ────────
// Open the unified user menu (the one right-click shows) from a gear button,
// positioned under the button rather than at the pointer so keyboard and touch
// activation land it in the right place too.
_openUserMenuFromButton(btn, userId, username) {
const rect = btn.getBoundingClientRect();
this._showUserContextMenu({
clientX: rect.left,
clientY: rect.bottom + 4,
target: btn,
preventDefault() {},
stopPropagation() {},
}, userId, username);
},
_showProfilePopup(profile) {
// If this was a hover-triggered popup but the mouse already left, abort
if (this._isHoverPopup && !this._hoverTarget) return;
// Closing any earlier card clears the hover target. Keep it for the hover
// card about to be drawn: without it the next mouseover on the same name
// read as a switch to a new trigger, closed the card and reopened it 350ms
// later, so it flickered under a resting mouse (#5608).
const hoverTarget = this._isHoverPopup ? this._hoverTarget : null;
this._closeProfilePopup();
if (hoverTarget) this._hoverTarget = hoverTarget;
const isSelf = profile.id === this.user.id;
const currentNick = !isSelf ? (this._nicknames[profile.id] || '') : '';
const color = this._getUserColor(profile.username);
const initial = this._escapeHtml(profile.username.charAt(0).toUpperCase());
const shapeClass = 'avatar-' + (profile.avatarShape || 'circle');
const avatarHtml = profile.avatar
? `<img class="profile-popup-avatar ${shapeClass}"${this._animAttr(profile.animateProfile)} src="${this._escapeHtml(profile.avatar)}" alt="${initial}">`
: `<div class="profile-popup-avatar profile-popup-avatar-fallback ${shapeClass}" style="background-color:${color}">${initial}</div>`;
// Status dot (shared logic so the live refresh stays in sync)
const statusClass = this._statusDotClass(profile);
const statusLabel = this._statusLabel(profile);
// Roles
const rolesHtml = (profile.roles && profile.roles.length > 0)
? profile.roles.map(r => {
const rIcon = r.icon ? `<img class="role-icon" src="${this._escapeHtml(r.icon)}" alt="">` : `<span class="profile-role-dot" style="background:${this._roleFill(r, 'var(--text-muted)')}"></span>`;
return `<span class="profile-popup-role" style="border-color:${this._safeColor(r.color, 'var(--border-light)')}; color:${this._safeColor(r.color, 'var(--text-secondary)')}">${rIcon}${this._roleNameHtml(r, r.name)}</span>`;
}).join('')
: '';
// Status text badge
const statusTextHtml = this._profileStatusTextHtml(profile.statusText);
// Bio (with "View Full Bio" toggle for long bios)
const bioText = profile.bio || '';
const bioShort = bioText.length > 80 ? bioText.slice(0, 80) + '…' : bioText;
const bioHtml = bioText
? `<div class="profile-popup-bio">
<span class="profile-bio-short">${this._escapeHtml(bioShort)}</span>
${bioText.length > 80 ? `<span class="profile-bio-full" style="display:none">${this._escapeHtml(bioText)}</span><button class="profile-bio-toggle">${t('users.view_full_bio')}</button>` : ''}
</div>`
: (isSelf ? `<div class="profile-popup-bio profile-bio-empty">${t('users.no_bio')}</div>` : '');
// Join date, with the birthday underneath it when the member has set one
const joinDate = profile.createdAt ? this._fmtDate(profile.createdAt, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
const birthdayLabel = this._birthdayLabel(profile.birthday);
const birthdayHtml = birthdayLabel ? `<div class="profile-popup-birthday">🎂 ${this._escapeHtml(birthdayLabel)}</div>` : '';
// Action buttons. Nickname lives in the unified menu; the gear opens that
// menu for anyone with mod powers over this user.
const canMod = this.user.isAdmin || this._canModerate();
const canPromote = this._hasPerm('promote_user');
const canBan = this.user.isAdmin || this._hasPerm('ban_user');
const gearVisible = !isSelf && (canMod || canPromote || canBan || this._canManageBadges());
const gearBtnHtml = gearVisible
? `<button class="profile-popup-action-btn profile-gear-btn" title="${this._escapeHtml(t('users.more_actions'))}">⚙️ ${t('users.more_actions')}</button>`
: '';
const actionsHtml = isSelf
? `<button class="profile-popup-action-btn profile-edit-btn" id="profile-popup-edit-btn">✏️ ${t('users.edit_profile')}</button><button class="profile-popup-action-btn profile-dm-btn" data-dm-uid="${profile.id}" title="${t('users.notes_to_self')}">📝 ${t('users.notes_to_self')}</button>`
: `<button class="profile-popup-action-btn profile-dm-btn" data-dm-uid="${profile.id}">💬 ${t('users.message_btn')}</button>${gearBtnHtml}`;
const popup = document.createElement('div');
popup.id = 'profile-popup';
popup.className = 'profile-popup';
popup.innerHTML = `
<div class="profile-popup-banner" style="background:linear-gradient(135deg, ${color}44, ${color}22)">
<button class="profile-popup-close" title="${t('modals.common.close')}">&times;</button>
</div>
<div class="profile-popup-avatar-wrapper">
${avatarHtml}
${this._pfpBorderMarker(profile.border, profile.borderTransform, profile.animateProfile)}
<span class="profile-popup-status-dot ${statusClass}" title="${statusLabel}"></span>
</div>
<div class="profile-popup-body">
<div class="profile-popup-names">
${currentNick ? `<span class="profile-popup-nickname">🏷️ ${this._escapeHtml(currentNick)}</span>` : ''}
<span class="profile-popup-displayname">${this._birthdayMarkHtml(profile.birthday)}${this._escapeHtml(profile.displayName)}</span>
<span class="profile-popup-username">@${this._escapeHtml(profile.username)}</span>
</div>
<div id="profile-popup-status-slot">${statusTextHtml}</div>
${bioHtml}
<div class="profile-popup-divider"></div>
<div id="profile-popup-activity-slot">${this._profileActivityHtml(profile.activity)}</div>
${rolesHtml ? `<div class="profile-popup-section-label">${t('users.profile_roles_label')}</div><div class="profile-popup-roles">${rolesHtml}</div>` : ''}
${this._badgeRowHtml(profile)}
${joinDate ? `<div class="profile-popup-section-label">${t('users.member_since_label')}</div><div class="profile-popup-join-date">${joinDate}${birthdayHtml}</div>` : ''}
<div class="profile-popup-actions">${actionsHtml}</div>
</div>
`;
// Hover mode: translucent, non-interactive preview (pointer-events:none via CSS)
if (this._isHoverPopup) popup.classList.add('profile-popup-hover');
document.body.appendChild(popup);
// Opening the profile card is a trigger context: flag the card so the freeze
// observer leaves its animated pfp (avatar and later-folded border) playing.
popup.dataset.animPlay = '1';
// Position near the anchor element
this._positionProfilePopup(popup);
// On the member's birthday the card opens to a shower of 🎉. A hover preview
// holds it until it is clicked open (_promoteHoverPopup).
if (this._daysUntilBirthday(profile.birthday) === 0) {
popup.dataset.birthday = '1';
if (!this._isHoverPopup) this._rainBirthdayConfetti(popup);
}
// Keep the music progress bar moving, and let presence updates refresh the
// card live (pause, resume, track change, clear) while it's open.
this._openProfileUserId = profile.id;
this._openProfileStatusKey = profile.statusText || '';
this._openProfileActivityKey = JSON.stringify(profile.activity || null);
this._startActivityProgress(popup);
// Hover mode is closed by the mouseover/mouseleave handlers; this is a safety
// net in case one of them misses. It only fires once the pointer has actually
// left the trigger. A fixed three-second timer closed the card while the
// mouse was still resting on the name, and the next nudge of the mouse opened
// it again, so it looked like it flickered on its own (#5608).
if (this._isHoverPopup) {
const stillHovering = () => {
const el = this._hoverTarget;
try { return !!(el && el.isConnected && el.matches(':hover')); } catch { return false; }
};
const check = () => {
if (!this._isHoverPopup) return;
if (stillHovering()) { this._hoverAutoCloseTimer = setTimeout(check, 1000); return; }
this._closeProfilePopup();
};
this._hoverAutoCloseTimer = setTimeout(check, 3000);
}
// Close button
popup.querySelector('.profile-popup-close').addEventListener('click', () => this._closeProfilePopup());
// Badges (#5632): click one to see what it is
this._bindBadgeRow(popup, profile);
// Gear: the unified user menu, same as right-click, anchored to the button
const gearBtnEl = popup.querySelector('.profile-gear-btn');
if (gearBtnEl) {
gearBtnEl.addEventListener('click', (e) => {
e.stopPropagation();
this._openUserMenuFromButton(gearBtnEl, profile.id, profile.username);
});
}
// Avatar → open the full-resolution image in the lightbox. Only real avatars
// are clickable; the letter fallback (a div) isn't an <img> so it's skipped.
// The card stays open behind the lightbox; the outside-click handler below
// ignores clicks that land on the lightbox so closing it keeps the card.
const avatarImgEl = popup.querySelector('img.profile-popup-avatar');
if (avatarImgEl) {
avatarImgEl.style.cursor = 'zoom-in';
avatarImgEl.addEventListener('click', (e) => {
e.stopPropagation();
this._openLightbox(profile.avatar);
});
}
// Bio toggle
const bioToggle = popup.querySelector('.profile-bio-toggle');
if (bioToggle) {
bioToggle.addEventListener('click', () => {
const short = popup.querySelector('.profile-bio-short');
const full = popup.querySelector('.profile-bio-full');
if (full.style.display === 'none') {
full.style.display = '';
short.style.display = 'none';
bioToggle.textContent = t('users.show_less');
} else {
full.style.display = 'none';
short.style.display = '';
bioToggle.textContent = t('users.view_full_bio');
}
});
}
// DM button
const dmBtnEl = popup.querySelector('.profile-dm-btn');
if (dmBtnEl) {
dmBtnEl.addEventListener('click', () => {
const targetId = parseInt(dmBtnEl.dataset.dmUid);
this.socket.emit('start-dm', { targetUserId: targetId });
this._closeProfilePopup();
this._showToast(t('users.opening_dm', { name: profile.displayName }), 'info');
});
}
// Edit profile button (for self)
const editBtnEl = popup.querySelector('#profile-popup-edit-btn');
if (editBtnEl) {
editBtnEl.addEventListener('click', () => {
this._closeProfilePopup();
// Open the Edit Profile (rename) modal which now includes avatar + display name + bio
this._openRenameModal();
});
}
// Close on outside click. Skipped for hover previews, which close on
// mouse-out instead.
if (!this._isHoverPopup) this._bindProfilePopupOutsideClose(popup);
},
// Outside-click close (delayed to avoid an instant close). Clicks on the image
// lightbox (opened by clicking the avatar) are ignored so dismissing the
// lightbox leaves the card open.
_bindProfilePopupOutsideClose(popup) {
setTimeout(() => {
this._profilePopupOutsideHandler = (e) => {
const lightbox = document.getElementById('image-lightbox');
if (lightbox && lightbox.contains(e.target)) return;
if (!popup.contains(e.target)) this._closeProfilePopup();
};
document.addEventListener('click', this._profilePopupOutsideHandler);
}, 50);
},
// Convert a hover preview into the full click card in place (no re-fetch)
_promoteHoverPopup(popup) {
this._isHoverPopup = false;
this._hoverTarget = null;
clearTimeout(this._hoverAutoCloseTimer);
clearTimeout(this._hoverFadeTimeout);
popup.classList.remove('profile-popup-hover', 'profile-popup-fading');
popup.style.pointerEvents = '';
// Re-run the entrance animation for the full card
popup.style.animation = 'none';
popup.offsetHeight; // force reflow
popup.style.animation = '';
this._bindProfilePopupOutsideClose(popup);
if (popup.dataset.birthday) this._rainBirthdayConfetti(popup);
},
_positionProfilePopup(popup) {
const anchor = this._profilePopupAnchor;
if (!anchor) {
// Center fallback
popup.style.left = '50%';
popup.style.top = '50%';
popup.style.transform = 'translate(-50%, -50%)';
return;
}
const rect = anchor.getBoundingClientRect();
// Measure the real rendered box. Width is a rem value in CSS and height varies
// with content, so hardcoded guesses under-clamped once font-size/zoom differed
// and the card spilled off-screen beside the member list (desktop app + web).
const pw = popup.offsetWidth;
const ph = popup.offsetHeight;
const margin = 8;
// Place the card to the right of the clicked element (the icon or the name).
// Flip to the left side only if it would overflow the right viewport edge.
let left = rect.right + margin;
if (left + pw > window.innerWidth - margin) {
const leftSide = rect.left - pw - margin;
left = leftSide >= margin ? leftSide : Math.max(margin, window.innerWidth - pw - margin);
}
// Vertically align the top of the card with the clicked element, then clamp
// so it stays fully on-screen in short viewports.
let top = Math.max(margin, Math.min(rect.top, window.innerHeight - ph - margin));
popup.style.left = left + 'px';
popup.style.top = top + 'px';
},
_closeProfilePopup() {
clearInterval(this._activityProgressTimer);
this._openProfileUserId = null;
const existing = document.getElementById('profile-popup');
if (existing) existing.remove();
if (this._profilePopupOutsideHandler) {
document.removeEventListener('click', this._profilePopupOutsideHandler);
this._profilePopupOutsideHandler = null;
}
// NOTE: _isHoverPopup is deliberately NOT reset here. It is only cleared by
// explicit user actions (click, promote, context menu). Resetting it on close
// raced an in-flight hover request: mouse-out closed the popup, the stale
// response then arrived with the flag off and rendered a permanent card.
this._hoverTarget = null;
clearTimeout(this._hoverCloseTimer);
clearTimeout(this._hoverAutoCloseTimer);
clearTimeout(this._hoverFadeTimeout);
},
// ── Voice Users ───────────────────────────────────────
_renderVoiceUsers(users, channelCode) {
// Track which channel this list belongs to so cached re-renders
// (stream info, nickname refresh, webcam status) preserve channel
// context for the self-injection guard below.
if (channelCode !== undefined) {
this._lastVoiceUsersChannel = channelCode;
}
// Belt-and-suspenders self-injection. The voice-users-update socket
// handler already injects the local user when we're in voice on the
// channel being rendered, but some call paths (re-render on stream
// info update, nickname refresh, channel switch) replay a cached
// _lastVoiceUsers that might pre-date our join, and other call paths
// pass through a stale list received before we appeared in the
// server roster. If we are in voice on THIS channel right now and the
// list doesn't include us, prepend ourselves so the right panel never
// shows the "Voice Connected" bar with the user absent from the
// participant list. Guarded by channelCode to avoid injecting self
// into a different channel's voice roster when we're viewing one
// channel but voice-connected to another. (#missing-self-voice-panel)
const renderChannel = (channelCode !== undefined)
? channelCode
: this._lastVoiceUsersChannel;
if (Array.isArray(users) && this.voice && this.voice.inVoice && this.user &&
renderChannel && renderChannel === this.voice.currentChannel) {
const myId = this.user.id;
if (myId != null && !users.some(u => u.id === myId)) {
users = [{
id: myId,
username: this.user.displayName || this.user.username,
roleColor: this.user.roleColor || null,
isMuted: !!this.voice.isMuted,
isDeafened: !!this.voice.isDeafened
}, ...users];
}
}
this._lastVoiceUsers = users; // Cache for re-render on stream info updates
const el = document.getElementById('voice-users');
if (users.length === 0) {
el.innerHTML = `<p class="muted-text">${t('right_sidebar.no_one_in_voice')}</p>`;
return;
}
const streams = this._streamInfo || [];
el.innerHTML = users.map(u => {
const isSelf = u.id === this.user.id;
const talking = this.voice && ((isSelf && this.voice.talkingState.get('self')) || this.voice.talkingState.get(u.id));
const dotColor = this._safeColor(u.roleColor);
// A gradient role fills the dot with its gradient; the talking glow
// keeps the role's first color.
const dotStyle = dotColor ? ` style="background:${this._roleFill(u, dotColor)};--voice-dot-color:${dotColor}"` : '';
// Stream indicators: is this user streaming? watching?
// We treat the user as streaming if EITHER the server-side `streams`
// payload lists them OR we've received a `screen-share-started` event
// for them. The server payload is only refreshed at certain hooks and
// could lag, so falling back on the live signaling avoids the bug
// where the icon didn't appear until the local user also shared.
const isStreamingByPayload = streams.some(s => s.sharerId === u.id);
const isStreamingBySignal = !!(this.voice && this.voice.screenSharers && this.voice.screenSharers.has(u.id));
const isStreaming = isStreamingByPayload || isStreamingBySignal;
const watchingStreams = streams.filter(s => s.viewers.some(v => v.id === u.id));
const isWatching = watchingStreams.length > 0;
// Webcam indicator
const hasWebcam = this.voice && this.voice.webcamUsers && this.voice.webcamUsers.has(u.id);
let streamBadge = '';
if (isStreaming) {
const myStream = streams.find(s => s.sharerId === u.id);
const viewers = myStream ? myStream.viewers : [];
const viewerCount = viewers.length;
// Compact the LIVE badge to a red dot + viewer count. The old
// "🔴 LIVE · N" text ate too much horizontal room in a narrow panel,
// squeezing out the username. The descriptive detail (viewer count
// plus who's watching) now lives in the hover tooltip. (#voice-declutter)
const liveLabel = viewerCount
? t(viewerCount === 1 ? 'users.streaming_viewers_one' : 'users.streaming_viewers_other', { count: viewerCount })
: t('users.streaming_no_viewers');
const viewerNames = viewers.map(v => v.username).join(', ');
// The badge is also the way back into a share you dismissed or did not
// auto-accept, so the tooltip says so (#5636).
const liveTitle = this._escapeHtml(`${viewerNames ? `${liveLabel}: ${viewerNames}` : liveLabel}. ${t('users.stream_click_to_watch')}`);
streamBadge = `<span class="voice-stream-badge live" title="${liveTitle}">🔴${viewerCount ? ' ' + viewerCount : ''}</span>`;
}
if (hasWebcam) {
streamBadge += `<span class="voice-stream-badge webcam" title="${t('users.camera_on')}">📹</span>`;
}
if (isWatching) {
const watchNames = watchingStreams.map(s => s.sharerName).join(', ');
const watchCount = watchingStreams.length;
streamBadge += `<span class="voice-stream-badge watching" title="${this._escapeHtml(t('users.watching_stream_title', { names: watchNames }))}">👁${watchCount > 1 ? ' ' + watchCount : ''}</span>`;
}
// Only render mic/speaker icons when they actually signal something,
// i.e. the user is muted or deafened. An unmuted, listening user shows
// no icons at all, so the roster stays legible instead of every row
// carrying two faded glyphs. The "you" tag is dropped too; people know
// who they are, and it was just more clutter. (#voice-declutter)
const statusIcons = [];
if (u.isMuted) statusIcons.push(`<span class="voice-status-icon is-muted" title="${t('voice.status_muted')}">🎙️</span>`);
if (u.isDeafened) statusIcons.push(`<span class="voice-status-icon is-deafened" title="${t('voice.status_deafened')}">🔊</span>`);
// Always rendered, even empty: its margin-left:auto is what pins the live
// badge to the right edge, so the badge stays put when the mute icon comes
// and goes and is not a moving target for someone on push to talk (#5636).
const statusIconsHtml = `<span class="voice-status-icons">${statusIcons.join('')}</span>`;
const botBadge = u.isBot ? '<span class="bot-badge">BOT</span>' : '';
return `
<div class="user-item voice-user-item${talking ? ' talking' : ''}" data-user-id="${u.id}" data-is-bot="${u.isBot ? 'true' : 'false'}"${dotColor ? ` style="--voice-dot-color:${dotColor}"` : ''}>
<span class="user-dot voice"${dotStyle}></span>
${this._birthdayMarkHtml(this._memberById(u.id)?.birthday)}
<span class="user-item-name"${this._nicknames[u.id] ? ` title="${this._escapeHtml(u.username)}"` : ''}>${this._escapeHtml(this._getNickname(u.id, u.username))}</span>
${botBadge}
${statusIconsHtml}
${streamBadge}
${isSelf || u.isBot ? '' : `<button class="voice-user-menu-btn" data-user-id="${u.id}" data-username="${this._escapeHtml(u.username)}" title="${t('users.more_actions')}">⋯</button>`}
</div>
`;
}).join('');
// Bind "..." buttons to open per-user voice submenu
el.querySelectorAll('.voice-user-menu-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const userId = parseInt(btn.dataset.userId);
const username = btn.dataset.username;
this._showVoiceUserMenu(btn, userId, username);
});
});
// Bind voice user names/items to open profile popup (same as sidebar)
el.querySelectorAll('.voice-user-item').forEach(item => {
const nameEl = item.querySelector('.user-item-name');
if (nameEl && item.dataset.isBot !== 'true') {
nameEl.style.cursor = 'pointer';
nameEl.addEventListener('click', (e) => {
e.stopPropagation();
const userId = parseInt(item.dataset.userId);
if (!isNaN(userId)) {
this._profilePopupAnchor = nameEl;
this.socket.emit('get-user-profile', { userId });
}
});
}
// Right-click on voice user → same options as "..." button
item.addEventListener('contextmenu', (e) => {
const userId = parseInt(item.dataset.userId);
if (isNaN(userId) || userId === this.user.id || item.dataset.isBot === 'true') return;
e.preventDefault();
e.stopPropagation();
const btn = item.querySelector('.voice-user-menu-btn');
const username = btn ? btn.dataset.username : '';
this._showVoiceUserMenu(btn || item, userId, username);
});
});
// Bind LIVE badges: clicking restores a hidden stream tile
el.querySelectorAll('.voice-stream-badge.live').forEach(badge => {
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
e.stopPropagation();
const userId = parseInt(badge.closest('.voice-user-item')?.dataset.userId);
if (isNaN(userId)) return;
this._watchStream(userId);
});
});
},
// Open someone's live stream from the voice list: the red badge and the
// Watch Stream menu entry both land here.
_watchStream(userId) {
const hiddenTile = document.querySelector(`#screen-tile-${userId}[data-hidden="true"]`);
if (hiddenTile) { this._showStreamTile(`screen-tile-${userId}`, userId); return; }
if (document.getElementById(`screen-tile-${userId}`)) return;
// With auto-accept off the share arrived and waited on the Join prompt.
// Once that prompt was gone, asking the sharer to resend brought the same
// stream back to the same prompt check, so the click only ever produced
// "Requesting stream". Clicking here is the accept: open what already
// arrived, or take the next arrival without the prompt (#5636).
const offered = this._pendingStreamOffers && this._pendingStreamOffers.get(userId);
if (offered && offered.getVideoTracks().some(tr => tr.readyState === 'live')) {
this._handleScreenStream(userId, offered, { force: true });
return;
}
this._pendingStreamOffers?.delete(userId);
if (!this._acceptedStreams) this._acceptedStreams = new Set();
this._acceptedStreams.add(userId);
if (!this.voice) return;
// Media may already be flowing into a receiver nobody rendered.
if (this.voice._deliverScreenFromReceivers?.(userId)) return;
// No tile at all (e.g. we joined after they went live and their stream
// never reached us, or we closed our view and the sharer's tile was
// since torn down), actively ask the sharer to (re)send. Arm the
// retry watchdog too: a single renegotiate request often loses the
// race (the sharer may be mid-signaling-change), which left the viewer
// stuck on "Requesting stream…" forever with no second attempt. The
// watchdog re-requests a few times until a live video track arrives.
// (#5426)
this.voice.requestScreenStream(userId);
this.voice._watchForScreenStream(userId);
this._showToast?.(t('voice.requesting_stream'), 'info');
},
_showVoiceUserMenu(anchorEl, userId, username) {
this._closeVoiceUserMenu();
const savedVol = this._getVoiceVolume(userId);
const isMuted = savedVol === 0;
const isDeafened = this.voice ? this.voice.isUserDeafened(userId) : false;
// Show voice kick for admins and mods with kick_user permission
const canKick = this._hasPerm('kick_user');
// Check if user is streaming and has a hidden tile we can restore
const streams = this._streamInfo || [];
const isStreaming = streams.some(s => s.sharerId === userId)
|| !!(this.voice && this.voice.screenSharers && this.voice.screenSharers.has(userId));
const hiddenTile = isStreaming ? document.querySelector(`#screen-tile-${userId}[data-hidden="true"]`) : null;
// Offer "Watch stream" whenever they're live and we don't already have a
// visible tile. This both restores a hidden tile and requests a stream we
// never received (late joiner).
const visibleTile = document.querySelector(`#screen-tile-${userId}:not([data-hidden="true"])`);
const canWatchStream = isStreaming && !visibleTile;
const menu = document.createElement('div');
menu.className = 'voice-user-menu';
menu.innerHTML = `
<div class="voice-user-menu-header">${this._escapeHtml(this._getNickname(userId, username))}</div>
<div class="voice-user-menu-row">
<span class="voice-user-menu-label">🔊 ${t('users.voice_menu.volume')}</span>
<input type="range" class="volume-slider voice-user-vol-slider" min="0" max="200" value="${savedVol}" title="${t('users.voice_menu.volume_title', { vol: savedVol })}">
<span class="voice-user-vol-value">${savedVol}%</span>
</div>
<div class="voice-user-menu-actions">
${canWatchStream ? `<button class="voice-user-menu-action" data-action="watch-stream">🖥 ${t('users.voice_menu.watch_stream')}</button>` : ''}
<button class="voice-user-menu-action" data-action="mute-user">${isMuted ? `🔊 ${t('users.voice_menu.unmute')}` : `🔇 ${t('users.voice_menu.mute')}`}</button>
<button class="voice-user-menu-action ${isDeafened ? 'active' : ''}" data-action="deafen-user">${isDeafened ? `🔊 ${t('users.voice_menu.undeafen')}` : `🔇 ${t('users.voice_menu.deafen')}`}</button>
${canKick ? `<button class="voice-user-menu-action danger" data-action="voice-kick" title="${t('users.voice_menu.voice_kick_title')}">🚪 ${t('users.voice_menu.voice_kick')}</button>` : ''}
</div>
<div class="voice-user-menu-hint">
<small>${t('users.voice_menu.mute_hint')}</small><br>
<small>${t('users.voice_menu.deafen_hint')}</small>
</div>
`;
document.body.appendChild(menu);
// Position
const rect = anchorEl.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left - 140}px`;
requestAnimationFrame(() => {
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = `${window.innerWidth - mr.width - 8}px`;
if (mr.bottom > window.innerHeight - 8) menu.style.top = `${rect.top - mr.height - 4}px`;
if (mr.left < 8) menu.style.left = '8px';
});
// Bind volume slider
const slider = menu.querySelector('.voice-user-vol-slider');
const volLabel = menu.querySelector('.voice-user-vol-value');
slider.addEventListener('input', () => {
const vol = parseInt(slider.value);
slider.title = t('users.voice_menu.volume_title', { vol });
volLabel.textContent = `${vol}%`;
this._setVoiceVolume(userId, vol);
if (this.voice) this.voice.setVolume(userId, vol / 100);
});
// Bind mute/deafen actions
menu.querySelectorAll('.voice-user-menu-action').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (btn.dataset.action === 'watch-stream') {
this._watchStream(userId);
this._closeVoiceUserMenu();
} else if (btn.dataset.action === 'mute-user') {
// Mute: toggle their volume to 0 so YOU can't hear THEM
const newVol = parseInt(slider.value) === 0 ? 100 : 0;
slider.value = newVol;
volLabel.textContent = `${newVol}%`;
this._setVoiceVolume(userId, newVol);
if (this.voice) this.voice.setVolume(userId, newVol / 100);
btn.textContent = newVol === 0 ? `🔊 ${t('users.voice_menu.unmute')}` : `🔇 ${t('users.voice_menu.mute')}`;
} else if (btn.dataset.action === 'deafen-user') {
// Deafen: stop sending YOUR audio to THEM (they can't hear you)
if (this.voice) {
if (this.voice.isUserDeafened(userId)) {
this.voice.undeafenUser(userId);
btn.textContent = `🔇 ${t('users.voice_menu.deafen')}`;
btn.classList.remove('active');
this._showToast(t('users.can_hear_again', { name: this._escapeHtml(username) }), 'info');
} else {
this.voice.deafenUser(userId);
btn.textContent = `🔊 ${t('users.voice_menu.undeafen')}`;
btn.classList.add('active');
this._showToast(t('users.cannot_hear', { name: this._escapeHtml(username) }), 'info');
}
}
} else if (btn.dataset.action === 'voice-kick') {
// Voice Kick: remove this user from voice (server enforces level check)
if (this.voice && this.voice.inVoice) {
this.socket.emit('voice-kick', { code: this.voice.currentChannel, userId });
this._closeVoiceUserMenu();
}
}
});
});
// Close on outside click
setTimeout(() => {
this._voiceUserMenuHandler = (e) => {
if (!menu.contains(e.target)) this._closeVoiceUserMenu();
};
document.addEventListener('click', this._voiceUserMenuHandler, true);
}, 10);
},
_closeVoiceUserMenu() {
const existing = document.querySelector('.voice-user-menu');
if (existing) existing.remove();
if (this._voiceUserMenuHandler) {
document.removeEventListener('click', this._voiceUserMenuHandler, true);
this._voiceUserMenuHandler = null;
}
},
_getVoiceVolume(userId) {
try {
const vols = JSON.parse(localStorage.getItem('haven_voice_volumes') || '{}');
return vols[userId] ?? 100;
} catch { return 100; }
},
_setVoiceVolume(userId, vol) {
try {
const vols = JSON.parse(localStorage.getItem('haven_voice_volumes') || '{}');
vols[userId] = vol;
localStorage.setItem('haven_voice_volumes', JSON.stringify(vols));
} catch { /* storage blocked or corrupt: the volume holds for this session only */ }
},
// ── Nicknames ─────────────────────────────────────────────
// Stored server-side in user_nicknames (synced on session-info).
// localStorage acts as a fast local cache only.
_getNickname(userId, fallbackUsername) {
if (userId && this._nicknames[userId]) return this._nicknames[userId];
return fallbackUsername;
},
_setNickname(userId, nickname) {
if (nickname && nickname.trim()) {
this._nicknames[userId] = nickname.trim();
} else {
delete this._nicknames[userId];
}
localStorage.setItem('haven_nicknames', JSON.stringify(this._nicknames));
// Mirror to server so the nickname survives across devices (#5394).
if (this.socket) {
this.socket.emit('set-nickname', { targetId: userId, nickname: nickname || null });
}
},
_showNicknameDialog(userId, currentUsername, currentDisplayName) {
const existing = this._nicknames[userId] || '';
const displayName = currentDisplayName || currentUsername;
// Members with Manage Display Names can flip this modal into editing the
// target's real (server-wide) display name instead of a private nickname.
const canManageNames = userId !== this.user?.id && this._hasPerm('manage_display_names');
const dialog = document.createElement('div');
dialog.className = 'modal-overlay';
dialog.style.display = 'flex';
dialog.style.zIndex = '100002';
dialog.innerHTML = `
<div class="modal" style="max-width:360px">
<h3 style="margin-top:0">${t('users.set_nickname_title')}</h3>
<p class="muted-text" style="margin:0 0 12px">${t('users.nickname_hint', { name: `<strong>${this._escapeHtml(currentUsername)}</strong>` })}</p>
<input type="text" id="nickname-input" class="modal-input" value="${this._escapeHtml(existing)}" placeholder="${this._escapeHtml(currentUsername)}" maxlength="32" style="width:100%;box-sizing:border-box">
${canManageNames ? `
<label class="toggle-row" style="margin-top:12px">
<span>${t('users.edit_display_name_toggle')}</span>
<input type="checkbox" id="nickname-global-toggle">
</label>
<p class="muted-text" id="nickname-global-note" style="display:none;margin:8px 0 0;padding:8px 10px;border-radius:6px;background:var(--bg-tertiary);border:1px solid var(--border-light);color:var(--text-secondary)">${t('users.edit_display_name_note')}</p>
` : ''}
<div class="modal-actions" style="margin-top:12px">
<button class="btn-sm" id="nickname-clear" style="${existing ? '' : 'display:none'}">${t('users.nickname_clear_btn')}</button>
<button class="btn-sm" id="nickname-cancel">${t('modals.common.cancel')}</button>
<button class="btn-sm btn-accent" id="nickname-save">${t('modals.common.save')}</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const input = dialog.querySelector('#nickname-input');
input.focus();
input.select();
const close = () => dialog.remove();
dialog.querySelector('#nickname-cancel').addEventListener('click', close);
dialog.addEventListener('click', (e) => { if (e.target === dialog) close(); });
const globalToggle = dialog.querySelector('#nickname-global-toggle');
const globalNote = dialog.querySelector('#nickname-global-note');
const clearBtn = dialog.querySelector('#nickname-clear');
const isGlobal = () => !!(globalToggle && globalToggle.checked);
if (globalToggle) {
globalToggle.addEventListener('change', () => {
const on = globalToggle.checked;
// Checked: prefill the target's current display name and reveal the
// warning. Unchecked: fall back to the private nickname exactly as before.
input.value = on ? displayName : existing;
input.maxLength = on ? 20 : 32;
globalNote.style.display = on ? '' : 'none';
clearBtn.style.display = (on || existing) ? '' : 'none';
clearBtn.textContent = on ? t('users.display_name_reset_btn') : t('users.nickname_clear_btn');
input.focus();
input.select();
});
}
clearBtn.addEventListener('click', () => {
if (isGlobal()) {
// Reset the target's display name back to their username
this.socket.emit('rename-user-global', { targetId: userId, displayName: '' });
this._showToast(t('users.display_name_reset'), 'info');
} else {
this._setNickname(userId, null);
this._refreshNicknameDisplays();
this._showToast(t('users.nickname_cleared'), 'info');
}
close();
});
dialog.querySelector('#nickname-save').addEventListener('click', () => {
const val = input.value.trim();
if (isGlobal()) {
this.socket.emit('rename-user-global', { targetId: userId, displayName: val });
this._showToast(t('users.display_name_updated', { name: val || displayName }), 'success');
} else {
this._setNickname(userId, val || null);
this._refreshNicknameDisplays();
if (val) {
this._showToast(t('users.nickname_set', { name: val }), 'success');
} else {
this._showToast(t('users.nickname_cleared'), 'info');
}
}
close();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') dialog.querySelector('#nickname-save').click();
if (e.key === 'Escape') close();
});
},
_refreshNicknameDisplays() {
// Re-render sidebar + voice to pick up nickname changes
if (this._lastOnlineUsers) this._renderOnlineUsers(this._lastOnlineUsers);
if (this._lastVoiceUsers) this._renderVoiceUsers(this._lastVoiceUsers);
// Update visible message author names in place
document.querySelectorAll('.message, .message-compact').forEach(el => {
const uid = parseInt(el.dataset.userId);
const realName = el.dataset.username;
if (uid && realName) {
const nick = this._getNickname(uid, realName);
const authorEl = el.querySelector('.message-author');
if (authorEl) {
// A gradient role name keeps its span; only the words change, with
// any emoji in them kept out of the gradient (#5720).
const gradient = authorEl.querySelector('.role-gradient');
if (gradient) gradient.innerHTML = this._roleEmojiSafe(this._escapeHtml(nick));
else authorEl.textContent = nick;
authorEl.title = nick !== realName ? realName : '';
}
}
});
// Close profile popup since data changed
this._closeProfilePopup();
},
};