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

1700 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export default {
// ── User Context Menu (right-click → options) ──
_showUserContextMenu(e, targetUserId, targetNameOverride) {
this._hideUserContextMenu();
this._closeProfilePopup();
const menu = document.createElement('div');
menu.id = 'user-context-menu';
menu.className = 'user-context-menu';
// Find target username from online users; fall back to a caller-supplied name
// (e.g. the author name on a chat message, whose sender may be offline).
const targetUser = (this._lastOnlineUsers || []).find(u => u.id === targetUserId);
const targetName = targetUser ? targetUser.username : (targetNameOverride || 'User');
// Header with username
const header = document.createElement('div');
header.className = 'user-ctx-header';
header.textContent = targetName;
menu.appendChild(header);
// Helper: append a menu button. `danger` paints it red (destructive action).
const addBtn = (label, onClick, danger = false) => {
const btn = document.createElement('button');
btn.innerHTML = label;
if (danger) btn.classList.add('user-ctx-danger');
btn.addEventListener('click', onClick);
menu.appendChild(btn);
};
const addDivider = () => {
const div = document.createElement('div');
div.className = 'user-ctx-divider';
menu.appendChild(div);
};
// ── Section 1: profile / social (always available) ──
// View Profile
addBtn(`👤 ${t('context.view_profile')}`, () => {
this._hideUserContextMenu();
this._isHoverPopup = false;
this._profilePopupAnchor = e.target.closest('.user-item') || e.target;
this.socket.emit('get-user-profile', { userId: targetUserId });
});
// Only the admin can open this menu on themselves, and all it offers them
// besides their profile is their own roles. The rest targets other people.
if (targetUserId === this.user?.id) {
addBtn(`👑 ${t('users.gear_menu.role_management')}`, () => {
this._hideUserContextMenu();
this._openRoleAssignCenter(targetUserId);
});
addBtn(`🏅 ${t('badges.menu')}`, () => {
this._hideUserContextMenu();
this._openMemberBadges(targetUserId, targetName);
});
this._placeUserContextMenu(menu, e);
return;
}
// Direct Message
addBtn(`💬 ${t('users.direct_message')}`, () => {
this._hideUserContextMenu();
this.socket.emit('start-dm', { targetUserId });
this._showToast(t('users.opening_dm', { name: targetName }), 'info');
});
// Invite to Channel (submenu)
// Private channels are excluded for non-admins: regular members can't bypass
// the code requirement by using the right-click invite menu.
// Both is_private=1 and code_visibility='private' count as private here.
// Private channels are offered to whoever the server would actually accept an
// invite from: an admin, the channel's creator, or a moderator in that
// channel. canInvitePrivate is decided per channel server-side. Previously
// only admins saw them, so creators and channel mods had the permission and
// no button. (#5466)
const inviteChannels = (this.channels || []).filter(ch =>
!ch.is_dm && ch.name &&
((!ch.is_private && ch.code_visibility !== 'private') || this.user?.isAdmin || ch.canInvitePrivate)
);
if (inviteChannels.length > 0) {
const inviteItem = document.createElement('div');
inviteItem.className = 'user-ctx-submenu-wrapper';
const inviteBtn = document.createElement('button');
inviteBtn.className = 'user-ctx-submenu-trigger';
inviteBtn.innerHTML = `📨 ${t('context.invite_to_channel')} <span class="user-ctx-arrow">▸</span>`;
inviteItem.appendChild(inviteBtn);
const submenu = document.createElement('div');
submenu.className = 'user-ctx-submenu';
for (const ch of inviteChannels) {
const chBtn = document.createElement('button');
chBtn.textContent = `# ${ch.name}`;
chBtn.title = ch.topic || ch.name;
chBtn.addEventListener('click', () => {
this.socket.emit('invite-to-channel', {
targetUserId,
channelId: ch.id
});
this._hideUserContextMenu();
});
submenu.appendChild(chBtn);
}
// Flip submenu left if it would overflow viewport right edge
inviteItem.addEventListener('mouseenter', () => {
const wrapRect = inviteItem.getBoundingClientRect();
const submenuWidth = Math.max(submenu.scrollWidth || 0, 180);
if (wrapRect.right + submenuWidth > window.innerWidth) {
submenu.style.left = 'auto';
submenu.style.right = '100%';
} else {
submenu.style.left = '100%';
submenu.style.right = 'auto';
}
});
inviteItem.appendChild(submenu);
menu.appendChild(inviteItem);
}
// Set Nickname
addBtn(`🏷️ ${t('users.set_nickname')}`, () => {
this._hideUserContextMenu();
this._showNicknameDialog(targetUserId, targetName, targetName);
});
// ── Moderation sections: same gating the old gear menu used ──
const isAdmin = this.user.isAdmin;
const canMod = isAdmin || this._canModerate();
const canPromote = this._hasPerm('promote_user');
// The server has always accepted ban_user; a moderator who holds it should
// see Ban even when below the level-25 _canModerate() threshold. (v3.43.0)
const canBan = isAdmin || this._hasPerm('ban_user');
// "Add to Channel" mirrors the invite filter but also skips sub-channels and
// never targets yourself. Its own picker validates membership server-side.
// It used to live inside the mod-only gear menu, so it stays gated on the same
// mod-ish powers; regular members use "Invite to Channel" above instead.
const addToChannelList = (this.channels || []).filter(ch =>
!ch.is_dm && ch.name && !ch.parent_channel_id &&
((!ch.is_private && ch.code_visibility !== 'private') || isAdmin || ch.canInvitePrivate)
);
const canAddToChannel = (canMod || canPromote || canBan) && addToChannelList.length > 0 && targetUserId !== this.user?.id;
// Hand-given badges (#5632); the server checks rank.
const canBadge = this._canManageBadges();
// ── Section 2: role / channel management ──
if (canPromote || canAddToChannel || canBadge) {
addDivider();
if (canPromote) addBtn(`👑 ${t('users.gear_menu.role_management')}`, () => {
this._hideUserContextMenu();
this._openRoleAssignCenter(targetUserId);
});
if (canBadge) addBtn(`🏅 ${t('badges.menu')}`, () => {
this._hideUserContextMenu();
this._openMemberBadges(targetUserId, targetName);
});
if (canAddToChannel) addBtn(`➕ ${t('users.gear_menu.add_to_channel')}`, () => {
this._hideUserContextMenu();
this._openMemberChannelPicker(targetUserId, targetName, 'add', addToChannelList);
});
}
// Mute for me: their messages stop notifying you (app-server-mute.js).
if (targetUserId !== this.user?.id) {
const _mutedForMe = this._mutedUserIds?.().has(Number(targetUserId));
addBtn(`${_mutedForMe ? '🔔' : '🔕'} ${t(_mutedForMe ? 'mute.unmute_member' : 'mute.mute_member')}`, () => {
this._hideUserContextMenu();
this._toggleUserMute(targetUserId, targetName);
});
}
// ── Section 3: moderation (kick / mute / ban / delete / transfer) ──
// Admin password reset stays opt-in: hidden unless the server setting is on
// and the target isn't yourself (#5300).
const canResetPassword = isAdmin && this.serverSettings?.admin_password_reset_enabled === 'true' && targetUserId !== this.user?.id;
if (canMod || canBan || isAdmin) {
addDivider();
if (canMod) addBtn(`👢 ${t('users.gear_menu.kick')}`, () => {
this._hideUserContextMenu();
this._showAdminActionModal('kick', targetUserId, targetName);
});
if (canMod) addBtn(`⏳ ${t('users.gear_menu.mute')}`, () => {
this._hideUserContextMenu();
this._showAdminActionModal('mute', targetUserId, targetName);
});
if (canMod) addBtn(`⌛ ${t('users.gear_menu.unmute')}`, () => {
this._hideUserContextMenu();
this.socket.emit('unmute-user', { userId: targetUserId });
});
// Moderation history (#5759): a warning with a reason, notes, and the
// record of everything done to this member.
if ((canMod || canBan) && targetUserId !== this.user?.id) addBtn(`⚠️ ${t('mod_cases.warn')}`, () => {
this._hideUserContextMenu();
this._openWarnDialog(targetUserId, targetName);
});
if ((canMod || canBan || this._hasPerm('view_audit_log')) && targetUserId !== this.user?.id) addBtn(`📋 ${t('mod_cases.history')}`, () => {
this._hideUserContextMenu();
this._openModHistory(targetUserId, targetName);
});
if (canBan) addBtn(`⛔ ${t('users.gear_menu.ban')}`, () => {
this._hideUserContextMenu();
this._showAdminActionModal('ban', targetUserId, targetName);
}, true);
if (isAdmin) addBtn(`🗑️ ${t('users.gear_menu.delete_user')}`, () => {
this._hideUserContextMenu();
this._showAdminActionModal('delete-user', targetUserId, targetName);
}, true);
if (canResetPassword) addBtn(`🔑 ${t('users.gear_menu.reset_password')}`, () => {
this._hideUserContextMenu();
this._confirmAdminResetPassword(targetUserId, targetName);
}, true);
if (isAdmin) addBtn(`🔑 ${t('users.gear_menu.transfer_admin')}`, () => {
this._hideUserContextMenu();
this._confirmTransferAdmin(targetUserId, targetName);
}, true);
}
this._addGroupMemberActions(targetUserId, targetName, addBtn, addDivider);
this._placeUserContextMenu(menu, e);
},
_placeUserContextMenu(menu, e) {
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
document.body.appendChild(menu);
// Clamp to viewport
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 8) + 'px';
if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 8) + 'px';
// Close on click elsewhere
const closer = (ev) => {
if (!menu.contains(ev.target)) {
this._hideUserContextMenu();
document.removeEventListener('click', closer, true);
document.removeEventListener('contextmenu', closer, true);
}
};
setTimeout(() => {
document.addEventListener('click', closer, true);
document.addEventListener('contextmenu', closer, true);
}, 0);
},
_hideUserContextMenu() {
const existing = document.getElementById('user-context-menu');
if (existing) existing.remove();
},
// ═══════════════════════════════════════════════════════
// ONLINE OVERLAY (status bar popup)
// ═══════════════════════════════════════════════════════
_setupOnlineOverlay() {
const trigger = document.getElementById('status-online-trigger');
const overlay = document.getElementById('online-overlay');
const closeBtn = document.getElementById('online-overlay-close');
if (!trigger || !overlay) return;
trigger.style.cursor = 'pointer';
trigger.addEventListener('click', () => {
const isOpen = overlay.style.display !== 'none';
if (isOpen) {
overlay.style.display = 'none';
return;
}
this._renderOnlineOverlay();
overlay.style.display = '';
// Position above the trigger
const rect = trigger.getBoundingClientRect();
overlay.style.left = rect.left + 'px';
overlay.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
});
if (closeBtn) {
closeBtn.addEventListener('click', () => { overlay.style.display = 'none'; });
}
// Close on outside click
document.addEventListener('click', (e) => {
if (overlay.style.display === 'none') return;
if (!overlay.contains(e.target) && !trigger.contains(e.target)) {
overlay.style.display = 'none';
}
});
},
_renderOnlineOverlay() {
const list = document.getElementById('online-overlay-list');
if (!list) return;
const users = this._lastOnlineUsers || [];
if (users.length === 0) {
list.innerHTML = `<p class="muted-text" style="padding:8px">${t('context.no_users')}</p>`;
return;
}
const online = users.filter(u => u.online !== false);
const offline = users.filter(u => u.online === false);
let html = '';
if (online.length > 0) {
html += `<div class="online-overlay-group">${t('users.online_count', { count: online.length })}</div>`;
html += online.map(u => this._renderOverlayUserItem(u)).join('');
}
if (offline.length > 0) {
html += `<div class="online-overlay-group offline">${t('users.offline_count', { count: offline.length })}</div>`;
html += offline.map(u => this._renderOverlayUserItem(u)).join('');
}
list.innerHTML = html;
},
_renderOverlayUserItem(u) {
const initial = this._escapeHtml((u.username || '?')[0].toUpperCase());
// Member list entries carry their role as `role`; roleColor is the flat
// shape some older callers used.
const role = u.role || (u.roleColor ? u : null);
const roleColor = role ? (role.color !== undefined ? role.color : role.roleColor) : null;
const color = this._safeColor(roleColor || u.avatarColor, '#7c5cfc');
const statusClass = u.online !== false ? 'online' : 'offline';
const avatar = u.avatarUrl
? `<img src="${this._escapeHtml(u.avatarUrl)}" class="online-overlay-avatar-img" alt="">`
: `<div class="online-overlay-avatar" style="background:${color}">${initial}</div>`;
const nameColor = this._safeColor(roleColor) ? ` style="color:${this._safeColor(roleColor)}"` : '';
return `<div class="online-overlay-user ${statusClass}">
${avatar}
<span class="online-overlay-username"${nameColor}>${this._roleNameHtml(role, this._getNickname(u.id, u.username))}</span>
<span class="online-overlay-status-dot ${statusClass}"></span>
</div>`;
},
// ═══════════════════════════════════════════════════════
// NOTIFICATIONS
// ═══════════════════════════════════════════════════════
_setupNotifications() {
const toggle = document.getElementById('notif-enabled');
const voiceActionCuesToggle = document.getElementById('notif-voice-action-cues-enabled');
const volume = document.getElementById('notif-volume');
const msgSound = document.getElementById('notif-msg-sound');
const mentionVolume = document.getElementById('notif-mention-volume');
const mentionSound = document.getElementById('notif-mention-sound');
const replyVolume = document.getElementById('notif-reply-volume');
const replySound = document.getElementById('notif-reply-sound');
const sentSound = document.getElementById('notif-sent-sound');
const joinVolume = document.getElementById('notif-join-volume');
const joinSound = document.getElementById('notif-join-sound');
const leaveVolume = document.getElementById('notif-leave-volume');
const leaveSound = document.getElementById('notif-leave-sound');
toggle.checked = this.notifications.enabled;
if (voiceActionCuesToggle) voiceActionCuesToggle.checked = this.notifications.voiceActionCuesEnabled;
volume.value = this.notifications.volume * 100;
msgSound.value = this.notifications.sounds.message;
if (sentSound) sentSound.value = this.notifications.sounds.sent;
mentionVolume.value = this.notifications.mentionVolume * 100;
mentionSound.value = this.notifications.sounds.mention;
if (replyVolume) replyVolume.value = this.notifications.replyVolume * 100;
if (replySound) replySound.value = this.notifications.sounds.reply;
if (joinVolume) joinVolume.value = this.notifications.joinVolume * 100;
if (joinSound) joinSound.value = this.notifications.sounds.join;
if (leaveVolume) leaveVolume.value = this.notifications.leaveVolume * 100;
if (leaveSound) leaveSound.value = this.notifications.sounds.leave;
// Per-type toggles
const mentionsToggle = document.getElementById('notif-mentions-enabled');
const repliesToggle = document.getElementById('notif-replies-enabled');
const dmToggle = document.getElementById('notif-dm-enabled');
// Mute this server: app-server-mute.js wires the choice.
if (mentionsToggle) { mentionsToggle.checked = this.notifications.mentionsEnabled; mentionsToggle.addEventListener('change', () => { this.notifications.mentionsEnabled = mentionsToggle.checked; this.notifications._savePref('haven_notif_mentions_enabled', mentionsToggle.checked); }); }
const roleMentionsToggle = document.getElementById('notif-role-mentions-enabled');
if (roleMentionsToggle) { roleMentionsToggle.checked = this.notifications.roleMentionsEnabled !== false; roleMentionsToggle.addEventListener('change', () => { this.notifications.roleMentionsEnabled = roleMentionsToggle.checked; this.notifications._savePref('haven_notif_role_mentions_enabled', roleMentionsToggle.checked); }); }
if (repliesToggle) { repliesToggle.checked = this.notifications.repliesEnabled; repliesToggle.addEventListener('change', () => { this.notifications.repliesEnabled = repliesToggle.checked; this.notifications._savePref('haven_notif_replies_enabled', repliesToggle.checked); }); }
if (dmToggle) { dmToggle.checked = this.notifications.dmEnabled; dmToggle.addEventListener('change', () => { this.notifications.dmEnabled = dmToggle.checked; this.notifications._savePref('haven_notif_dm_enabled', dmToggle.checked); }); }
const popupCooldownSel = document.getElementById('notif-popup-cooldown');
if (popupCooldownSel) {
// Presets, Never, or a number of minutes typed in (#5619). A stored gap
// that matches no preset shows as Custom with its minutes filled in.
const customRow = document.getElementById('notif-popup-custom-row');
const customMin = document.getElementById('notif-popup-custom-minutes');
const current = this.notifications.popupCooldownMs || 0;
if ([...popupCooldownSel.options].some(o => o.value === String(current))) {
popupCooldownSel.value = String(current);
} else {
popupCooldownSel.value = 'custom';
if (customMin) customMin.value = String(Math.max(1, Math.round(current / 60000)));
}
const syncRow = () => { if (customRow) customRow.style.display = popupCooldownSel.value === 'custom' ? '' : 'none'; };
const applyCustom = () => {
const mins = Math.min(1440, Math.max(1, parseInt(customMin && customMin.value, 10) || 0));
if (customMin) customMin.value = String(mins);
this.notifications.setPopupCooldownMs(mins * 60000);
};
syncRow();
popupCooldownSel.addEventListener('change', () => {
syncRow();
// A push from the server always shows as a pop-up, so Never has to turn
// push off on this device too, or DMs kept popping up anyway (#5693).
if (popupCooldownSel.value === '-1' && this._pushSubscription) {
this._unsubscribePush().then(() => {
const pushToggle = document.getElementById('push-notif-enabled');
if (pushToggle) pushToggle.checked = false;
});
}
if (popupCooldownSel.value !== 'custom') { this.notifications.setPopupCooldownMs(popupCooldownSel.value); return; }
if (customMin) { if (!customMin.value) customMin.value = '10'; applyCustom(); customMin.focus(); }
});
if (customMin) customMin.addEventListener('change', applyCustom);
}
toggle.addEventListener('change', () => {
this.notifications.setEnabled(toggle.checked);
// Push on this device follows the same choice.
this._syncPushScope();
});
if (voiceActionCuesToggle) {
voiceActionCuesToggle.addEventListener('change', () => {
this.notifications.setVoiceActionCuesEnabled(voiceActionCuesToggle.checked);
});
}
volume.addEventListener('input', () => {
this.notifications.setVolume(volume.value / 100);
});
msgSound.addEventListener('change', () => {
this.notifications.setSound('message', msgSound.value);
// Preview even while the Notifications toggle is off, which is the default.
this.notifications.play('message', { preview: true });
});
if (sentSound) {
sentSound.addEventListener('change', () => {
this.notifications.setSound('sent', sentSound.value);
this.notifications.play('sent');
});
}
mentionVolume.addEventListener('input', () => {
this.notifications.setMentionVolume(mentionVolume.value / 100);
});
mentionSound.addEventListener('change', () => {
this.notifications.setSound('mention', mentionSound.value);
this.notifications.play('mention'); // Preview the selected sound
});
if (replyVolume) {
replyVolume.addEventListener('input', () => {
this.notifications.setReplyVolume(replyVolume.value / 100);
});
}
if (replySound) {
replySound.addEventListener('change', () => {
this.notifications.setSound('reply', replySound.value);
this.notifications.play('reply');
});
}
if (joinVolume) {
joinVolume.addEventListener('input', () => {
this.notifications.setJoinVolume(joinVolume.value / 100);
});
}
if (joinSound) {
joinSound.addEventListener('change', () => {
this.notifications.setSound('join', joinSound.value);
this.notifications.play('join');
});
}
if (leaveVolume) {
leaveVolume.addEventListener('input', () => {
this.notifications.setLeaveVolume(leaveVolume.value / 100);
});
}
if (leaveSound) {
leaveSound.addEventListener('change', () => {
this.notifications.setSound('leave', leaveSound.value);
this.notifications.play('leave');
});
}
const autoAcceptToggle = document.getElementById('auto-accept-streams');
if (autoAcceptToggle) {
autoAcceptToggle.checked = localStorage.getItem('haven_auto_accept_streams') !== 'false';
autoAcceptToggle.addEventListener('change', () => {
localStorage.setItem('haven_auto_accept_streams', String(autoAcceptToggle.checked));
});
}
// Hide voice panel (opt-in)
const hideVoicePanelToggle = document.getElementById('hide-voice-panel');
if (hideVoicePanelToggle) {
hideVoicePanelToggle.checked = localStorage.getItem('haven_hide_voice_panel') === 'true';
hideVoicePanelToggle.addEventListener('change', () => {
localStorage.setItem('haven_hide_voice_panel', String(hideVoicePanelToggle.checked));
const voicePanel = document.getElementById('right-sidebar-voice');
if (voicePanel) voicePanel.style.display = hideVoicePanelToggle.checked ? 'none' : '';
});
// Apply on load
if (hideVoicePanelToggle.checked) {
const voicePanel = document.getElementById('right-sidebar-voice');
if (voicePanel) voicePanel.style.display = 'none';
}
}
// Sidebar voice controls (opt-in)
const sidebarVoiceToggle = document.getElementById('sidebar-voice-controls');
if (sidebarVoiceToggle) {
sidebarVoiceToggle.checked = localStorage.getItem('haven_sidebar_voice_controls') === 'true';
sidebarVoiceToggle.addEventListener('change', () => {
localStorage.setItem('haven_sidebar_voice_controls', String(sidebarVoiceToggle.checked));
// Re-apply button visibility for current voice state
if (this.voice && this.voice.inVoice) {
this._updateVoiceButtons(true);
}
});
}
// Up arrow edits last message (on by default)
const upArrowEditToggle = document.getElementById('up-arrow-edit');
if (upArrowEditToggle) {
upArrowEditToggle.checked = localStorage.getItem('haven_up_arrow_edit') !== 'false';
upArrowEditToggle.addEventListener('change', () => {
localStorage.setItem('haven_up_arrow_edit', String(upArrowEditToggle.checked));
});
}
// Profile card on hover (on by default). The hover handler reads the flag
// live, so no reload is needed.
const hoverCardToggle = document.getElementById('hover-profile-card');
if (hoverCardToggle) {
hoverCardToggle.checked = localStorage.getItem('haven_hover_profile_card') !== 'false';
hoverCardToggle.addEventListener('change', () => {
localStorage.setItem('haven_hover_profile_card', String(hoverCardToggle.checked));
});
}
// DM single-click default: open fullscreen DM instead of PiP. (#5295)
const dmFsToggle = document.getElementById('dm-fullscreen-default');
if (dmFsToggle) {
dmFsToggle.checked = localStorage.getItem('haven_dm_fullscreen_default') === 'true';
dmFsToggle.addEventListener('change', () => {
localStorage.setItem('haven_dm_fullscreen_default', String(dmFsToggle.checked));
});
}
// Show status bar. Off by default in a browser, on by default in the
// Desktop app, where it is the window's footer; the toggle is honoured in
// both. It used to be ignored on Desktop, so the switch sat unticked while
// the bar stayed up (#5647).
const showStatusBarToggle = document.getElementById('show-status-bar');
const statusBarToggleTab = document.getElementById('status-bar-toggle');
if (showStatusBarToggle) {
showStatusBarToggle.checked = this._statusBarWanted();
const applyStatusBar = () => {
const show = showStatusBarToggle.checked;
const sb = document.getElementById('status-bar');
if (show) {
document.documentElement.removeAttribute('data-hide-statusbar');
if (sb) sb.style.setProperty('display', 'flex', 'important');
} else {
document.documentElement.setAttribute('data-hide-statusbar', '1');
// The show branch sets an INLINE `display: flex !important`, and inline
// !important outranks the stylesheet's `[data-hide-statusbar] .status-bar
// { display: none !important }`. Leaving it in place meant the bar could
// be shown once and then never hidden again: the attribute flipped, the
// checkbox unchecked, and the bar stayed on screen regardless.
if (sb) sb.style.removeProperty('display');
}
};
showStatusBarToggle.addEventListener('change', () => {
localStorage.setItem('haven_show_statusbar', String(showStatusBarToggle.checked));
applyStatusBar();
});
applyStatusBar();
}
// Toggle tab (visible when bar is hidden): click to show bar
if (statusBarToggleTab) {
statusBarToggleTab.addEventListener('click', () => {
if (showStatusBarToggle) {
showStatusBarToggle.checked = true;
showStatusBarToggle.dispatchEvent(new Event('change'));
} else {
// Fallback: toggle directly
document.documentElement.removeAttribute('data-hide-statusbar');
const sb = document.getElementById('status-bar');
if (sb) sb.style.setProperty('display', 'flex', 'important');
localStorage.setItem('haven_show_statusbar', 'true');
}
});
}
// Hide the Send button for people who only ever press Enter (#5654).
const hideSendToggle = document.getElementById('hide-send-btn');
if (hideSendToggle) {
const applyHideSend = () => document.documentElement.toggleAttribute('data-hide-send-btn', hideSendToggle.checked);
hideSendToggle.checked = localStorage.getItem('haven_hide_send_btn') === 'true';
hideSendToggle.addEventListener('change', () => {
localStorage.setItem('haven_hide_send_btn', String(hideSendToggle.checked));
applyHideSend();
});
applyHideSend();
}
// Fold the toolbar into one + button (#5654).
const compactToggle = document.getElementById('compact-composer');
if (compactToggle) {
const applyCompact = () => {
document.documentElement.toggleAttribute('data-compact-composer', compactToggle.checked);
if (!compactToggle.checked) this._closeComposerMenu?.();
};
compactToggle.checked = localStorage.getItem('haven_compact_composer') === 'true';
compactToggle.addEventListener('change', () => {
localStorage.setItem('haven_compact_composer', String(compactToggle.checked));
applyCompact();
});
applyCompact();
}
// ── Score badge visibility ──
// "Hide other players' badges" is a per-device client-side filter.
// "Hide my own badge" is a server-side preference so other clients also
// hide it (and it gets stripped from the leaderboard).
const hideOtherScoresToggle = document.getElementById('hide-other-scores');
if (hideOtherScoresToggle) {
hideOtherScoresToggle.checked = localStorage.getItem('haven_hide_other_scores') === 'true';
hideOtherScoresToggle.addEventListener('change', () => {
localStorage.setItem('haven_hide_other_scores', String(hideOtherScoresToggle.checked));
if (this._lastOnlineUsers) this._renderOnlineUsers(this._lastOnlineUsers);
});
}
const hideNsfwToggle = document.getElementById('hide-nsfw-channels');
if (hideNsfwToggle) {
hideNsfwToggle.checked = localStorage.getItem('haven_hide_nsfw') === 'true';
hideNsfwToggle.addEventListener('change', () => this._setHideNsfw?.(hideNsfwToggle.checked));
}
// The blur on an NSFW topic is on unless switched off (#5633).
const blurNsfwToggle = document.getElementById('blur-nsfw-topics');
if (blurNsfwToggle) {
blurNsfwToggle.checked = localStorage.getItem('haven_blur_nsfw') !== 'false';
blurNsfwToggle.addEventListener('change', () => {
try { localStorage.setItem('haven_blur_nsfw', blurNsfwToggle.checked ? 'true' : 'false'); } catch { /* storage blocked (private mode): nothing is remembered, nothing else breaks */ }
if (this._forumActive && this._forumReload) this._forumReload();
});
}
this._setupSettingsSearch?.();
const hideOwnScoreToggle = document.getElementById('hide-own-score');
if (hideOwnScoreToggle) {
// Initial value comes from the server-synced preferences cache, falling
// back to localStorage so the toggle reflects intent before prefs land.
const cached = (this._userPrefs && this._userPrefs.hide_score_badge) || localStorage.getItem('haven_hide_own_score');
hideOwnScoreToggle.checked = cached === 'true';
hideOwnScoreToggle.addEventListener('change', () => {
const v = String(hideOwnScoreToggle.checked);
localStorage.setItem('haven_hide_own_score', v);
if (this._userPrefs) this._userPrefs.hide_score_badge = v;
this.socket?.emit('set-preference', { key: 'hide_score_badge', value: v });
// Re-render immediately so the badge appears/disappears without waiting
// for the next organic online-users broadcast.
if (this._lastOnlineUsers) this._renderOnlineUsers(this._lastOnlineUsers);
});
}
// ── Activity sharing (rich presence) ──
// All three are server-side preferences because other people's clients need
// to honour them. The master switch is opt-in; the sub-toggles default on
// but do nothing until the master is enabled, so the UI hides them until
// then rather than showing controls that have no effect.
const shareActivityToggle = document.getElementById('share-activity');
const shareGameToggle = document.getElementById('share-game-activity');
const shareMusicToggle = document.getElementById('share-music-activity');
const activitySubOptions = document.getElementById('activity-suboptions');
const syncActivityUI = () => {
const prefs = this._userPrefs || {};
// Mirrors the server's read in activity.js prefsFor(): absent = on.
const master = prefs.share_activity !== 'false';
if (shareActivityToggle) shareActivityToggle.checked = master;
// Absent sub-preference means "on", which matches the server's read of it.
if (shareGameToggle) shareGameToggle.checked = prefs.share_game_activity !== 'false';
if (shareMusicToggle) shareMusicToggle.checked = prefs.share_music_activity !== 'false';
if (activitySubOptions) activitySubOptions.style.display = master ? '' : 'none';
// Keep the quick toggles in the status picker in step with this section.
this._syncStatusPickerActivity?.();
};
this._syncActivityUI = syncActivityUI;
syncActivityUI();
const bindActivityToggle = (el, key) => {
if (!el) return;
el.addEventListener('change', () => {
const v = String(el.checked);
if (this._userPrefs) this._userPrefs[key] = v;
this.socket?.emit('set-preference', { key, value: v });
syncActivityUI();
});
};
bindActivityToggle(shareActivityToggle, 'share_activity');
bindActivityToggle(shareGameToggle, 'share_game_activity');
bindActivityToggle(shareMusicToggle, 'share_music_activity');
// Ask for the linked-account list whenever settings are wired up; the
// response also tells us which providers this server actually has
// credentials for, so we don't offer a button that can only fail.
this.socket?.emit('get-connections');
// ── Listening presence (any music player) ──
// A single on/off switch: on generates a webhook token a player posts to,
// off removes it. The read-only URL box is only shown once we have a token.
const listeningToggle = document.getElementById('listening-enabled');
const listeningUrlRow = document.getElementById('listening-url-row');
const listeningUrlInput = document.getElementById('listening-url');
// Selecting the whole URL on focus makes copy-paste one gesture.
listeningUrlInput?.addEventListener('focus', () => listeningUrlInput.select());
this._applyListeningState = (token) => {
const on = !!token;
if (listeningToggle) listeningToggle.checked = on;
if (listeningUrlInput) listeningUrlInput.value = on ? `${location.origin}/api/webhooks/listening/${token}` : '';
if (listeningUrlRow) listeningUrlRow.hidden = !on;
};
listeningToggle?.addEventListener('change', () => {
this.socket?.emit('set-listening', { enabled: listeningToggle.checked });
});
this.socket?.emit('get-listening');
// Coming back from a Steam/Spotify redirect? Report the outcome once.
this._handleConnectRedirect?.();
// ── Server URL in status bar (copyable, privacy toggle) ──
const statusUrlEl = document.getElementById('status-url-text');
const statusUrlToggle = document.getElementById('status-url-toggle');
if (statusUrlEl && statusUrlToggle) {
// Start from where this browser connected, then ask the server for the
// address other people could actually use. For whoever runs the server
// that is the difference between "localhost:3000" and something worth
// copying. (#status-bar)
let origin = window.location.origin;
const urlItem = document.getElementById('status-url-item');
const isLoopback = (u) => /^https?:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)(:|$)/i.test(u || '');
// Always start hidden each session; the address is only revealed after
// an explicit click, and that choice is intentionally NOT persisted so it
// resets to hidden every time the app (re)loads. (privacy default)
let urlVisible = false;
const applyUrlVis = () => {
if (urlVisible) {
statusUrlEl.textContent = origin;
statusUrlEl.classList.remove('url-hidden');
statusUrlToggle.textContent = '👁';
statusUrlToggle.title = t('context.hide_server_address');
} else {
statusUrlEl.textContent = '••••••••';
statusUrlEl.classList.add('url-hidden');
statusUrlToggle.textContent = '👁\u200d🗨';
statusUrlToggle.title = t('context.show_server_address');
}
};
applyUrlVis();
// Swap in the shareable address once the server reports it. `origin` is
// read at call time by both the toggle and the copy handler, so they pick
// this up without rewiring anything.
this._fetchShareOrigin().then(url => {
if (url) {
origin = url;
applyUrlVis();
} else if (isLoopback(origin) && urlItem) {
// Nothing shareable exists and the local address is no use to
// anyone else, so hide the widget rather than offer to copy
// localhost.
urlItem.style.display = 'none';
}
});
statusUrlToggle.addEventListener('click', () => {
urlVisible = !urlVisible;
applyUrlVis();
});
// Click to copy: works even when URL is hidden.
// navigator.clipboard.writeText() fails silently in Electron's BrowserView,
// so fall back to a hidden-textarea execCommand('copy') like the other
// copy buttons do. (#182)
const _flashCopied = () => {
statusUrlEl.textContent = t('common.copied');
setTimeout(() => { statusUrlEl.textContent = urlVisible ? origin : '••••••••'; }, 1500);
};
const _fallbackCopy = (text) => {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
_flashCopied();
} catch { /* could not copy: the Copied flash simply does not show */ }
};
statusUrlEl.addEventListener('click', () => {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(origin).then(_flashCopied).catch(() => _fallbackCopy(origin));
} else {
_fallbackCopy(origin);
}
});
}
},
// ── Push Notifications (Web Push API) ──────────────────
async _setupPushNotifications() {
const toggle = document.getElementById('push-notif-enabled');
const statusEl = document.getElementById('push-notif-status');
// Haven Desktop provides native OS notifications via app-preload.js, so hide the web-push section entirely
if (window.havenDesktop?.isDesktopApp) {
const section = document.getElementById('section-push');
if (section) section.style.display = 'none';
const navItem = document.querySelector('.settings-nav-item[data-target="section-push"]');
if (navItem) navItem.style.display = 'none';
if (toggle) toggle.disabled = true;
if (statusEl) statusEl.textContent = t('context.push_native_desktop');
return;
}
// Wire dismiss button for push error modal
document.getElementById('push-error-dismiss-btn')?.addEventListener('click', () => {
document.getElementById('push-error-modal').style.display = 'none';
localStorage.setItem('haven_push_error_dismissed', 'true');
});
// Detect browser and platform
const isBrave = navigator.brave && (await navigator.brave.isBrave?.()) || false;
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
// Secure context required (covers HTTPS, localhost, etc.)
if (!window.isSecureContext) {
if (toggle) toggle.disabled = true;
if (statusEl) statusEl.textContent = t('context.push_requires_https');
this._pushErrorReason = t('context.push_error.secure');
if (!localStorage.getItem('haven_push_error_dismissed')) this._showPushError(this._pushErrorReason);
return;
}
// Check browser support
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (toggle) toggle.disabled = true;
let reason = t('context.push_error.unsupported');
let helpType = '';
if (isIOS && !isStandalone) {
reason = t('context.push_error.ios_install');
helpType = 'ios_install';
} else if (isIOS) {
reason = t('context.push_error.ios_version');
}
if (statusEl) statusEl.textContent = t('context.push_not_supported');
this._pushErrorReason = reason;
if (!localStorage.getItem('haven_push_error_dismissed')) this._showPushError(reason, helpType);
return;
}
// Register service worker
try {
this._swRegistration = await navigator.serviceWorker.register('/sw.js');
} catch (err) {
console.error('SW registration failed:', err);
if (toggle) toggle.disabled = true;
let reason = t('context.push_error.service_worker', { error: err.message });
let helpType = '';
if (err.name === 'SecurityError' || /SSL|certificate/i.test(err.message || '')) {
const issue = await fetch('/api/public-config').then(r => r.json()).then(d => d.tls_issue).catch(err => {
console.warn('[push] could not ask the server about its certificate:', err.message);
return null;
});
reason = t(issue === 'missing_chain' ? 'context.push_error.ssl_chain' : issue === 'untrusted' ? 'context.push_error.ssl_untrusted' : issue === 'expired' ? 'context.push_error.ssl_expired' : 'context.push_error.ssl_required');
}
if (isBrave) {
reason = t('context.push_error.brave_setup');
helpType = 'brave';
}
if (statusEl) statusEl.textContent = isBrave ? t('context.push_blocked_brave') : t('context.push_registration_failed');
this._pushErrorReason = reason;
if (!localStorage.getItem('haven_push_error_dismissed')) this._showPushError(reason, helpType);
return;
}
// Listen for notification clicks from service worker (channel switch)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'push-notification-click' && event.data.channelCode) {
this.switchChannel(event.data.channelCode);
}
});
// Check current subscription state
let existingSub = null;
try {
existingSub = await this._swRegistration.pushManager.getSubscription();
} catch (err) {
console.warn('Push getSubscription failed (non-fatal, will retry on subscribe):', err.message || err);
// Don't bail out; let the user attempt to subscribe via the toggle.
// The actual subscribe() call in _subscribePush will surface the real error.
}
this._pushSubscription = existingSub;
if (toggle) toggle.checked = !!existingSub;
if (statusEl) statusEl.textContent = existingSub ? t('context.push_enabled') : t('context.push_disabled');
// Re-register existing subscription with server on every load
// (handles server DB resets, reconnects, and subscription refresh)
if (existingSub) {
const subJson = existingSub.toJSON();
this.socket.emit('push-subscribe', {
endpoint: subJson.endpoint,
keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth },
scope: this._pushScope()
});
}
// If permission was previously denied, show early warning
if (Notification.permission === 'denied') {
if (toggle) toggle.disabled = true;
if (statusEl) statusEl.textContent = t('context.push_blocked');
this._pushErrorReason = 'Notification permission was denied. Check your browser\'s site settings and allow notifications for this site, then reload.';
return;
}
// Listen for server confirmation
this.socket.on('push-subscribed', () => {
if (statusEl) statusEl.textContent = t('context.push_enabled');
});
this.socket.on('push-unsubscribed', () => {
if (statusEl) statusEl.textContent = t('context.push_disabled');
});
// Toggle handler
if (toggle) {
toggle.addEventListener('change', async () => {
if (toggle.checked) {
// If we have a stored error reason, show popup instead of trying
if (toggle.disabled && this._pushErrorReason) {
toggle.checked = false;
this._showPushError(this._pushErrorReason);
return;
}
await this._subscribePush();
} else {
await this._unsubscribePush();
}
});
}
},
// ── Activities / Games system methods ────────────────────
async _openActivitiesModal() {
const modal = document.getElementById('activities-modal');
const grid = document.getElementById('activities-grid');
if (!modal || !grid) return;
grid.innerHTML = '';
// Check flash ROM installation status
let flashStatus = {};
try {
const res = await fetch('/api/flash-rom-status');
if (res.ok) {
const data = await res.json();
for (const rom of data.roms) flashStatus[rom.file] = rom.installed;
this._flashAllInstalled = data.allInstalled;
}
} catch (err) { console.warn('[Games] could not read the Flash ROM status', err); }
// If any flash games are not installed, show a download banner at top
const hasFlashGames = this._gamesRegistry.some(g => g.type === 'flash');
if (hasFlashGames && !this._flashAllInstalled) {
const banner = document.createElement('div');
banner.className = 'flash-install-banner';
// Only an admin can install them, so everyone else gets a note instead
// of a button that always fails.
const canInstall = !!this.user?.isAdmin;
banner.innerHTML = `
<span>🎮 ${t('context.flash_not_installed')}</span>
${canInstall
? `<button class="btn-sm btn-accent" id="install-flash-btn">${t('context.flash_download_btn')}</button>`
: `<span class="flash-install-note">${t('context.flash_ask_admin')}</span>`}
`;
grid.appendChild(banner);
banner.querySelector('#install-flash-btn')?.addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = t('context.flash_downloading');
try {
// The server downloads in the background; ask how it is going until
// it is done. A page in front of the server (a proxy's error page)
// is not JSON, so its HTTP status is what gets reported.
const auth = { Authorization: 'Bearer ' + this.token };
const ask = async (opts) => {
const res = await fetch('/api/install-flash-roms', { headers: auth, ...opts });
const data = await res.json().catch(() => null);
if (!res.ok || !data) throw new Error((data && data.error) || t('context.flash_download_http', { status: res.status }));
return data;
};
let state = await ask({ method: 'POST' });
const giveUpAt = Date.now() + 15 * 60 * 1000;
while (state.running) {
if (Date.now() > giveUpAt) throw new Error(t('context.flash_still_running'));
await new Promise(r => setTimeout(r, 3000));
state = await ask();
}
const results = state.results || [];
const installed = results.filter(r => r.status === 'installed').length;
const already = results.filter(r => r.status === 'already-installed').length;
const errors = results.filter(r => r.status === 'error');
if (errors.length) {
console.warn('[Games] Flash download problems:', errors);
this._showToast(t('context.flash_install_failed', { installed, errors: errors.length, reason: errors[0].error || '' }), 'error');
} else {
this._showToast(t('context.flash_install_result', { installed, already, errors: 0 }), 'success');
}
this._flashAllInstalled = errors.length === 0;
// Refresh modal
this._openActivitiesModal();
} catch (err) {
this._showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = t('context.flash_download_btn');
}
});
}
for (const game of this._gamesRegistry) {
// For flash games, check if ROM is installed
const isFlash = game.type === 'flash';
const romFile = isFlash ? game.path.match(/swf=\/games\/roms\/(.+?)&/)?.[1] : null;
const romInstalled = !isFlash || (romFile && flashStatus[decodeURIComponent(romFile)] !== false);
const card = document.createElement('div');
card.className = 'activity-card' + (!romInstalled ? ' activity-card-disabled' : '');
card.dataset.gameId = game.id;
card.innerHTML = `
<div class="activity-card-icon">${this._escapeHtml(game.icon)}</div>
<div class="activity-card-name">${this._escapeHtml(game.name)}</div>
<div class="activity-card-desc">${this._escapeHtml(game.description || '')}${!romInstalled ? `<br><em style="color:var(--text-muted)">${t('context.flash_not_installed_label')}</em>` : ''}</div>
`;
if (romInstalled) {
card.addEventListener('click', () => {
this._closeActivitiesModal();
this._launchGame(game);
});
}
grid.appendChild(card);
}
modal.style.display = 'flex';
},
_closeActivitiesModal() {
const modal = document.getElementById('activities-modal');
if (modal) modal.style.display = 'none';
},
_launchGame(game) {
this._currentGame = game;
// Default: pop out into a new window
const tok = localStorage.getItem('haven_token') || '';
const url = game.path + '#token=' + encodeURIComponent(tok);
this._gameWindow = window.open(url, '_blank', 'width=800,height=900');
// If popup was blocked, fall back to inline iframe
if (!this._gameWindow || this._gameWindow.closed) {
const overlay = document.getElementById('game-iframe-overlay');
const iframe = document.getElementById('game-iframe');
const titleEl = document.getElementById('game-iframe-title');
if (!overlay || !iframe) return;
this._gameIframe = iframe;
if (titleEl) titleEl.textContent = `${game.icon} ${game.name}`;
iframe.src = url;
overlay.style.display = 'flex';
}
// Close activities modal
this._closeActivitiesModal();
// Request leaderboard for this game
this.socket.emit('get-high-scores', { game: game.id });
this._setGameActivity(game.id);
},
_closeGameIframe() {
const overlay = document.getElementById('game-iframe-overlay');
const iframe = document.getElementById('game-iframe');
if (overlay) overlay.style.display = 'none';
if (iframe) iframe.src = 'about:blank';
this._currentGame = null;
this._gameIframe = null;
if (!this._gameWindow || this._gameWindow.closed) this._setGameActivity('');
},
// "Playing Shippy Container" in the member list while a game is open. A game
// in its own window is checked every few seconds, since a closed window sends
// nothing back.
_setGameActivity(gameId) {
clearInterval(this._gameWindowWatch);
this._gameWindowWatch = null;
this.socket?.emit('set-game-activity', { game: gameId });
if (!gameId || !this._gameWindow || this._gameWindow.closed) return;
const win = this._gameWindow;
this._gameWindowWatch = setInterval(() => {
if (!win.closed) return;
clearInterval(this._gameWindowWatch);
this._gameWindowWatch = null;
if (this._gameWindow === win) this._gameWindow = null;
if (!this._currentGame || !this._gameIframe) this._setGameActivity('');
}, 3000);
},
_popoutGame() {
if (!this._currentGame) return;
const tok = localStorage.getItem('haven_token') || '';
const url = this._currentGame.path + '#token=' + encodeURIComponent(tok);
const win = window.open(url, '_blank', 'width=740,height=860');
// Only close the inline iframe if the popup actually opened
if (win && !win.closed) {
const game = this._currentGame;
this._gameWindow = win;
this._closeGameIframe();
this._setGameActivity(game.id);
} else {
this._showToast?.(t('toasts.popup_blocked'), 'error');
}
},
_showPushError(reason, helpType = '') {
const modal = document.getElementById('push-error-modal');
const reasonEl = document.getElementById('push-error-reason');
if (!modal || !reasonEl) return;
// Build structured content with browser-specific action buttons
let html = this._escapeHtml(reason);
// Detect Brave-specific advice and add a copy button for the settings URL
if (helpType === 'brave') {
const settingsUrl = 'brave://settings/privacy';
html += `<div style="margin-top:12px;padding:10px;background:var(--bg-secondary);border-radius:6px;font-family:monospace;font-size:0.8125rem;display:flex;align-items:center;gap:8px;justify-content:center;">
<span style="user-select:all;">${settingsUrl}</span>
<button class="btn-accent" id="push-error-copy-settings" style="padding:4px 10px;font-size:0.75rem;min-width:52px;">${t('common.copy')}</button>
</div>
<p style="color:var(--text-muted);font-size:0.6875rem;margin:8px 0 0;">${t('context.push_error.brave_copy_hint')}</p>`;
}
// Detect permission denied and provide Chrome/Edge settings hints
if (helpType === 'permission') {
html += `<div style="margin-top:12px;font-size:0.75rem;color:var(--text-secondary);line-height:1.6;">${t('context.push_error.permission_help_html')}</div>`;
}
// iOS standalone hint
if (helpType === 'ios_install') {
html += `<div style="margin-top:12px;font-size:0.75rem;color:var(--text-secondary);line-height:1.6;">${t('context.push_error.ios_help_html')}</div>`;
}
reasonEl.innerHTML = html;
reasonEl.querySelector('#push-error-copy-settings')?.addEventListener('click', async (event) => {
await navigator.clipboard.writeText('brave://settings/privacy');
event.currentTarget.textContent = t('common.copied');
setTimeout(() => { event.currentTarget.textContent = t('common.copy'); }, 1500);
});
modal.style.display = 'flex';
},
/** Decode HTML entities back to raw characters (for legacy DB content) */
_decodeHtmlEntities(str) {
if (typeof str !== 'string') return '';
return str
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
},
/** Escape HTML entities for safe innerHTML insertion */
_escapeHtml(str) {
if (typeof str !== 'string') return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
},
async _subscribePush() {
const statusEl = document.getElementById('push-notif-status');
const toggle = document.getElementById('push-notif-enabled');
try {
// Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
if (toggle) toggle.checked = false;
if (statusEl) statusEl.textContent = t('context.push_permission_denied');
this._showPushError(t('context.push_error.permission_denied'), 'permission');
return;
}
// Fetch VAPID public key from server
const res = await fetch('/api/push/vapid-key');
if (!res.ok) throw new Error(t('context.push_error.key_fetch_failed'));
const { publicKey } = await res.json();
// Convert VAPID key to Uint8Array
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
};
// Subscribe to push
const sub = await this._swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
this._pushSubscription = sub;
const subJson = sub.toJSON();
// Send subscription to server
this.socket.emit('push-subscribe', {
endpoint: subJson.endpoint,
keys: {
p256dh: subJson.keys.p256dh,
auth: subJson.keys.auth
},
scope: this._pushScope()
});
if (statusEl) statusEl.textContent = t('context.push_subscribing');
} catch (err) {
console.error('Push subscribe error:', err);
if (toggle) toggle.checked = false;
const isBrave = navigator.brave && (await navigator.brave.isBrave?.()) || false;
let reason = t('context.push_error.subscription_failed', { error: err.message });
let helpType = '';
if (isBrave) {
reason = t('context.push_error.brave_subscription', { error: err.message || t('context.push_error.unknown') });
helpType = 'brave';
} else if (err.message?.includes('push service')) {
reason = t('context.push_error.browser_service');
}
if (statusEl) statusEl.textContent = t('context.push_registration_failed');
this._showPushError(reason, helpType);
}
},
async _unsubscribePush() {
const statusEl = document.getElementById('push-notif-status');
try {
if (this._pushSubscription) {
const endpoint = this._pushSubscription.endpoint;
await this._pushSubscription.unsubscribe();
this._pushSubscription = null;
// Tell server to remove subscription
this.socket.emit('push-unsubscribe', { endpoint });
}
if (statusEl) statusEl.textContent = t('context.push_disabled');
} catch (err) {
console.error('Push unsubscribe error:', err);
if (statusEl) statusEl.textContent = t('context.push_registration_failed');
}
},
// ── Tunnel Management ─────────────────────────────────
/** Sync tunnel enabled/provider state to server */
async _syncTunnelState(enabled) {
const provider = document.getElementById('tunnel-provider-select')?.value || 'localtunnel';
const statusEl = document.getElementById('tunnel-status-display');
const btn = document.getElementById('tunnel-toggle-btn');
if (statusEl) statusEl.textContent = t(enabled ? 'settings.admin.tunnel_starting' : 'settings.admin.tunnel_stopping');
if (btn) btn.disabled = true;
try {
const res = await fetch('/api/tunnel/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({ enabled, provider })
});
if (!res.ok) {
console.error('Tunnel sync failed:', res.status);
if (statusEl) statusEl.textContent = t('settings.admin.tunnel_sync_failed');
return;
}
// Update status from the response directly (no delay needed)
const data = await res.json();
this._updateTunnelStatusUI(data);
} catch (err) {
console.error('Tunnel sync error:', err);
if (statusEl) statusEl.textContent = t('settings.admin.tunnel_error');
} finally {
if (btn) btn.disabled = false;
}
},
/** Fetch current tunnel status from server and update UI.
* If the tunnel is still starting, poll every 2 s until it resolves. */
async _refreshTunnelStatus() {
if (!this.user?.isAdmin) return;
try {
const res = await fetch('/api/tunnel/status', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (!res.ok && res.status !== 304) throw new Error(`HTTP ${res.status}`);
if (res.status === 304) return; // Not Modified: nothing to update
const data = await res.json();
this._updateTunnelStatusUI(data);
// If still starting, poll again in 2 s
if (data.starting) {
clearTimeout(this._tunnelPollTimer);
this._tunnelPollTimer = setTimeout(() => this._refreshTunnelStatus(), 2000);
}
} catch (err) {
const statusEl = document.getElementById('tunnel-status-display');
if (statusEl) statusEl.textContent = t('settings.admin.tunnel_status_error');
console.error('Tunnel status error:', err);
}
},
/** Update the tunnel status display from a status object */
_updateTunnelStatusUI(data) {
const statusEl = document.getElementById('tunnel-status-display');
const btn = document.getElementById('tunnel-toggle-btn');
if (btn) {
if (data.active) {
btn.textContent = t('settings.admin.tunnel_stop_btn');
btn.classList.add('btn-danger');
btn.classList.remove('btn-accent');
} else {
btn.textContent = t('settings.admin.tunnel_start_btn');
btn.classList.remove('btn-danger');
btn.classList.add('btn-accent');
}
}
if (!statusEl) return;
if (data.active && data.url) {
statusEl.textContent = data.url;
statusEl.title = t('settings.admin.tunnel_active_title');
statusEl.style.cursor = 'pointer';
statusEl.onclick = () => {
const markCopied = () => { statusEl.textContent = t('common.copied'); };
navigator.clipboard.writeText(data.url).then(markCopied).catch(() => {
try {
const ta = document.createElement('textarea');
ta.value = data.url;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.focus(); ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
markCopied();
} catch { /* could not copy */ }
});
setTimeout(() => { statusEl.textContent = data.url; }, 1500);
};
} else if (data.starting) {
statusEl.textContent = t('settings.admin.tunnel_starting');
statusEl.style.cursor = '';
statusEl.onclick = null;
} else {
statusEl.textContent = data.error || t('settings.admin.tunnel_inactive');
statusEl.style.cursor = '';
statusEl.onclick = null;
}
},
// ── Theme System ──────────────────────────────────────
_setupThemes() {
initThemeSwitcher('theme-selector', this.socket);
},
// ── Status Bar ────────────────────────────────────────
// Whether the status bar should be on screen: the saved preference, or the
// platform default when none is saved (on in the Desktop app, off in a
// browser) (#5647).
_statusBarWanted() {
const isDesktop = !!(window.havenDesktop?.isDesktopApp ||
navigator.userAgent.includes('Electron'));
const saved = localStorage.getItem('haven_show_statusbar');
return saved === null ? isDesktop : saved === 'true';
},
_startStatusBar() {
// In the Electron desktop shell, show the status bar regardless of CSS
// responsive breakpoints or DPI-scaled viewport width, unless the user
// switched it off in Settings (#5647).
const isDesktop = !!(window.havenDesktop?.isDesktopApp ||
navigator.userAgent.includes('Electron'));
const _forceWebStatusBar = () => {
const sb = document.getElementById('status-bar');
if (!sb) return;
sb.style.setProperty('display', 'flex', 'important');
// Verify the bar is inside the visible viewport. If clipped by
// Electron BrowserView (100dvh mismatch), fall back to fixed positioning.
requestAnimationFrame(() => {
const rect = sb.getBoundingClientRect();
if (rect.height === 0 || rect.bottom > window.innerHeight + 2) {
sb.style.setProperty('position', 'fixed', 'important');
sb.style.setProperty('bottom', '0', 'important');
sb.style.setProperty('left', '0', 'important');
sb.style.setProperty('right', '0', 'important');
sb.style.setProperty('z-index', '50', 'important');
const appBody = document.getElementById('app-body');
if (appBody) appBody.style.paddingBottom = sb.offsetHeight + 'px';
}
});
};
if (isDesktop) {
// Belt-and-suspenders: ensure the CSS attribute is present (preload
// sets this on DOMContentLoaded, but reinforce here in case of timing)
document.documentElement.setAttribute('data-desktop-app', '1');
// The status bar is the desktop app's only footer. Pre-v1.4.26 builds
// inject one of their own from the preload, which used to make us stand
// down here to avoid two stacked bars, but that legacy bar is now hidden
// in CSS, so standing down would leave no footer at all. Show ours unless
// the Settings toggle is off (#5647).
if (this._statusBarWanted()) _forceWebStatusBar();
else document.documentElement.setAttribute('data-hide-statusbar', '1');
} else {
// Browser / mobile: respect the user's opt-in preference (default hidden).
// The settings toggle in _initSettings applies the attribute + display;
// here we just honour it in case _startStatusBar runs first.
const sb = document.getElementById('status-bar');
if (sb && localStorage.getItem('haven_show_statusbar') === 'true') {
sb.style.setProperty('display', 'flex', 'important');
}
}
this._updateClock();
if (this._clockInterval) clearInterval(this._clockInterval);
this._clockInterval = setInterval(() => this._updateClock(), 1000);
},
_updateClock() {
const el = document.getElementById('status-clock');
if (!el) return;
const now = new Date();
// Honour a confirmed timezone / clock preference. With nothing confirmed the
// clock keeps its original device-local 24-hour HH:MM:SS look, so Skip and
// "Remind later" change nothing here.
if (this._userTimeZone?.() || this._userHour12?.() !== undefined) {
try {
el.textContent = this._fmtTime(now, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return;
} catch { /* fall through to the device-local default */ }
}
const h = now.getHours().toString().padStart(2, '0');
const m = now.getMinutes().toString().padStart(2, '0');
const s = now.getSeconds().toString().padStart(2, '0');
el.textContent = `${h}:${m}:${s}`;
},
/**
* Emit a latency probe, recording when it went out.
*
* Timestamps go in a FIFO rather than a single `_pingStart` field because
* more than one place emits 'ping-check' (the 15 s monitor below and the
* window-focus zombie-socket probe in app-socket.js. The server replies with
* a bare 'pong-check' carrying no correlation id, so a shared field meant the
* focus probe's pong was measured against the *previous scheduled ping's*
* timestamp. That reported "time since the last 15 s tick" as latency: a
* uniformly random 0 to 15000 ms, which is where multi-second readings on a
* localhost server came from. Socket.IO preserves ordering, so pongs come
* back in send order and the queue pairs them up correctly.
*/
_pingSend() {
if (!this.socket || !this.socket.connected) return;
if (!this._pingQueue) this._pingQueue = [];
// If pongs stop coming back, don't accumulate: a stale head would later be
// paired with an unrelated pong and produce exactly the bogus reading this
// is meant to prevent.
if (this._pingQueue.length >= 4) this._pingQueue.shift();
this._pingQueue.push(Date.now());
this.socket.emit('ping-check');
},
_startPingMonitor() {
if (this.pingInterval) clearInterval(this.pingInterval);
this.pingInterval = setInterval(() => {
this._pingSend();
}, 15000);
// Periodic member list + voice refresh every 30s to keep sidebar in sync
if (this._memberRefreshInterval) clearInterval(this._memberRefreshInterval);
this._memberRefreshInterval = setInterval(() => {
if (this.socket && this.socket.connected && this.currentChannel) {
this.socket.emit('request-online-users', { code: this.currentChannel });
// VOICE panel follows the channel in view.
this.socket.emit('request-voice-users', {
code: this.currentChannel,
iAmInVoice: !!(this.voice && this.voice.inVoice && this.voice.currentChannel === this.currentChannel)
});
}
}, 30000);
this._pingSend();
},
_setLed(id, state) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'led ' + state;
},
// ═══════════════════════════════════════════════════════════
// Automatic Performance Diagnostics
//
// Starts automatically 30 s after init. Samples FPS once per second,
// and every 15 s evaluates the trend. If average FPS is dropping or
// already low, logs a diagnostic snapshot to the console (which the
// Desktop app forwards to its server-log panel).
//
// Manual HUD toggle: app._perfHUD(true) / app._perfHUD(false)
// ═══════════════════════════════════════════════════════════
_startPerfDiagnostics() {
if (this._perfDiag) return; // already running
const SAMPLE_INTERVAL = 1000; // measure one FPS reading every 1 s
const REPORT_INTERVAL = 15000; // evaluate + log every 15 s
const FPS_WARN = 30; // warn below this average
const FPS_CRITICAL = 12; // critical: user is seeing freeze
const samples = []; // rolling window of {fps, ts}
const MAX_SAMPLES = 60; // keep last 60 s of FPS readings
let frameCount = 0;
let lastSampleTime = performance.now();
let rafId = null;
let reportTimer = null;
// Count frames via rAF; skip sampling when the window is hidden/backgrounded
// because Chromium throttles rAF to ~1 FPS in background tabs, which would
// cause false CRITICAL alerts even when the app is perfectly healthy.
//
// (#5456) Sample in short bursts instead of running rAF forever. A frame
// loop that never stops keeps the renderer requesting a frame on every
// vsync for the whole session, so the compositor and GPU never get to go
// idle and any running CSS animation is re-evaluated on every one of those
// frames. Measuring the frame rate does need rAF, but it does not need it
// 100% of the time. Three seconds out of every fifteen is plenty for an
// average and a trend, and leaves the renderer alone the rest of the time.
const BURST_MS = 3000;
let burstStart = 0;
const countFrame = (now) => {
if (document.hidden) {
// Reset so the first sample after becoming visible starts clean
frameCount = 0;
lastSampleTime = now;
rafId = requestAnimationFrame(countFrame);
return;
}
frameCount++;
const elapsed = now - lastSampleTime;
if (elapsed >= SAMPLE_INTERVAL) {
const fps = Math.round(frameCount * 1000 / elapsed);
samples.push({ fps, ts: Date.now() });
if (samples.length > MAX_SAMPLES) samples.shift();
frameCount = 0;
lastSampleTime = now;
}
if (now - burstStart >= BURST_MS) {
rafId = null; // burst over, stop asking for frames until the next one
return;
}
rafId = requestAnimationFrame(countFrame);
};
const startBurst = () => {
if (rafId) return;
frameCount = 0;
burstStart = performance.now();
lastSampleTime = burstStart;
rafId = requestAnimationFrame(countFrame);
};
startBurst();
// Periodic evaluation
reportTimer = setInterval(() => {
if (samples.length < 5) return; // not enough data yet
const recent = samples.slice(-15); // last ~15 seconds
const avgFps = Math.round(recent.reduce((s, r) => s + r.fps, 0) / recent.length);
const minFps = Math.min(...recent.map(r => r.fps));
// Trend: compare first half vs second half
const half = Math.floor(recent.length / 2);
const firstHalfAvg = recent.slice(0, half).reduce((s, r) => s + r.fps, 0) / half;
const secondHalfAvg = recent.slice(half).reduce((s, r) => s + r.fps, 0) / (recent.length - half);
const trend = secondHalfAvg - firstHalfAvg; // negative = degrading
// Collect system context
const mem = performance.memory
? { heapUsed: Math.round(performance.memory.usedJSHeapSize / 1048576), heapTotal: Math.round(performance.memory.totalJSHeapSize / 1048576) }
: null;
const domCount = document.querySelectorAll('*').length;
const msgCount = document.getElementById('messages')?.children.length || 0;
const visibleModals = document.querySelectorAll('.modal-overlay[style*="display:flex"], .modal-overlay[style*="display: flex"]').length;
const isRgbCycling = document.documentElement.classList.contains('rgb-cycling');
const theme = document.documentElement.getAttribute('data-theme') || 'none';
// Determine severity
let severity = null;
if (avgFps < FPS_CRITICAL) severity = 'CRITICAL';
else if (avgFps < FPS_WARN) severity = 'WARNING';
else if (trend < -10 && avgFps < 50) severity = 'DEGRADING';
if (severity) {
const report = [
`[Haven Perf ${severity}]`,
`FPS avg:${avgFps} min:${minFps} trend:${trend > 0 ? '+' : ''}${Math.round(trend)}`,
mem ? `Heap:${mem.heapUsed}/${mem.heapTotal}MB` : '',
`DOM:${domCount} msgs:${msgCount} modals-open:${visibleModals}`,
`theme:${theme} rgb:${isRgbCycling}`,
`samples:[${recent.map(r => r.fps).join(',')}]`,
].filter(Boolean).join(' | ');
console.warn(report);
}
// Always log a quiet heartbeat every 60 s (every 4th report) for baseline tracking
if (samples.length % 4 === 0) {
console.log(`[Haven Perf] FPS:${avgFps} trend:${trend > 0 ? '+' : ''}${Math.round(trend)} DOM:${domCount}${mem ? ' heap:' + mem.heapUsed + 'MB' : ''} rgb:${isRgbCycling}`);
}
}, REPORT_INTERVAL);
// Take the next reading (#5456); the frame loop is idle between bursts.
const burstTimer = setInterval(startBurst, REPORT_INTERVAL);
this._perfDiag = { reportTimer, burstTimer, samples, stop: () => {
clearInterval(reportTimer);
clearInterval(burstTimer);
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
} };
},
// Toggle visual HUD overlay: app._perfHUD(true)
_perfHUD(enable) {
if (!enable) {
if (this._perfHudRAF) cancelAnimationFrame(this._perfHudRAF);
this._perfHudRAF = null;
const hud = document.getElementById('_perf_hud');
if (hud) hud.remove();
return;
}
if (this._perfHudRAF) return;
const hud = document.createElement('div');
hud.id = '_perf_hud';
hud.style.cssText = 'position:fixed;top:4px;right:4px;z-index:999999;background:rgba(0,0,0,.85);color:#0f0;font:12px monospace;padding:6px 10px;border-radius:4px;pointer-events:none;white-space:pre';
document.body.appendChild(hud);
let frames = 0, lastSec = performance.now();
const tick = (now) => {
this._perfHudRAF = requestAnimationFrame(tick);
frames++;
if (now - lastSec >= 1000) {
const fps = Math.round(frames * 1000 / (now - lastSec));
const mem = performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : '?';
const dom = document.querySelectorAll('*').length;
const rgb = document.documentElement.classList.contains('rgb-cycling') ? ' RGB' : '';
hud.textContent = t('context.performance_hud', { fps, memory: mem, dom, rgb });
frames = 0;
lastSec = now;
}
};
this._perfHudRAF = requestAnimationFrame(tick);
},
};