mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 17:47:30 +00:00
1561 lines
75 KiB
JavaScript
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')}">×</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();
|
|
},
|
|
|
|
};
|