mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-31 02:54:59 +00:00
1700 lines
72 KiB
JavaScript
1700 lines
72 KiB
JavaScript
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(/'/g, "'")
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/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, '"').replace(/'/g, ''');
|
||
},
|
||
|
||
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);
|
||
},
|
||
|
||
};
|