Haven/public/js/modules/app-channels.js

2214 lines
99 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 {
// ── Channel Management ────────────────────────────────
async switchChannel(code) {
if (this.currentChannel === code) return;
// Clear any pending image queue from previous channel
this._clearImageQueue();
// Voice persists across channel switches — no auto-disconnect
this.currentChannel = code;
this._coupledToBottom = true;
const jumpBtn = document.getElementById('jump-to-bottom');
if (jumpBtn) jumpBtn.classList.remove('visible');
const channel = this.channels.find(c => c.code === code);
const isDm = channel && channel.is_dm;
const displayName = isDm && channel.dm_target
? `@ ${this._getNickname(channel.dm_target.id, channel.dm_target.username)}`
: channel ? `# ${channel.name}` : code;
document.getElementById('channel-header-name').textContent = displayName;
// Clear scramble cache so the effect picks up the new channel name
const headerEl = document.getElementById('channel-header-name');
if (headerEl) { delete headerEl.dataset.originalText; headerEl._scrambling = false; }
const displayCode = channel ? (channel.display_code || code) : code;
const isMaskedCode = (displayCode === '••••••••');
document.getElementById('channel-code-display').textContent = isDm ? '' : displayCode;
document.getElementById('copy-code-btn').style.display = (isDm || isMaskedCode) ? 'none' : 'inline-flex';
// Show channel code settings gear for admins / users with create_channel on non-DM channels
const codeSettingsBtn = document.getElementById('channel-code-settings-btn');
if (codeSettingsBtn) {
codeSettingsBtn.style.display = (!isDm && (this.user.isAdmin || this._hasPerm('create_channel'))) ? 'inline-flex' : 'none';
}
// Show the header actions box
const actionsBox = document.getElementById('header-actions-box');
if (actionsBox) actionsBox.style.display = 'flex';
// Update voice button state — persist controls if in voice anywhere
if (this.voice && this.voice.inVoice) {
this._updateVoiceButtons(true);
// If viewing a different channel from the one we're in voice in, show "Join Voice" instead of "Voice Active"
if (this.voice.currentChannel !== code) {
const _canVoice = this.user?.isAdmin || this._hasPerm('use_voice');
const indic = document.getElementById('voice-active-indicator');
if (indic) indic.style.display = 'none';
const _scJoinBtn = document.getElementById('voice-join-btn');
if (_scJoinBtn) _scJoinBtn.style.display = (channel && channel.voice_enabled === 0) || !_canVoice ? 'none' : 'inline-flex';
const mobileJoin = document.getElementById('voice-join-mobile');
if (mobileJoin) mobileJoin.style.display = (channel && channel.voice_enabled === 0) || !_canVoice ? 'none' : '';
}
} else {
// Show just the join button (not the indicator), but hide it for text-only channels or users without voice permission
const _scJoinBtn = document.getElementById('voice-join-btn');
const _canVoice = this.user?.isAdmin || this._hasPerm('use_voice');
if (_scJoinBtn) _scJoinBtn.style.display = (channel && channel.voice_enabled === 0) || !_canVoice ? 'none' : 'inline-flex';
const indic = document.getElementById('voice-active-indicator');
if (indic) indic.style.display = 'none';
const vp = document.getElementById('voice-panel');
if (vp) vp.style.display = 'none';
const mobileJoin = document.getElementById('voice-join-mobile');
if (mobileJoin) mobileJoin.style.display = (channel && channel.voice_enabled === 0) || !_canVoice ? 'none' : '';
}
document.getElementById('search-toggle-btn').style.display = '';
document.getElementById('pinned-toggle-btn').style.display = '';
// Auto-close pinned panel on channel switch so stale pins don't linger
document.getElementById('pinned-panel').style.display = 'none';
// Show "Select messages" button for admins/mods on non-DM channels
const moveSelectBtn = document.getElementById('move-select-btn');
if (moveSelectBtn) {
const canMove = !isDm && (this.user.isAdmin || this._canModerate());
moveSelectBtn.style.display = canMove ? 'inline-flex' : 'none';
}
// Exit selection mode when switching channels
if (this._moveSelectionActive) this._exitMoveSelectionMode();
// Show/hide topic bar
this._updateTopicBar(channel?.topic || '');
// Show/hide message input — keep upload button visible for media-only channels
const msgInputArea = document.getElementById('message-input-area');
const _textOff = channel && channel.text_enabled === 0;
const _mediaOff = channel && channel.media_enabled === 0;
// Read-only: hide input unless user is admin or has read_only_override permission
const _isReadOnly = channel && channel.read_only === 1 && !this.user?.isAdmin && !this._hasPerm('read_only_override');
if (msgInputArea) msgInputArea.style.display = (_isReadOnly || (_textOff && _mediaOff)) ? 'none' : '';
// Text-only elements
const _msgInput = document.getElementById('message-input');
const _sendBtn = document.getElementById('send-btn');
const _emojiBtn = document.getElementById('emoji-btn');
const _gifBtn = document.getElementById('gif-btn');
const _pollBtn = document.getElementById('poll-btn');
if (_msgInput) _msgInput.style.display = _textOff ? 'none' : '';
if (_sendBtn) _sendBtn.style.display = _textOff ? 'none' : '';
if (_emojiBtn) _emojiBtn.style.display = _textOff ? 'none' : '';
if (_gifBtn) _gifBtn.style.display = _textOff ? 'none' : '';
if (_pollBtn) _pollBtn.style.display = _textOff ? 'none' : '';
// Upload button tied to media toggle
const _uploadBtn = document.getElementById('upload-btn');
if (_uploadBtn) _uploadBtn.style.display = _mediaOff ? 'none' : '';
// Dividers: first one only if both upload and text buttons visible, rest if text is on
const _dividers = document.querySelectorAll('.input-actions-box .input-actions-divider');
if (_dividers[0]) _dividers[0].style.display = (!_textOff && !_mediaOff) ? '' : 'none';
if (_dividers[1]) _dividers[1].style.display = _textOff ? 'none' : '';
if (_dividers[2]) _dividers[2].style.display = _textOff ? 'none' : '';
const messagesEl = document.getElementById('messages');
messagesEl.innerHTML = '';
document.getElementById('message-area').style.display = 'flex';
document.getElementById('no-channel-msg').style.display = 'none';
document.querySelectorAll('.channel-item').forEach(el => el.classList.remove('active'));
const activeEl = document.querySelector(`.channel-item[data-code="${code}"]`);
if (activeEl) activeEl.classList.add('active');
this.unreadCounts[code] = 0;
this._updateBadge(code);
document.getElementById('status-channel').textContent = isDm && channel.dm_target
? t('channels.dm_status', { name: channel.dm_target.username }) : channel ? channel.name : code;
// Reset pagination state for the new channel
this._oldestMsgId = null;
this._noMoreHistory = false;
this._loadingHistory = false;
this._historyBefore = null;
this._newestMsgId = null;
this._noMoreFuture = true;
this._loadingFuture = false;
this._historyAfter = null;
this.socket.emit('enter-channel', { code });
// E2E: fetch DM partner's public key BEFORE requesting messages
if (isDm && channel) await this._fetchDMPartnerKey(channel);
this.socket.emit('get-messages', { code });
this.socket.emit('get-channel-members', { code });
this.socket.emit('request-voice-users', { code });
this._clearReply();
this._closeThread();
// Auto-focus the message input for quick typing
const msgInput = document.getElementById('message-input');
if (msgInput) setTimeout(() => msgInput.focus(), 50);
// Show E2E encryption menu only in DM channels
const e2eWrapper = document.getElementById('e2e-menu-wrapper');
if (e2eWrapper) e2eWrapper.style.display = isDm ? '' : 'none';
// Close dropdown when switching channels
const e2eDropdown = document.getElementById('e2e-dropdown');
if (e2eDropdown) e2eDropdown.style.display = 'none';
},
_updateTopicBar(topic) {
let bar = document.getElementById('channel-topic-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'channel-topic-bar';
bar.className = 'channel-topic-bar';
const header = document.querySelector('.channel-header');
header.parentNode.insertBefore(bar, header.nextSibling);
}
const canEdit = this.user.isAdmin || this._hasPerm('set_channel_topic');
if (topic) {
bar.textContent = topic;
bar.style.display = 'block';
bar.title = canEdit ? t('channels.topic_edit_hint') : topic;
bar.onclick = canEdit ? () => this._editTopic() : null;
bar.style.cursor = canEdit ? 'pointer' : 'default';
} else {
if (canEdit) {
bar.textContent = t('channels.topic_placeholder');
bar.style.display = 'block';
bar.style.opacity = '';
bar.style.color = 'var(--text-muted)';
bar.style.cursor = 'pointer';
bar.onclick = () => this._editTopic();
} else {
bar.style.display = 'none';
}
}
if (topic) { bar.style.opacity = '1'; bar.style.color = ''; }
},
async _editTopic() {
const channel = this.channels.find(c => c.code === this.currentChannel);
const current = channel?.topic || '';
const newTopic = await this._showPromptModal(t('channels.topic_modal_title'), t('channels.topic_modal_hint'), current);
if (newTopic === null) return; // cancelled
this.socket.emit('set-channel-topic', { code: this.currentChannel, topic: newTopic.slice(0, 256) });
},
_showWelcome() {
document.getElementById('message-area').style.display = 'none';
document.getElementById('no-channel-msg').style.display = 'flex';
document.getElementById('channel-header-name').textContent = t('header.select_channel');
// Clear scramble cache when going back to welcome
const welcomeHeader = document.getElementById('channel-header-name');
if (welcomeHeader) { delete welcomeHeader.dataset.originalText; welcomeHeader._scrambling = false; }
document.getElementById('channel-code-display').textContent = '';
document.getElementById('copy-code-btn').style.display = 'none';
document.getElementById('voice-join-btn').style.display = 'none';
const indic2 = document.getElementById('voice-active-indicator');
if (indic2) indic2.style.display = 'none';
const vp2 = document.getElementById('voice-panel');
if (vp2) vp2.style.display = 'none';
const mobileJoin = document.getElementById('voice-join-mobile');
if (mobileJoin) mobileJoin.style.display = 'none';
const actionsBox = document.getElementById('header-actions-box');
if (actionsBox) actionsBox.style.display = 'none';
document.getElementById('status-channel').textContent = t('channels.status_none');
document.getElementById('status-online-count').textContent = '0';
const topicBar = document.getElementById('channel-topic-bar');
if (topicBar) topicBar.style.display = 'none';
},
/* ── Channel context menu helpers ─────────────────────── */
_initChannelContextMenu() {
this._ctxMenuChannel = null;
this._ctxMenuEl = document.getElementById('channel-ctx-menu');
// Delegate clicks on "..." buttons inside the channel list
document.getElementById('channel-list')?.addEventListener('click', (e) => {
const btn = e.target.closest('.channel-more-btn');
if (!btn) return;
e.stopPropagation();
const code = btn.closest('.channel-item')?.dataset.code;
if (code) this._openChannelCtxMenu(code, btn);
});
},
_openChannelCtxMenu(code, btnEl) {
this._ctxMenuChannel = code;
const menu = this._ctxMenuEl;
if (!menu) return;
// Show/hide admin-only items (also allow users with create_channel perm)
const isAdmin = this.user && this.user.isAdmin;
const canManageChannels = isAdmin || this._hasPerm('create_channel');
const isMod = isAdmin || this._canModerate();
menu.querySelectorAll('.admin-only').forEach(el => {
el.style.display = canManageChannels ? '' : 'none';
});
// Show delete button for users with delete_channel permission even if not admin
const deleteBtn = menu.querySelector('[data-action="delete"]');
if (deleteBtn && !canManageChannels && this._hasPerm('delete_channel')) {
deleteBtn.style.display = '';
}
// Also show delete for users who created a temp channel
if (deleteBtn && !canManageChannels && !this._hasPerm('delete_channel')) {
const ch = this.channels.find(c => c.code === code);
if (ch && ch.is_temp_voice && ch.created_by === this.user?.id) {
deleteBtn.style.display = '';
}
}
menu.querySelectorAll('.mod-only').forEach(el => {
el.style.display = isMod ? '' : 'none';
});
// Always reset the Channel Functions panel to closed when the menu opens
const cfnPanel = document.getElementById('channel-functions-panel');
if (cfnPanel) cfnPanel.style.display = 'none';
const cfnArrow = menu.querySelector('[data-action="channel-functions"] .cfn-arrow');
if (cfnArrow) cfnArrow.textContent = '▶';
// Show/hide "Mark as Read" based on unread count
const markReadBtn = menu.querySelector('[data-action="mark-read"]');
if (markReadBtn) markReadBtn.style.display = (this.unreadCounts[code] > 0) ? '' : 'none';
// Show "Create Sub-channel" for mods OR users with create_channel / manage_sub_channels perm
const ch = this.channels.find(c => c.code === code);
const createSubBtn = menu.querySelector('[data-action="create-sub-channel"]');
if (createSubBtn) {
const canCreateSub = isMod || this._hasPerm('manage_sub_channels') || this._hasPerm('create_channel');
createSubBtn.style.display = (canCreateSub && ch && !ch.parent_channel_id) ? '' : 'none';
}
// Hide "Leave Channel" for admins (always in all channels)
const leaveBtn = menu.querySelector('[data-action="leave-channel"]');
if (leaveBtn) leaveBtn.style.display = isAdmin ? 'none' : '';
// Show "Organize" only for parent channels that have sub-channels
const organizeBtn = menu.querySelector('[data-action="organize"]');
if (organizeBtn) {
const hasSubs = ch && !ch.parent_channel_id && this.channels.some(c => c.parent_channel_id === ch.id);
organizeBtn.style.display = (canManageChannels && hasSubs) ? '' : 'none';
}
// Show "Move to…" for channels that can become sub-channels (no children of their own)
const moveToBtn = menu.querySelector('[data-action="move-to-parent"]');
if (moveToBtn && ch) {
const hasChildren = this.channels.some(c => c.parent_channel_id === ch.id);
// Can move if: admin, not a DM, and has no children (can't nest 2 levels)
moveToBtn.style.display = (canManageChannels && !ch.is_dm && !hasChildren) ? '' : 'none';
}
// Show "Promote to Channel" only for sub-channels
const promoteBtn = menu.querySelector('[data-action="promote-channel"]');
if (promoteBtn && ch) {
promoteBtn.style.display = (canManageChannels && ch.parent_channel_id) ? '' : 'none';
}
// Update Channel Functions panel with current channel values
if (canManageChannels) this._updateChannelFunctionsPanel(ch);
// Update mute label
const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
const muteBtn = menu.querySelector('[data-action="mute"]');
if (muteBtn) muteBtn.textContent = muted.includes(code) ? `🔕 ${t('channels.unmute_channel')}` : `🔔 ${t('channels.mute_channel')}`;
// Show/hide voice options based on current voice state
const joinVoiceBtn = menu.querySelector('[data-action="join-voice"]');
const leaveVoiceBtn = menu.querySelector('[data-action="leave-voice"]');
const inVoice = this.voice && this.voice.inVoice;
const inThisChannel = inVoice && this.voice.currentChannel === code;
const isVoiceOff = ch && ch.voice_enabled === 0;
const _noVP = !this.user?.isAdmin && !this._hasPerm('use_voice');
if (joinVoiceBtn) joinVoiceBtn.style.display = (inThisChannel || isVoiceOff || _noVP) ? 'none' : '';
if (leaveVoiceBtn) leaveVoiceBtn.style.display = inVoice ? '' : 'none';
// Position near the button
const rect = btnEl.getBoundingClientRect();
menu._anchorEl = btnEl;
menu.style.display = 'block';
menu.style.top = rect.bottom + 4 + 'px';
menu.style.left = rect.left + 'px';
// Keep menu inside viewport
requestAnimationFrame(() => {
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
if (mr.bottom > window.innerHeight) menu.style.top = (rect.top - mr.height - 4) + 'px';
});
},
_setCfnBadge(fn, isOn, text) {
const row = document.querySelector(`.cfn-row[data-fn="${fn}"]`);
if (!row) return;
let badge = row.querySelector('.cfn-badge');
if (!badge) {
// Badge was replaced by an input — restore it
const input = row.querySelector('.cfn-input');
badge = document.createElement('span');
badge.className = 'cfn-badge';
if (input) input.replaceWith(badge);
else return;
}
badge.textContent = text;
badge.className = 'cfn-badge ' + (isOn ? 'cfn-on' : 'cfn-off');
},
_updateChannelFunctionsPanel(ch) {
if (!ch) return;
// Voice & text toggles
const voiceOff = ch.voice_enabled === 0;
const textOff = ch.text_enabled === 0;
this._setCfnBadge('voice', !voiceOff, voiceOff ? 'OFF' : 'ON');
this._setCfnBadge('text', !textOff, textOff ? 'OFF' : 'ON');
// Basic toggles
this._setCfnBadge('streams', ch.streams_enabled !== 0, ch.streams_enabled !== 0 ? 'ON' : 'OFF');
this._setCfnBadge('music', ch.music_enabled !== 0, ch.music_enabled !== 0 ? 'ON' : 'OFF');
this._setCfnBadge('media', ch.media_enabled !== 0, ch.media_enabled !== 0 ? 'ON' : 'OFF');
// Read-only toggle
const isReadOnly = ch.read_only === 1;
this._setCfnBadge('read-only', isReadOnly, isReadOnly ? 'ON' : 'OFF');
const interval = ch.slow_mode_interval || 0;
this._setCfnBadge('slow-mode', interval > 0, interval > 0 ? `${interval}s` : 'OFF');
this._setCfnBadge('cleanup-exempt', ch.cleanup_exempt === 1, ch.cleanup_exempt === 1 ? 'ON' : 'OFF');
// Streams and music greyed when voice is disabled (they depend on voice)
const streamsRow = document.querySelector('.cfn-row[data-fn="streams"]');
if (streamsRow) streamsRow.classList.toggle('cfn-disabled', voiceOff);
const musicRow = document.querySelector('.cfn-row[data-fn="music"]');
if (musicRow) musicRow.classList.toggle('cfn-disabled', voiceOff);
// Voice Limit (0 = unlimited = ∞; minimum meaningful limit is 2)
const limit = ch.voice_user_limit || 0;
this._setCfnBadge('user-limit', limit >= 2, limit >= 2 ? String(limit) : '∞');
// User limit greyed when voice is disabled
const userLimitRow = document.querySelector('.cfn-row[data-fn="user-limit"]');
if (userLimitRow) userLimitRow.classList.toggle('cfn-disabled', voiceOff);
// Voice Bitrate (0 = auto / no cap)
const bitrate = ch.voice_bitrate || 0;
this._setCfnBadge('voice-bitrate', bitrate > 0, bitrate > 0 ? bitrate + ' kbps' : 'Auto');
// Voice bitrate greyed when voice is disabled
const bitrateRow = document.querySelector('.cfn-row[data-fn="voice-bitrate"]');
if (bitrateRow) bitrateRow.classList.toggle('cfn-disabled', voiceOff);
// Announcement channel
const isAnnouncement = ch.notification_type === 'announcement';
this._setCfnBadge('announcement', isAnnouncement, isAnnouncement ? 'ON' : 'OFF');
// Self Destruct timer
const hasExpiry = !!ch.expires_at;
if (hasExpiry) {
const hoursLeft = Math.max(1, Math.round((new Date(ch.expires_at) - Date.now()) / 3600000));
this._setCfnBadge('self-destruct', true, `${hoursLeft}h`);
} else {
this._setCfnBadge('self-destruct', false, 'OFF');
}
// AFK sub-channel (only for parent channels)
const isParent = !ch.parent_channel_id && !ch.is_dm;
const hasSubs = isParent && (this.channels || []).some(c => c.parent_channel_id === ch.id);
document.querySelectorAll('.cfn-afk-row, .cfn-afk-divider').forEach(el => {
el.style.display = (isParent && hasSubs) ? '' : 'none';
});
if (isParent && hasSubs) {
const afkSubCode = ch.afk_sub_code || '';
const afkTimeout = ch.afk_timeout_minutes || 0;
if (afkSubCode) {
const sub = (this.channels || []).find(c => c.code === afkSubCode);
this._setCfnBadge('afk-sub', true, sub ? sub.name : afkSubCode.slice(0, 6));
} else {
this._setCfnBadge('afk-sub', false, 'OFF');
}
this._setCfnBadge('afk-timeout', afkTimeout > 0, afkTimeout > 0 ? `${afkTimeout}m` : 'OFF');
}
},
_closeChannelCtxMenu() {
if (this._ctxMenuEl) this._ctxMenuEl.style.display = 'none';
const cfnPanel = document.getElementById('channel-functions-panel');
if (cfnPanel) cfnPanel.style.display = 'none';
this._ctxMenuChannel = null;
},
/* ── DM context menu helpers ──────────────────────────── */
_initDmContextMenu() {
this._dmCtxMenuEl = document.getElementById('dm-ctx-menu');
this._dmCtxMenuCode = null;
// Mark DM as read
document.querySelector('[data-action="dm-mark-read"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
if (!code) return;
this._closeDmCtxMenu();
this.unreadCounts[code] = 0;
this._updateBadge(code);
this.socket.emit('mark-read-channel', { code });
});
// Mute DM
document.querySelector('[data-action="dm-mute"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
if (!code) return;
this._closeDmCtxMenu();
const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
const idx = muted.indexOf(code);
if (idx >= 0) { muted.splice(idx, 1); this._showToast(t('channels.dm_unmuted'), 'success'); }
else { muted.push(code); this._showToast(t('channels.dm_muted'), 'success'); }
localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
});
// Delete DM
document.querySelector('[data-action="dm-delete"]')?.addEventListener('click', () => {
const code = this._dmCtxMenuCode;
if (!code) return;
this._closeDmCtxMenu();
if (!confirm('⚠️ ' + t('channels.dm_delete_confirm'))) return;
this.socket.emit('delete-dm', { code });
});
// Close on outside click
document.addEventListener('click', (e) => {
if (this._dmCtxMenuEl && !this._dmCtxMenuEl.contains(e.target) && !e.target.closest('.dm-more-btn')) {
this._closeDmCtxMenu();
}
});
},
_openDmCtxMenu(code, anchorEl, mouseEvent) {
this._dmCtxMenuCode = code;
const menu = this._dmCtxMenuEl;
if (!menu) return;
// Update mute label
const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
const muteBtn = menu.querySelector('[data-action="dm-mute"]');
if (muteBtn) muteBtn.textContent = muted.includes(code) ? `🔕 ${t('channels.unmute_dm')}` : `🔔 ${t('channels.mute_dm')}`;
// Show/hide "Mark as Read" based on unread count
const markReadBtn = menu.querySelector('[data-action="dm-mark-read"]');
if (markReadBtn) markReadBtn.style.display = (this.unreadCounts[code] > 0) ? '' : 'none';
// Position
if (mouseEvent) {
menu.style.top = mouseEvent.clientY + 'px';
menu.style.left = mouseEvent.clientX + 'px';
} else {
const rect = anchorEl.getBoundingClientRect();
menu.style.top = rect.bottom + 4 + 'px';
menu.style.left = rect.left + 'px';
}
menu.style.display = 'block';
// Keep inside viewport
requestAnimationFrame(() => {
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
if (mr.bottom > window.innerHeight) menu.style.top = (mr.top - mr.height - 4) + 'px';
});
},
_closeDmCtxMenu() {
if (this._dmCtxMenuEl) this._dmCtxMenuEl.style.display = 'none';
this._dmCtxMenuCode = null;
},
/* ── Sub-channel Subscriptions Panel ──────────────────── */
_openSubChannelPanel() {
const modal = document.getElementById('sub-panel-modal');
if (!modal) return;
// Run one-time migration: muted sub-channels → unsubbed, others → subbed
if (!localStorage.getItem('haven_sub_panel_migrated')) {
localStorage.setItem('haven_sub_panel_migrated', 'true');
// Existing muted list already represents unsubbed state — no changes needed.
// All non-muted channels are implicitly subscribed.
}
this._renderSubChannelPanel();
modal.style.display = 'flex';
// Close handlers
const closeBtn = document.getElementById('sub-panel-close-btn');
const closeHandler = () => {
modal.style.display = 'none';
closeBtn.removeEventListener('click', closeHandler);
modal.removeEventListener('click', overlayHandler);
};
const overlayHandler = (e) => { if (e.target === modal) closeHandler(); };
closeBtn.addEventListener('click', closeHandler);
modal.addEventListener('click', overlayHandler);
},
_renderSubChannelPanel() {
const container = document.getElementById('sub-panel-content');
if (!container) return;
container.innerHTML = '';
const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
const regularChannels = (this.channels || []).filter(c => !c.is_dm);
const subChannels = regularChannels.filter(c => c.parent_channel_id);
if (!subChannels.length) {
container.innerHTML = `<p style="text-align:center;opacity:0.5;padding:24px">${t('channels.no_sub_channels')}</p>`;
return;
}
// Group sub-channels by parent
const parentMap = {};
subChannels.forEach(sub => {
if (!parentMap[sub.parent_channel_id]) parentMap[sub.parent_channel_id] = [];
parentMap[sub.parent_channel_id].push(sub);
});
// Sort parents by position/name
const parentIds = Object.keys(parentMap).map(Number);
const parentChannels = parentIds.map(id => regularChannels.find(c => c.id === id)).filter(Boolean);
parentChannels.sort((a, b) => (a.position || 0) - (b.position || 0) || a.name.localeCompare(b.name));
parentChannels.forEach(parent => {
const subs = parentMap[parent.id] || [];
// Split into subscribed (not muted) and unsubscribed (muted)
const subbed = subs.filter(s => !muted.includes(s.code));
const unsubbed = subs.filter(s => muted.includes(s.code));
const section = document.createElement('div');
section.className = 'sub-panel-parent-section';
const header = document.createElement('h4');
header.className = 'sub-panel-parent-header';
header.textContent = `# ${parent.name}`;
section.appendChild(header);
// Render subbed tiles first, then a divider, then unsubbed
if (subbed.length) {
const subbedLabel = document.createElement('div');
subbedLabel.className = 'sub-panel-group-label';
subbedLabel.textContent = t('channels.subscribed');
section.appendChild(subbedLabel);
const subbedGrid = document.createElement('div');
subbedGrid.className = 'sub-panel-grid';
subbed.forEach(ch => subbedGrid.appendChild(this._createSubPanelTile(ch, true)));
section.appendChild(subbedGrid);
}
if (unsubbed.length) {
const unsubbedLabel = document.createElement('div');
unsubbedLabel.className = 'sub-panel-group-label unsubbed';
unsubbedLabel.textContent = t('channels.unsubscribed');
section.appendChild(unsubbedLabel);
const unsubbedGrid = document.createElement('div');
unsubbedGrid.className = 'sub-panel-grid';
unsubbed.forEach(ch => unsubbedGrid.appendChild(this._createSubPanelTile(ch, false)));
section.appendChild(unsubbedGrid);
}
container.appendChild(section);
});
},
_createSubPanelTile(ch, isSubbed) {
const tile = document.createElement('div');
tile.className = 'sub-panel-tile' + (isSubbed ? ' subbed' : ' unsubbed');
tile.dataset.code = ch.code;
const unread = this.unreadCounts[ch.code] || 0;
const unreadBadge = unread > 0 ? `<span class="sub-panel-badge">${unread > 99 ? '99+' : unread}</span>` : '';
tile.innerHTML = `
<label class="sub-panel-toggle" title="${isSubbed ? t('channels.unsubscribe_hint') : t('channels.subscribe_hint')}">
<input type="checkbox" ${isSubbed ? 'checked' : ''}>
<span class="sub-panel-toggle-label">${isSubbed ? '🔔' : '🔕'}</span>
</label>
<span class="sub-panel-tile-name">${ch.is_private ? '🔒 ' : ''}${this._escapeHtml(ch.name)}</span>
${unreadBadge}
`;
// Toggle sub/unsub
const checkbox = tile.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
const muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
const idx = muted.indexOf(ch.code);
if (checkbox.checked) {
// Subscribe: remove from muted
if (idx >= 0) muted.splice(idx, 1);
this._showToast(t('channels.subscribed_to', { name: ch.name }), 'success');
} else {
// Unsubscribe: add to muted
if (idx < 0) muted.push(ch.code);
this._showToast(t('channels.unsubscribed_from', { name: ch.name }), 'success');
}
localStorage.setItem('haven_muted_channels', JSON.stringify(muted));
// Re-render the panel and sidebar
this._renderSubChannelPanel();
this._renderChannels();
});
// Click tile (not checkbox) to jump to channel
tile.addEventListener('click', (e) => {
if (e.target.closest('.sub-panel-toggle')) return; // Don't navigate when toggling checkbox
document.getElementById('sub-panel-modal').style.display = 'none';
this.switchChannel(ch.code);
});
return tile;
},
/* ── Re-parent channel modal (move to / promote) ───── */
_openReparentModal(code) {
const ch = this.channels.find(c => c.code === code);
if (!ch) return;
const titleEl = document.getElementById('reparent-modal-title');
const descEl = document.getElementById('reparent-modal-desc');
const listEl = document.getElementById('reparent-channel-list');
titleEl.textContent = `📦 ${t('channels.move_channel')}`;
descEl.textContent = t('channels.move_channel_desc', { name: ch.name });
// Build list of valid parent targets (top-level channels that aren't this one)
const targets = this.channels.filter(c =>
!c.is_dm &&
!c.parent_channel_id && // Must be a top-level channel
c.id !== ch.id && // Can't parent under self
c.id !== ch.parent_channel_id // Skip current parent (already there)
).sort((a, b) => (a.position || 0) - (b.position || 0));
let html = '';
// If currently a sub-channel, show "Promote to top-level" option at the top
if (ch.parent_channel_id) {
html += `<div class="organize-item reparent-option" data-target="__top__" style="border-bottom:1px solid rgba(255,255,255,0.08);margin-bottom:4px;padding-bottom:8px">
<span style="opacity:0.5">⬆️</span>
<span style="flex:1"><strong>${t('channels.promote_to_top_level')}</strong></span>
</div>`;
}
for (const t of targets) {
const subCount = this.channels.filter(c => c.parent_channel_id === t.id).length;
const badge = subCount > 0 ? ` <span style="opacity:0.4;font-size:0.8em">${t('channels.sub_ch_count', { count: subCount })}</span>` : '';
html += `<div class="organize-item reparent-option" data-target="${t.code}">
<span style="opacity:0.5">#</span>
<span style="flex:1">${this._escapeHtml(t.name)}${badge}</span>
</div>`;
}
if (!targets.length && !ch.parent_channel_id) {
html += `<p style="text-align:center;opacity:0.5;padding:16px;font-size:0.85rem">${t('channels.no_valid_parents')}</p>`;
}
listEl.innerHTML = html;
// Wire up click handlers on the targets
listEl.querySelectorAll('.reparent-option').forEach(el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const newParentCode = target === '__top__' ? null : target;
const action = newParentCode === null
? t('channels.confirm_promote', { name: ch.name })
: t('channels.confirm_move', { name: ch.name, parent: this.channels.find(c => c.code === newParentCode)?.name || target });
if (confirm(action)) {
this.socket.emit('reparent-channel', { code, newParentCode });
document.getElementById('reparent-modal').style.display = 'none';
}
});
});
document.getElementById('reparent-modal').style.display = 'flex';
},
/* ── Organize sub-channels modal ─────────────────────── */
_openOrganizeModal(parentCode, serverLevel) {
if (serverLevel) {
// Server-level mode: organize top-level channels
const parents = this.channels.filter(c => !c.parent_channel_id && !c.is_dm);
this._organizeParentCode = '__server__';
this._organizeParentId = null;
this._organizeServerLevel = true;
this._organizeList = [...parents].sort((a, b) => (a.position || 0) - (b.position || 0));
this._organizeSelected = null;
this._organizeSelectedTag = null;
this._organizeTagSorts = JSON.parse(localStorage.getItem('haven_tag_sorts___server__') || this.serverSettings?.channel_tag_sorts || '{}');
this._organizeCatOrder = JSON.parse(localStorage.getItem('haven_cat_order___server__') || this.serverSettings?.channel_cat_order || '[]');
this._organizeCatSort = localStorage.getItem('haven_cat_sort___server__') || this.serverSettings?.channel_cat_sort || 'az';
document.getElementById('organize-modal-title').textContent = `📋 ${t('channels.organize_channels')}`;
document.getElementById('organize-modal-parent-name').textContent = t('channels.organize_desc');
// Server-level sort: check for personal override, else use server default
const sortSel = document.getElementById('organize-global-sort');
const localOverride = localStorage.getItem('haven_server_sort_mode');
sortSel.value = localOverride || 'server_default';
const catSortSel = document.getElementById('organize-cat-sort');
if (catSortSel) catSortSel.value = this._organizeCatSort;
document.getElementById('organize-tag-input').value = '';
const backBtn = document.getElementById('organize-back-btn');
if (backBtn) backBtn.style.display = 'none';
// Hide admin-only controls (move/tag) for non-admin users at server level
const canManage = this.user?.isAdmin || this._hasPerm('manage_server') || this._hasPerm('create_channel');
document.querySelector('.organize-controls')?.style.setProperty('display', canManage ? '' : 'none');
this._renderOrganizeList();
document.getElementById('organize-modal').style.display = 'flex';
return;
}
const parent = this.channels.find(c => c.code === parentCode);
if (!parent) return;
const subs = this.channels.filter(c => c.parent_channel_id === parent.id);
this._organizeParentCode = parentCode;
this._organizeParentId = parent.id;
this._organizeServerLevel = false;
this._organizeList = [...subs].sort((a, b) => (a.position || 0) - (b.position || 0));
this._organizeSelected = null;
this._organizeSelectedTag = null;
// Per-tag sort overrides: tag → 'manual'|'alpha'|'created'|'oldest' (persisted in localStorage)
this._organizeTagSorts = JSON.parse(localStorage.getItem(`haven_tag_sorts_${parentCode}`) || '{}');
this._organizeCatOrder = JSON.parse(localStorage.getItem(`haven_cat_order_${parentCode}`) || '[]');
this._organizeCatSort = localStorage.getItem(`haven_cat_sort_${parentCode}`) || 'az';
document.getElementById('organize-modal-title').textContent = `📋 ${t('channels.organize_sub_channels')}`;
document.getElementById('organize-modal-parent-name').textContent = `# ${parent.name}`;
// Map sort_alphabetical: 0=manual, 1=alpha, 2=created
const sortSel = document.getElementById('organize-global-sort');
sortSel.value = parent.sort_alphabetical === 1 ? 'alpha' : parent.sort_alphabetical === 2 ? 'created' : parent.sort_alphabetical === 3 ? 'oldest' : parent.sort_alphabetical === 4 ? 'dynamic' : 'manual';
const catSortSel = document.getElementById('organize-cat-sort');
if (catSortSel) catSortSel.value = this._organizeCatSort;
document.getElementById('organize-tag-input').value = '';
const backBtn = document.getElementById('organize-back-btn');
if (backBtn) {
backBtn.style.display = '';
// Replace listener with a fresh one each time
const newBtn = backBtn.cloneNode(true);
backBtn.parentNode.replaceChild(newBtn, backBtn);
newBtn.addEventListener('click', () => this._openOrganizeModal(null, true));
}
// Sub-channel organize: always show controls (already permission-gated by context menu)
document.querySelector('.organize-controls')?.style.setProperty('display', '');
this._renderOrganizeList();
document.getElementById('organize-modal').style.display = 'flex';
},
_renderOrganizeList() {
const listEl = document.getElementById('organize-channel-list');
let globalSort = document.getElementById('organize-global-sort').value;
// Resolve "server_default" to the actual server sort mode
if (globalSort === 'server_default') globalSort = this.serverSettings?.channel_sort_mode || 'manual';
let displayList = [...(this._organizeList || [])];
// Collect unique tags (case-insensitive dedup, keep first-seen casing)
const _orgTagMap = new Map();
displayList.filter(c => c.category).forEach(c => {
const key = c.category.toLowerCase();
if (!_orgTagMap.has(key)) _orgTagMap.set(key, c.category);
});
const realTags = [..._orgTagMap.values()];
const hasUntagged = displayList.some(c => !c.category);
const hasTags = realTags.length > 0;
// Build the full ordered keys list: real tags + __untagged__ (if applicable)
const allKeys = [...realTags];
if (hasUntagged && hasTags) allKeys.push('__untagged__');
// Show/hide category toolbar
const catToolbar = document.getElementById('organize-cat-toolbar');
if (catToolbar) catToolbar.style.display = hasTags ? 'flex' : 'none';
// Sort category headers by chosen mode
const catSort = this._organizeCatSort || 'az';
if (catSort === 'az') {
allKeys.sort((a, b) => {
if (a === '__untagged__') return 1; if (b === '__untagged__') return -1;
return a.localeCompare(b);
});
} else if (catSort === 'za') {
allKeys.sort((a, b) => {
if (a === '__untagged__') return 1; if (b === '__untagged__') return -1;
return b.localeCompare(a);
});
} else {
// manual — use stored order
const order = this._organizeCatOrder || [];
allKeys.sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
if (ia === -1 && ib === -1) {
if (a === '__untagged__') return 1; if (b === '__untagged__') return -1;
return a.localeCompare(b);
}
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
}
// Sort within each tag group
const sortGroup = (arr, mode) => {
if (mode === 'alpha') {
arr.sort((a, b) => a.name.localeCompare(b.name));
} else if (mode === 'created') {
arr.sort((a, b) => (b.id || 0) - (a.id || 0)); // Higher ID = newer
} else if (mode === 'oldest') {
arr.sort((a, b) => (a.id || 0) - (b.id || 0)); // Lower ID = older
} else if (mode === 'dynamic') {
arr.sort((a, b) => (b.latestMessageId || 0) - (a.latestMessageId || 0)); // Most recent activity first
} else {
arr.sort((a, b) => (a.position || 0) - (b.position || 0));
}
return arr;
};
// Build grouped display
let grouped = [];
if (hasTags) {
for (const key of allKeys) {
if (key === '__untagged__') {
const untagged = displayList.filter(c => !c.category);
if (untagged.length) {
const untaggedSort = this._organizeTagSorts['__untagged__'] || globalSort;
grouped.push({ tag: '', items: sortGroup(untagged, untaggedSort), sort: untaggedSort });
}
} else {
const tagSort = this._organizeTagSorts[key] || globalSort;
const keyLower = key.toLowerCase();
const tagItems = sortGroup(displayList.filter(c => c.category && c.category.toLowerCase() === keyLower), tagSort);
grouped.push({ tag: key, items: tagItems, sort: tagSort });
}
}
} else {
grouped.push({ tag: '', items: sortGroup(displayList, globalSort), sort: globalSort });
}
let html = '';
for (const group of grouped) {
// Tag header
if (hasTags) {
const tagKey = group.tag || '__untagged__';
const label = group.tag ? this._escapeHtml(group.tag) : t('channels.untagged');
const isTagSelected = this._organizeSelectedTag === tagKey;
html += `<div class="organize-tag-header${isTagSelected ? ' selected' : ''}" data-tag-key="${this._escapeHtml(tagKey)}">
<span>${label}</span>
<select class="tag-sort-select" data-tag="${this._escapeHtml(tagKey)}" title="Sort this group">
<option value="manual"${group.sort === 'manual' ? ' selected' : ''}>${t('channels.sort.manual')}</option>
<option value="alpha"${group.sort === 'alpha' ? ' selected' : ''}>${t('channels.sort.alpha')}</option>
<option value="created"${group.sort === 'created' ? ' selected' : ''}>${t('channels.sort.newest')}</option>
<option value="oldest"${group.sort === 'oldest' ? ' selected' : ''}>${t('channels.sort.oldest')}</option>
<option value="dynamic"${group.sort === 'dynamic' ? ' selected' : ''}>${t('channels.sort.dynamic')}</option>
</select>
</div>`;
}
for (const ch of group.items) {
const sel = this._organizeSelected === ch.code;
const tagBadge = ch.category ? `<span class="organize-tag-badge">${this._escapeHtml(ch.category)}</span>` : '';
const icon = this._organizeServerLevel ? '#' : (ch.is_private ? '🔒' : '↳');
const hasSubs = this._organizeServerLevel && this.channels.some(c => c.parent_channel_id === ch.id);
const drillHint = hasSubs ? `<span class="organize-drill-hint" title="${t('channels.drill_hint')}">▶</span>` : '';
html += `<div class="organize-item${sel ? ' selected' : ''}${hasSubs ? ' organize-has-subs' : ''}" data-code="${ch.code}">
<span style="opacity:0.5">${icon}</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${this._escapeHtml(ch.name)}</span>
${tagBadge}${drillHint}
</div>`;
}
}
if (!displayList.length) {
html = `<div style="padding:24px;text-align:center;opacity:0.4;font-size:0.9rem">${this._organizeServerLevel ? t('channels.no_channels_yet') : t('channels.no_sub_channels_yet')}</div>`;
}
listEl.innerHTML = html;
// Click to select channel
listEl.querySelectorAll('.organize-item').forEach(el => {
el.addEventListener('click', () => {
this._organizeSelected = el.dataset.code;
this._organizeSelectedTag = null; // clear tag selection
const ch = this._organizeList.find(c => c.code === el.dataset.code);
document.getElementById('organize-tag-input').value = (ch && ch.category) || '';
this._renderOrganizeList();
});
// Double-click on a parent channel (server-level mode) drills into its sub-channels
if (this._organizeServerLevel) {
el.addEventListener('dblclick', () => {
const ch = this.channels.find(c => c.code === el.dataset.code);
if (!ch) return;
const hasSubs = this.channels.some(c => c.parent_channel_id === ch.id);
if (hasSubs) this._openOrganizeModal(ch.code);
});
}
});
// Click tag header to select category
listEl.querySelectorAll('.organize-tag-header').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-sort-select')) return; // ignore dropdown clicks
this._organizeSelectedTag = el.dataset.tagKey;
this._organizeSelected = null; // clear channel selection
document.getElementById('organize-tag-input').value = '';
this._renderOrganizeList();
});
});
// Per-tag sort dropdowns
listEl.querySelectorAll('.tag-sort-select').forEach(sel => {
sel.addEventListener('click', (e) => e.stopPropagation());
sel.addEventListener('change', (e) => {
e.stopPropagation();
const tagKey = sel.dataset.tag;
this._organizeTagSorts[tagKey] = sel.value;
// Persist per-tag sorts so sidebar respects them
localStorage.setItem(`haven_tag_sorts_${this._organizeParentCode}`, JSON.stringify(this._organizeTagSorts));
// Server-level: sync to server so all users see category-specific sorts
if (this._organizeServerLevel && (this.user?.isAdmin || this._hasPerm('manage_server'))) {
this.socket.emit('update-server-setting', { key: 'channel_tag_sorts', value: JSON.stringify(this._organizeTagSorts) });
}
this._renderOrganizeList();
});
});
// Disable up/down based on selection type
let canMoveUp = false, canMoveDown = false;
if (this._organizeSelectedTag) {
// Category selected — always allow movement; handler auto-switches to manual mode
const orderedTags = grouped.map(g => g.tag || '__untagged__');
const tagIdx = orderedTags.indexOf(this._organizeSelectedTag);
canMoveUp = tagIdx > 0;
canMoveDown = tagIdx >= 0 && tagIdx < orderedTags.length - 1;
} else if (this._organizeSelected) {
// Channel selected — can move if its tag group sort is manual
const ch = this._organizeList.find(c => c.code === this._organizeSelected);
if (ch) {
const { group, effectiveSort } = this._getOrganizeVisualGroup(ch);
if (effectiveSort === 'manual') {
const groupIdx = group.findIndex(c => c.code === this._organizeSelected);
canMoveUp = groupIdx > 0;
canMoveDown = groupIdx >= 0 && groupIdx < group.length - 1;
}
}
}
document.getElementById('organize-move-up').disabled = !canMoveUp;
document.getElementById('organize-move-down').disabled = !canMoveDown;
document.getElementById('organize-set-tag').disabled = !this._organizeSelected;
document.getElementById('organize-remove-tag').disabled = !this._organizeSelected;
},
/**
* Get the sorted visual group of channels for the organize modal.
* Returns the channels in the same tag group as `ch`, sorted by
* the effective sort mode, plus the sort mode string.
*/
_getOrganizeVisualGroup(ch) {
let globalSort = document.getElementById('organize-global-sort').value;
if (globalSort === 'server_default') globalSort = this.serverSettings?.channel_sort_mode || 'manual';
const tagKey = ch.category || '__untagged__';
const effectiveSort = this._organizeTagSorts[tagKey] || globalSort;
// Collect channels in the same tag group (case-insensitive)
const group = ch.category
? this._organizeList.filter(c => c.category && c.category.toLowerCase() === ch.category.toLowerCase())
: this._organizeList.filter(c => !c.category);
// Sort by effective mode (mirrors _renderOrganizeList's sortGroup)
if (effectiveSort === 'alpha') {
group.sort((a, b) => a.name.localeCompare(b.name));
} else if (effectiveSort === 'created') {
group.sort((a, b) => (b.id || 0) - (a.id || 0));
} else if (effectiveSort === 'oldest') {
group.sort((a, b) => (a.id || 0) - (b.id || 0));
} else if (effectiveSort === 'dynamic') {
group.sort((a, b) => (b.latestMessageId || 0) - (a.latestMessageId || 0));
} else {
group.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
}
return { group, effectiveSort };
},
/**
* Move a category group up or down in the order.
* @param {number} direction -1 for up, +1 for down
*/
_moveCategoryInOrder(direction) {
if (!this._organizeSelectedTag) return;
// Build full ordered keys (real tags + __untagged__) from channel data
const displayList = [...(this._organizeList || [])];
const realTags = [...new Map(displayList.filter(c => c.category).map(c => [c.category.toLowerCase(), c.category])).values()];
const hasUntagged = displayList.some(c => !c.category);
const allKeys = [...realTags];
if (hasUntagged) allKeys.push('__untagged__');
// Sort by current mode to match the visual order (same logic as _renderOrganizeList)
const catSort = this._organizeCatSort || 'az';
if (catSort === 'az') {
allKeys.sort((a, b) => {
if (a === '__untagged__') return 1; if (b === '__untagged__') return -1;
return a.localeCompare(b);
});
} else if (catSort === 'za') {
allKeys.sort((a, b) => {
if (a === '__untagged__') return 1; if (b === '__untagged__') return -1;
return b.localeCompare(a);
});
} else {
const order = this._organizeCatOrder || [];
allKeys.sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
if (ia === -1 && ib === -1) {
if (a === '__untagged__') return 1; if (b === '__untagged__') return -1;
return a.localeCompare(b);
}
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
}
const idx = allKeys.indexOf(this._organizeSelectedTag);
const targetIdx = idx + direction;
if (idx < 0 || targetIdx < 0 || targetIdx >= allKeys.length) return;
// Swap
[allKeys[idx], allKeys[targetIdx]] = [allKeys[targetIdx], allKeys[idx]];
// Switch to manual mode
this._organizeCatSort = 'manual';
this._organizeCatOrder = allKeys;
document.getElementById('organize-cat-sort').value = 'manual';
// Persist
localStorage.setItem(`haven_cat_order_${this._organizeParentCode}`, JSON.stringify(allKeys));
localStorage.setItem(`haven_cat_sort_${this._organizeParentCode}`, 'manual');
// Server-level: sync category order to server so all users see it
if (this._organizeServerLevel && (this.user?.isAdmin || this._hasPerm('manage_server'))) {
this.socket.emit('update-server-setting', { key: 'channel_cat_order', value: JSON.stringify(allKeys) });
this.socket.emit('update-server-setting', { key: 'channel_cat_sort', value: 'manual' });
}
this._renderOrganizeList();
if (this._organizeServerLevel) this._renderChannels();
},
/* ── DM Organize (client-side, localStorage) ─────────── */
_openDmOrganizeModal() {
const dmChannels = this.channels.filter(c => c.is_dm);
const order = JSON.parse(localStorage.getItem('haven_dm_order') || '[]');
const assignments = JSON.parse(localStorage.getItem('haven_dm_assignments') || '{}');
// Build list sorted by saved order, then alphabetical for unknowns
const ordered = [];
for (const code of order) {
const ch = dmChannels.find(c => c.code === code);
if (ch) ordered.push(ch);
}
for (const ch of dmChannels) {
if (!ordered.includes(ch)) ordered.push(ch);
}
this._dmOrganizeList = ordered;
this._dmOrganizeSelected = null;
const sortSel = document.getElementById('dm-organize-sort');
sortSel.value = localStorage.getItem('haven_dm_sort_mode') || 'manual';
document.getElementById('dm-organize-tag-input').value = '';
this._renderDmOrganizeList();
document.getElementById('dm-organize-modal').style.display = 'flex';
},
_saveDmOrder() {
localStorage.setItem('haven_dm_order', JSON.stringify(this._dmOrganizeList.map(c => c.code)));
},
_renderDmOrganizeList() {
const listEl = document.getElementById('dm-organize-list');
const sortMode = document.getElementById('dm-organize-sort').value;
const assignments = JSON.parse(localStorage.getItem('haven_dm_assignments') || '{}');
let displayList = [...(this._dmOrganizeList || [])];
// Collect unique tags
const allTags = [...new Set(displayList.map(c => assignments[c.code]).filter(Boolean))].sort();
const hasTags = allTags.length > 0;
const getDmName = (ch) => ch.dm_target ? this._getNickname(ch.dm_target.id, ch.dm_target.username) : t('channels.unknown_user');
const sortGroup = (arr, mode) => {
if (mode === 'alpha') {
arr.sort((a, b) => getDmName(a).localeCompare(getDmName(b)));
} else if (mode === 'recent') {
arr.sort((a, b) => (b.last_activity || 0) - (a.last_activity || 0));
}
// manual = keep current order
return arr;
};
let grouped = [];
if (hasTags) {
for (const tag of allTags) {
const tagItems = sortGroup(displayList.filter(c => assignments[c.code] === tag), sortMode);
grouped.push({ tag, items: tagItems });
}
const untagged = displayList.filter(c => !assignments[c.code]);
if (untagged.length) {
grouped.push({ tag: '', items: sortGroup(untagged, sortMode) });
}
} else {
grouped.push({ tag: '', items: sortGroup(displayList, sortMode) });
}
let html = '';
for (const group of grouped) {
if (group.tag) {
html += `<div class="organize-tag-header">🏷️ ${this._escapeHtml(group.tag)}</div>`;
} else if (hasTags) {
html += `<div class="organize-tag-header" style="opacity:0.5">${t('channels.uncategorized')}</div>`;
}
for (const ch of group.items) {
const name = getDmName(ch);
const sel = ch.code === this._dmOrganizeSelected ? ' selected' : '';
const tagBadge = assignments[ch.code] ? `<span class="organize-tag-badge">${this._escapeHtml(assignments[ch.code])}</span>` : '';
html += `<div class="organize-item${sel}" data-code="${ch.code}">
<span class="organize-item-name">@ ${this._escapeHtml(name)}</span>
${tagBadge}
</div>`;
}
}
listEl.innerHTML = html || `<p class="muted-text">${t('channels.no_dms_to_organize')}</p>`;
// Click to select
listEl.querySelectorAll('.organize-item').forEach(el => {
el.addEventListener('click', () => {
this._dmOrganizeSelected = el.dataset.code;
listEl.querySelectorAll('.organize-item').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
// Pre-fill tag input with current tag
const currentTag = assignments[el.dataset.code] || '';
document.getElementById('dm-organize-tag-input').value = currentTag;
this._updateDmOrganizeButtons();
});
});
this._updateDmOrganizeButtons();
},
_updateDmOrganizeButtons() {
const sortMode = document.getElementById('dm-organize-sort').value;
const isManual = sortMode === 'manual';
document.getElementById('dm-organize-move-up').disabled = !isManual || !this._dmOrganizeSelected;
document.getElementById('dm-organize-move-down').disabled = !isManual || !this._dmOrganizeSelected;
document.getElementById('dm-organize-set-tag').disabled = !this._dmOrganizeSelected;
document.getElementById('dm-organize-remove-tag').disabled = !this._dmOrganizeSelected;
},
_openWebhookModal(channelCode) {
const ch = this.channels.find(c => c.code === channelCode);
const modal = document.getElementById('webhook-modal');
modal._channelCode = channelCode;
document.getElementById('webhook-modal-channel-name').textContent = ch ? `# ${ch.name}` : '';
document.getElementById('webhook-name-input').value = '';
document.getElementById('webhook-token-reveal').style.display = 'none';
document.getElementById('webhook-list').innerHTML = `<p style="opacity:0.5;font-size:0.85rem">${t('channels.webhook_loading')}</p>`;
modal.style.display = 'flex';
this.socket.emit('get-webhooks', { channelCode });
},
_renderWebhookList(webhooks, channelCode) {
const container = document.getElementById('webhook-list');
if (!webhooks.length) {
container.innerHTML = `<p style="opacity:0.5;font-size:0.85rem">${t('channels.no_webhooks')}</p>`;
return;
}
container.innerHTML = webhooks.map(wh => {
const maskedToken = wh.token.slice(0, 8) + '••••••••';
const statusLabel = wh.is_active ? `🟢 ${t('channels.webhook_active')}` : `🔴 ${t('channels.webhook_disabled')}`;
const toggleLabel = wh.is_active ? t('channels.webhook_disable') : t('channels.webhook_enable');
return `
<div class="webhook-item" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:6px;background:rgba(255,255,255,0.04);margin-bottom:6px">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:0.9rem">${this._escapeHtml(wh.name)}</div>
<div style="font-size:0.75rem;opacity:0.5;font-family:monospace">${maskedToken}</div>
</div>
<span style="font-size:0.75rem;white-space:nowrap">${statusLabel}</span>
<button class="btn-xs webhook-toggle-btn" data-id="${wh.id}" style="font-size:0.75rem">${toggleLabel}</button>
<button class="btn-xs webhook-delete-btn" data-id="${wh.id}" style="font-size:0.75rem;color:#ff4444">🗑️</button>
</div>`;
}).join('');
container.querySelectorAll('.webhook-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (confirm(t('channels.webhook_delete_confirm'))) {
this.socket.emit('delete-webhook', { webhookId: parseInt(btn.dataset.id) });
}
});
});
container.querySelectorAll('.webhook-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.socket.emit('toggle-webhook', { webhookId: parseInt(btn.dataset.id) });
});
});
},
_renderChannels() {
const list = document.getElementById('channel-list');
list.innerHTML = '';
const regularChannels = this.channels.filter(c => !c.is_dm);
const dmChannels = this.channels.filter(c => c.is_dm);
// Build parent → sub-channel tree
const parentChannels = regularChannels.filter(c => !c.parent_channel_id);
const subChannelMap = {};
regularChannels.filter(c => c.parent_channel_id).forEach(c => {
if (!subChannelMap[c.parent_channel_id]) subChannelMap[c.parent_channel_id] = [];
subChannelMap[c.parent_channel_id].push(c);
});
// Show/hide sub-channel panel button based on whether sub-channels exist
const subPanelBtn = document.getElementById('sub-channel-panel-btn');
if (subPanelBtn) subPanelBtn.style.display = Object.keys(subChannelMap).length > 0 ? '' : 'none';
// Sort sub-channels — respect parent's sort_alphabetical setting & per-tag overrides
// sort_alphabetical: 0=manual, 1=alpha, 2=created, 3=oldest
// Per-tag overrides (from organize modal) are stored in localStorage
Object.entries(subChannelMap).forEach(([parentId, arr]) => {
const parent = parentChannels.find(p => p.id === parseInt(parentId));
const globalSortMode = parent ? parent.sort_alphabetical : 0;
const hasTags = arr.some(c => c.category);
// Load per-tag sort overrides
const tagOverrides = parent ? JSON.parse(localStorage.getItem(`haven_tag_sorts_${parent.code}`) || '{}') : {};
// Tag grouping helper (groups by tag name, respects stored category order)
const catOrder = parent ? JSON.parse(localStorage.getItem(`haven_cat_order_${parent.code}`) || '[]') : [];
const catSort = parent ? (localStorage.getItem(`haven_cat_sort_${parent.code}`) || 'az') : 'az';
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA.toLowerCase() !== tagB.toLowerCase()) {
const keyA = tagA || '__untagged__';
const keyB = tagB || '__untagged__';
if (catSort === 'manual') {
const iA = catOrder.indexOf(keyA); const iB = catOrder.indexOf(keyB);
if (iA !== -1 || iB !== -1) {
if (iA === -1) return 1; if (iB === -1) return -1;
return iA - iB;
}
}
// Default: untagged at bottom, then alphabetical
if (!tagA) return 1;
if (!tagB) return -1;
if (catSort === 'za') return tagB.localeCompare(tagA);
return tagA.localeCompare(tagB);
}
return 0;
};
// Sort function for a given mode
const sortByMode = (a, b, mode) => {
if (mode === 1 || mode === 'alpha') return a.name.localeCompare(b.name);
if (mode === 2 || mode === 'created') return (b.id || 0) - (a.id || 0);
if (mode === 3 || mode === 'oldest') return (a.id || 0) - (b.id || 0);
if (mode === 4 || mode === 'dynamic') return (b.latestMessageId || 0) - (a.latestMessageId || 0);
return (a.position || 0) - (b.position || 0); // manual
};
// Map string modes to numbers for consistency
const modeToNum = (m) => m === 'alpha' ? 1 : m === 'created' ? 2 : m === 'oldest' ? 3 : m === 'dynamic' ? 4 : m === 'manual' ? 0 : m;
if (hasTags) {
// Sort by tag group first, then within each group use per-tag override or global
arr.sort((a, b) => {
const g = tagGroup(a, b);
if (g !== 0) return g;
// Same tag group — check per-tag override
const tag = a.category || '__untagged__';
const override = tagOverrides[tag];
const effectiveMode = override !== undefined ? modeToNum(override) : globalSortMode;
return sortByMode(a, b, effectiveMode);
});
} else {
arr.sort((a, b) => sortByMode(a, b, globalSortMode));
}
// Secondary sort: subscribed (not muted) sub-channels appear before unsubscribed (muted)
const _subMuted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
arr.sort((a, b) => {
const aMuted = _subMuted.includes(a.code) ? 1 : 0;
const bMuted = _subMuted.includes(b.code) ? 1 : 0;
return aMuted - bMuted; // stable sort preserves original order within same group
});
});
// Sort parent channels — respect server-level sort mode & per-tag overrides
const localSortOverride = localStorage.getItem('haven_server_sort_mode');
const serverSortMode = localSortOverride || this.serverSettings?.channel_sort_mode || 'manual';
// Per-tag overrides: prefer localStorage (admin's local state) then fall back to server settings
const localTagOverrides = localStorage.getItem('haven_tag_sorts___server__');
const serverTagOverrides = JSON.parse(localTagOverrides || this.serverSettings?.channel_tag_sorts || '{}');
const parentHasTags = parentChannels.some(c => c.category);
const serverSortByMode = (a, b, mode) => {
if (mode === 'alpha') return a.name.localeCompare(b.name);
if (mode === 'created') return (b.id || 0) - (a.id || 0);
if (mode === 'oldest') return (a.id || 0) - (b.id || 0);
if (mode === 'dynamic') return (b.latestMessageId || 0) - (a.latestMessageId || 0);
return (a.position || 0) - (b.position || 0) || a.name.localeCompare(b.name); // manual
};
// Load stored category order for server-level categories
// Prefer localStorage (admin's local state) then fall back to server settings
const localCatOrder = localStorage.getItem('haven_cat_order___server__');
const serverCatOrder = JSON.parse(localCatOrder || this.serverSettings?.channel_cat_order || '[]');
const localCatSort = localStorage.getItem('haven_cat_sort___server__');
const serverCatSort = localCatSort || this.serverSettings?.channel_cat_sort || 'az';
if (parentHasTags) {
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA.toLowerCase() !== tagB.toLowerCase()) {
const keyA = tagA || '__untagged__';
const keyB = tagB || '__untagged__';
if (serverCatSort === 'manual') {
const iA = serverCatOrder.indexOf(keyA); const iB = serverCatOrder.indexOf(keyB);
if (iA !== -1 || iB !== -1) {
if (iA === -1) return 1; if (iB === -1) return -1;
return iA - iB;
}
}
// Default: untagged at bottom, then alphabetical
if (!tagA) return 1;
if (!tagB) return -1;
if (serverCatSort === 'za') return tagB.localeCompare(tagA);
return tagA.localeCompare(tagB);
}
return 0;
};
parentChannels.sort((a, b) => {
const g = tagGroup(a, b);
if (g !== 0) return g;
const tag = a.category || '__untagged__';
const override = serverTagOverrides[tag];
const effectiveMode = override !== undefined ? override : serverSortMode;
return serverSortByMode(a, b, effectiveMode);
});
} else {
parentChannels.sort((a, b) => serverSortByMode(a, b, serverSortMode));
}
const renderChannelItem = (ch, isSub) => {
const el = document.createElement('div');
el.className = 'channel-item' + (isSub ? ' sub-channel-item' : '') + (ch.is_private ? ' private-channel' : '') + (ch.code === this.currentChannel ? ' active' : '');
el.dataset.code = ch.code;
if (isSub) el.dataset.parentId = ch.parent_channel_id;
const hasSubs = !isSub && (subChannelMap[ch.id] || []).length > 0;
const isCollapsed = hasSubs && localStorage.getItem(`haven_subs_collapsed_${ch.code}`) === 'true';
const isAnnouncement = ch.notification_type === 'announcement';
const isTemporary = !!ch.expires_at;
const isTempVoice = !!ch.is_temp_voice;
const hashIcon = isSub ? (ch.is_private ? '🔒' : '↳') : (isTempVoice ? '🔊' : (isTemporary ? '⏱️' : (isAnnouncement ? '📢' : '#')));
// Build small status indicators for channel features
const _badges = [];
if (!isSub) {
if (ch.streams_enabled === 0) _badges.push(`<span class="ch-disabled-badge" title="${t('channels.screen_share_not_allowed')}">🖥️</span>`);
if (ch.music_enabled === 0) _badges.push(`<span class="ch-disabled-badge" title="${t('channels.music_not_allowed')}">🎵</span>`);
if (ch.slow_mode_interval > 0) _badges.push(`<span title="${t('channels.slow_mode_title', { seconds: ch.slow_mode_interval })}" style="opacity:0.5;font-size:0.65rem">🐢</span>`);
if (ch.cleanup_exempt === 1) _badges.push(`<span title="${t('channels.cleanup_exempt_title')}" style="opacity:0.5;font-size:0.65rem">🛡️</span>`);
}
const _mutedList = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]');
if (_mutedList.includes(ch.code)) _badges.push(`<span class="ch-disabled-badge" title="${t('channels.muted_unsubscribed')}">🔕</span>`);
const indicators = _badges.length ? `<span class="channel-indicators" style="margin-left:auto;display:flex;gap:2px;align-items:center;flex-shrink:0">${_badges.join('')}</span>` : '';
const expiryTitle = isTemporary ? ` title="${t('channels.temporary_expires', { date: new Date(ch.expires_at).toLocaleString() })}"` : '';
el.innerHTML = `
${hasSubs ? `<span class="channel-collapse-arrow${isCollapsed ? ' collapsed' : ''}" title="${t('channels.expand_collapse')}">▾</span>` : ''}
<span class="channel-hash"${expiryTitle}>${hashIcon}</span>
<span class="channel-name">${this._escapeHtml(ch.name)}</span>
${indicators}
<button class="channel-more-btn" title="${t('channels.channel_options')}">⋯</button>
`;
// If parent has sub-channels, clicking the arrow toggles them
if (hasSubs) {
const arrow = el.querySelector('.channel-collapse-arrow');
arrow.addEventListener('click', (e) => {
e.stopPropagation();
const collapsed = arrow.classList.toggle('collapsed');
localStorage.setItem(`haven_subs_collapsed_${ch.code}`, collapsed);
document.querySelectorAll(`.sub-channel-item[data-parent-id="${ch.id}"], .sub-tag-label[data-parent-id="${ch.id}"]`).forEach(sub => {
sub.style.display = collapsed ? 'none' : '';
});
if (collapsed) {
// Bubble up sub-channel unreads to the parent
const subTotal = this.channels
.filter(c => c.parent_channel_id === ch.id)
.reduce((sum, c) => sum + (this.unreadCounts[c.code] || 0), 0);
if (subTotal > 0) {
let bubble = el.querySelector('.channel-badge-bubble');
if (!bubble) {
bubble = document.createElement('span');
bubble.className = 'channel-badge channel-badge-bubble';
el.appendChild(bubble);
}
bubble.textContent = subTotal > 99 ? '99+' : subTotal;
}
} else {
// Remove the parent bubble when expanding — individual sub-channel badges are now visible
const bubble = el.querySelector('.channel-badge-bubble');
if (bubble) bubble.remove();
}
});
}
const count = (ch.code in this.unreadCounts) ? this.unreadCounts[ch.code] : (ch.unreadCount || 0);
if (count > 0) {
const badge = document.createElement('span');
badge.className = 'channel-badge' + (isAnnouncement ? ' announcement-badge' : '');
badge.textContent = count > 99 ? '99+' : count;
el.appendChild(badge);
}
el.addEventListener('click', () => this.switchChannel(ch.code));
// Double-click to join voice in the channel (blocked for text-only)
el.addEventListener('dblclick', () => {
const _dblCh = this.channels.find(c => c.code === ch.code);
if (_dblCh && _dblCh.voice_enabled === 0) return;
if (!this.user?.isAdmin && !this._hasPerm('use_voice')) return;
this.switchChannel(ch.code);
setTimeout(() => this._joinVoice(), 300);
});
// Right-click to open context menu
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
const btn = el.querySelector('.channel-more-btn');
if (btn) this._openChannelCtxMenu(ch.code, btn);
});
return el;
};
// ── Channels toggle (collapsible) ──
const channelsCollapsed = localStorage.getItem('haven_channels_collapsed') === 'true';
const channelsArrow = document.getElementById('channels-toggle-arrow');
if (channelsArrow) {
channelsArrow.classList.toggle('collapsed', channelsCollapsed);
}
// Set up channels toggle click (only once)
if (!this._channelsToggleBound) {
this._channelsToggleBound = true;
document.getElementById('channels-toggle')?.addEventListener('click', (e) => {
// Ignore clicks on the organize button or sub-panel button inside the header
if (e.target.closest('#organize-channels-btn')) return;
if (e.target.closest('#sub-channel-panel-btn')) return;
const nowCollapsed = list.style.display !== 'none';
list.style.display = nowCollapsed ? 'none' : '';
const arrow = document.getElementById('channels-toggle-arrow');
if (arrow) arrow.classList.toggle('collapsed', nowCollapsed);
localStorage.setItem('haven_channels_collapsed', nowCollapsed);
// Adjust pane flex so DMs fill when channels collapsed
const channelsPane = document.getElementById('channels-pane');
const dmPane = document.getElementById('dm-pane');
if (nowCollapsed) {
channelsPane.style.flex = '0 0 auto';
dmPane.style.flex = '1 1 0';
} else {
const savedRatio = localStorage.getItem('haven_sidebar_split_ratio');
const ratio = savedRatio ? parseFloat(savedRatio) : 0.6;
channelsPane.style.flex = `${ratio} 1 0`;
dmPane.style.flex = `${1 - ratio} 1 0`;
}
});
// Organize Channels button (admin only)
document.getElementById('organize-channels-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
this._openOrganizeModal(null, true); // server-level mode
});
// Sub-channel subscriptions panel button
document.getElementById('sub-channel-panel-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
this._openSubChannelPanel();
});
}
if (channelsCollapsed) {
list.style.display = 'none';
const cp = document.getElementById('channels-pane');
const dp = document.getElementById('dm-pane');
if (cp) cp.style.flex = '0 0 auto';
if (dp) dp.style.flex = '1 1 0';
}
// ── Render channels grouped by category (case-insensitive) ──
const categories = new Map();
const _catCanonical = new Map(); // lowercase -> first-seen casing
parentChannels.forEach(ch => {
const raw = ch.category || '';
const key = raw.toLowerCase();
if (!_catCanonical.has(key)) _catCanonical.set(key, raw);
const cat = _catCanonical.get(key);
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat).push(ch);
});
const sortedCats = [...categories.keys()].sort((a, b) => {
const keyA = a || '__untagged__';
const keyB = b || '__untagged__';
if (serverCatSort === 'manual') {
const iA = serverCatOrder.indexOf(keyA); const iB = serverCatOrder.indexOf(keyB);
if (iA !== -1 || iB !== -1) {
if (iA === -1) return 1; if (iB === -1) return -1;
return iA - iB;
}
}
// Default: untagged first (empty string), then alphabetical
if (!a) return -1; if (!b) return 1;
if (serverCatSort === 'za') return b.localeCompare(a);
return a.localeCompare(b);
});
for (const cat of sortedCats) {
const catKey = cat || '';
const catCollapsed = cat ? localStorage.getItem(`haven_cat_collapsed_${cat}`) === 'true' : false;
if (cat) {
const catLabel = document.createElement('h5');
catLabel.className = 'section-label category-label';
catLabel.style.cssText = 'padding:10px 12px 4px;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;opacity:0.5;user-select:none;cursor:pointer;display:flex;align-items:center;gap:4px';
catLabel.dataset.category = cat;
const arrow = document.createElement('span');
arrow.className = 'cat-collapse-arrow' + (catCollapsed ? ' collapsed' : '');
arrow.textContent = '▾';
catLabel.appendChild(arrow);
const catText = document.createElement('span');
catText.textContent = cat;
catLabel.appendChild(catText);
list.appendChild(catLabel);
catLabel.addEventListener('click', () => {
const nowCollapsed = arrow.classList.toggle('collapsed');
localStorage.setItem(`haven_cat_collapsed_${cat}`, nowCollapsed);
list.querySelectorAll(`[data-cat-group="${CSS.escape(cat)}"]`).forEach(el => {
el.style.display = nowCollapsed ? 'none' : '';
});
// Toggle sub-channel items within this category too
list.querySelectorAll(`[data-cat-sub-group="${CSS.escape(cat)}"]`).forEach(el => {
el.style.display = nowCollapsed ? 'none' : '';
});
// Update unread badge on category label
const badge = catLabel.querySelector('.cat-unread-badge');
if (nowCollapsed) {
const allChans = categories.get(cat) || [];
let total = 0;
allChans.forEach(c => {
total += this.unreadCounts[c.code] || 0;
(subChannelMap[c.id] || []).forEach(s => { total += this.unreadCounts[s.code] || 0; });
});
if (total > 0) {
if (badge) { badge.textContent = total > 99 ? '99+' : total; badge.style.display = ''; }
else {
const b = document.createElement('span');
b.className = 'channel-badge channel-badge-bubble cat-unread-badge';
b.style.marginLeft = 'auto';
b.textContent = total > 99 ? '99+' : total;
catLabel.appendChild(b);
}
} else if (badge) badge.style.display = 'none';
} else {
if (badge) badge.style.display = 'none';
}
});
}
categories.get(cat).forEach(ch => {
const chEl = renderChannelItem(ch, false);
if (cat) {
chEl.dataset.catGroup = cat;
if (catCollapsed) chEl.style.display = 'none';
}
list.appendChild(chEl);
const subs = subChannelMap[ch.id] || [];
const isSubCollapsed = localStorage.getItem(`haven_subs_collapsed_${ch.code}`) === 'true';
const subHasTags = subs.some(s => s.category);
let lastSubTag = undefined;
subs.forEach(sub => {
if (subHasTags && (lastSubTag === undefined || (sub.category || '').toLowerCase() !== (lastSubTag || '').toLowerCase())) {
const tagName = sub.category || 'Untagged';
const tagKey = `haven_subtag_collapsed_${ch.code}_${tagName}`;
const isTagCollapsed = localStorage.getItem(tagKey) === 'true';
const tagLabel = document.createElement('div');
tagLabel.className = 'sub-channel-item sub-tag-label';
tagLabel.dataset.parentId = ch.id;
tagLabel.dataset.parentCode = ch.code;
tagLabel.dataset.tagName = tagName;
if (cat) tagLabel.dataset.catSubGroup = cat;
tagLabel.style.cssText = 'padding:4px 12px 2px 28px;font-size:0.65rem;text-transform:uppercase;letter-spacing:0.05em;opacity:0.35;user-select:none;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:4px';
const tagArrow = document.createElement('span');
tagArrow.className = 'cat-collapse-arrow' + (isTagCollapsed ? ' collapsed' : '');
tagArrow.textContent = '▾';
tagLabel.appendChild(tagArrow);
const tagText = document.createElement('span');
tagText.textContent = tagName;
tagLabel.appendChild(tagText);
tagLabel.addEventListener('click', (e) => {
e.stopPropagation();
const nowCollapsed = tagArrow.classList.toggle('collapsed');
localStorage.setItem(tagKey, nowCollapsed);
list.querySelectorAll(`.sub-channel-item[data-parent-code="${ch.code}"][data-sub-tag="${CSS.escape(tagName)}"]`).forEach(el => {
el.style.display = nowCollapsed ? 'none' : '';
});
});
if (isSubCollapsed || catCollapsed) tagLabel.style.display = 'none';
list.appendChild(tagLabel);
lastSubTag = sub.category;
}
const subEl = renderChannelItem(sub, true);
if (cat) subEl.dataset.catSubGroup = cat;
if (subHasTags) {
subEl.dataset.parentCode = ch.code;
subEl.dataset.subTag = sub.category || 'Untagged';
const subTagKey = `haven_subtag_collapsed_${ch.code}_${subEl.dataset.subTag}`;
if (localStorage.getItem(subTagKey) === 'true') subEl.style.display = 'none';
}
if (isSubCollapsed || catCollapsed) subEl.style.display = 'none';
list.appendChild(subEl);
});
// If collapsed and sub-channels have unreads, bubble a badge onto the parent
if (isSubCollapsed && subs.length) {
const subTotal = subs.reduce((sum, s) => {
const cnt = (s.code in this.unreadCounts) ? this.unreadCounts[s.code] : (s.unreadCount || 0);
return sum + cnt;
}, 0);
if (subTotal > 0) {
const parentEl = list.querySelector(`.channel-item[data-code="${ch.code}"]`);
if (parentEl) {
const bubble = document.createElement('span');
bubble.className = 'channel-badge channel-badge-bubble';
bubble.textContent = subTotal > 99 ? '99+' : subTotal;
parentEl.appendChild(bubble);
}
}
}
});
// Show unread badge on collapsed category at render time
if (cat && catCollapsed) {
const allChans = categories.get(cat) || [];
let total = 0;
allChans.forEach(c => {
total += this.unreadCounts[c.code] || 0;
(subChannelMap[c.id] || []).forEach(s => { total += this.unreadCounts[s.code] || 0; });
});
if (total > 0) {
const catEl = list.querySelector(`[data-category="${CSS.escape(cat)}"]`);
if (catEl) {
const b = document.createElement('span');
b.className = 'channel-badge channel-badge-bubble cat-unread-badge';
b.style.marginLeft = 'auto';
b.textContent = total > 99 ? '99+' : total;
catEl.appendChild(b);
}
}
}
}
// ── "Create Temp Channel" button (visible if user has create_temp_channel perm) ──
if (this.user?.isAdmin || this._hasPerm('create_temp_channel')) {
const tempBtn = document.createElement('div');
tempBtn.className = 'channel-item temp-channel-create-btn';
tempBtn.style.cssText = 'opacity:0.5;cursor:pointer;padding:4px 12px;font-size:0.8rem;display:flex;align-items:center;gap:6px';
tempBtn.innerHTML = `<span style="font-size:0.9rem"></span><span>${t('channels.create_temp_channel')}</span>`;
tempBtn.title = t('channels.create_temp_channel_title');
tempBtn.addEventListener('click', async () => {
const name = await this._showPromptModal(
t('channels.create_temp_channel_title'),
t('channels.create_temp_channel_hint')
);
if (name && name.trim()) {
this.socket.emit('create-temp-channel', { name: name.trim() });
}
});
list.appendChild(tempBtn);
}
// ── DM section (separate pane) ──
const dmList = document.getElementById('dm-list');
if (dmList) {
dmList.innerHTML = '';
const dmCollapsed = localStorage.getItem('haven_dm_collapsed') === 'true';
const dmArrow = document.getElementById('dm-toggle-arrow');
// Set up DM toggle click (only once)
if (!this._dmToggleBound) {
this._dmToggleBound = true;
document.getElementById('dm-toggle-header')?.addEventListener('click', (e) => {
if (e.target.closest('#organize-dms-btn')) return;
const nowCollapsed = dmList.style.display !== 'none';
dmList.style.display = nowCollapsed ? 'none' : '';
const arrow = document.getElementById('dm-toggle-arrow');
if (arrow) arrow.classList.toggle('collapsed', nowCollapsed);
localStorage.setItem('haven_dm_collapsed', nowCollapsed);
// Shrink/restore the DM pane so channels get the freed space
const dp = document.getElementById('dm-pane');
const cp = document.getElementById('channels-pane');
if (nowCollapsed) {
if (dp) dp.style.flex = '0 0 auto';
if (cp) cp.style.flex = '1 1 0';
} else {
const r = parseFloat(localStorage.getItem('haven_sidebar_split_ratio')) || 0.6;
if (dp) dp.style.flex = `${1 - r} 1 0`;
if (cp) cp.style.flex = `${r} 1 0`;
}
});
}
if (dmArrow) dmArrow.classList.toggle('collapsed', dmCollapsed);
if (dmCollapsed) {
dmList.style.display = 'none';
const dp = document.getElementById('dm-pane');
const cp = document.getElementById('channels-pane');
if (dp) dp.style.flex = '0 0 auto';
if (cp) cp.style.flex = '1 1 0';
}
// Update unread badge
const totalUnread = dmChannels.reduce((sum, ch) => sum + ((ch.code in this.unreadCounts) ? this.unreadCounts[ch.code] : (ch.unreadCount || 0)), 0);
const badge = document.getElementById('dm-unread-badge');
if (badge) {
if (totalUnread > 0) {
badge.textContent = totalUnread > 99 ? '99+' : totalUnread;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
// Show/hide DM pane
const dmPane = document.getElementById('dm-pane');
if (dmPane) dmPane.style.display = dmChannels.length ? '' : 'none';
// ── DM categorization (client-side localStorage) ──
const dmAssignments = JSON.parse(localStorage.getItem('haven_dm_assignments') || '{}');
const dmCategories = JSON.parse(localStorage.getItem('haven_dm_categories') || '{}');
const dmSortMode = localStorage.getItem('haven_dm_sort_mode') || 'manual';
const dmOrder = JSON.parse(localStorage.getItem('haven_dm_order') || '[]');
const getDmName = (ch) => ch.dm_target ? this._getNickname(ch.dm_target.id, ch.dm_target.username) : t('channels.unknown_user');
// Sort DMs by saved order first, then append any new ones
let sortedDms = [];
if (dmSortMode === 'manual' && dmOrder.length) {
for (const code of dmOrder) {
const ch = dmChannels.find(c => c.code === code);
if (ch) sortedDms.push(ch);
}
for (const ch of dmChannels) {
if (!sortedDms.includes(ch)) sortedDms.push(ch);
}
} else if (dmSortMode === 'alpha') {
sortedDms = [...dmChannels].sort((a, b) => getDmName(a).localeCompare(getDmName(b)));
} else if (dmSortMode === 'recent') {
sortedDms = [...dmChannels].sort((a, b) => (b.last_activity || 0) - (a.last_activity || 0));
} else {
sortedDms = [...dmChannels];
}
// Collect active tag names from assigned DMs
const activeTags = [...new Set(sortedDms.map(c => dmAssignments[c.code]).filter(Boolean))].sort();
const hasDmTags = activeTags.length > 0;
const renderDmItem = (ch) => {
const el = document.createElement('div');
el.className = 'channel-item dm-item' + (ch.code === this.currentChannel ? ' active' : '');
el.dataset.code = ch.code;
const dmName = getDmName(ch);
el.innerHTML = `
<span class="channel-hash">@</span>
<span class="channel-name">${this._escapeHtml(dmName)}</span>
`;
const count = (ch.code in this.unreadCounts) ? this.unreadCounts[ch.code] : (ch.unreadCount || 0);
if (count > 0) {
const bdg = document.createElement('span');
bdg.className = 'channel-badge';
bdg.textContent = count > 99 ? '99+' : count;
el.appendChild(bdg);
}
// "..." more button for DM context menu
const moreBtn = document.createElement('button');
moreBtn.className = 'channel-more-btn dm-more-btn';
moreBtn.textContent = '⋯';
moreBtn.title = t('channels.more_options');
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._openDmCtxMenu(ch.code, moreBtn);
});
el.appendChild(moreBtn);
// Right-click context menu
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this._openDmCtxMenu(ch.code, el, e);
});
el.addEventListener('click', () => this.switchChannel(ch.code));
return el;
};
if (hasDmTags) {
// Render by category groups
for (const tag of activeTags) {
const tagDms = sortedDms.filter(c => dmAssignments[c.code] === tag);
if (!tagDms.length) continue;
const catState = dmCategories[tag] || {};
const isCollapsed = catState.collapsed || false;
// Category header
const header = document.createElement('div');
header.className = 'dm-category-header';
header.innerHTML = `<span class="dm-category-arrow${isCollapsed ? ' collapsed' : ''}">▾</span> <span class="dm-category-name">${this._escapeHtml(tag)}</span>`;
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const cats = JSON.parse(localStorage.getItem('haven_dm_categories') || '{}');
if (!cats[tag]) cats[tag] = {};
cats[tag].collapsed = !cats[tag].collapsed;
localStorage.setItem('haven_dm_categories', JSON.stringify(cats));
this._renderChannels();
});
dmList.appendChild(header);
for (const ch of tagDms) {
const el = renderDmItem(ch);
if (isCollapsed) el.style.display = 'none';
el.dataset.dmTag = tag;
dmList.appendChild(el);
}
}
// Untagged DMs
const untagged = sortedDms.filter(c => !dmAssignments[c.code]);
if (untagged.length) {
const uncatCats = JSON.parse(localStorage.getItem('haven_dm_categories') || '{}');
const uncatCollapsed = uncatCats['__uncategorized__']?.collapsed || false;
const header = document.createElement('div');
header.className = 'dm-category-header';
header.style.opacity = '0.5';
header.style.cursor = 'pointer';
header.innerHTML = `<span class="dm-category-arrow${uncatCollapsed ? ' collapsed' : ''}">▾</span> <span class="dm-category-name">${t('channels.uncategorized')}</span>`;
header.addEventListener('click', () => {
const cats = JSON.parse(localStorage.getItem('haven_dm_categories') || '{}');
if (!cats['__uncategorized__']) cats['__uncategorized__'] = {};
cats['__uncategorized__'].collapsed = !cats['__uncategorized__'].collapsed;
localStorage.setItem('haven_dm_categories', JSON.stringify(cats));
this._renderChannels();
});
dmList.appendChild(header);
for (const ch of untagged) {
const el = renderDmItem(ch);
if (uncatCollapsed) el.style.display = 'none';
dmList.appendChild(el);
}
}
} else {
// No tags — flat list (original behavior)
sortedDms.forEach(ch => dmList.appendChild(renderDmItem(ch)));
}
}
// Render voice indicators for channels with active voice users
this._updateChannelVoiceIndicators();
// Debounced refresh of voice counts to catch any missed updates during re-render
clearTimeout(this._voiceCountRefreshTimer);
this._voiceCountRefreshTimer = setTimeout(() => {
if (this.socket?.connected) this.socket.emit('get-voice-counts');
}, 600);
},
_updateBadge(code) {
const el = document.querySelector(`.channel-item[data-code="${code}"]`);
if (el) {
let badge = el.querySelector('.channel-badge:not(.channel-badge-bubble)');
const count = this.unreadCounts[code] || 0;
if (count > 0) {
const ch = this.channels.find(c => c.code === code);
const isAnn = ch && ch.notification_type === 'announcement';
if (!badge) { badge = document.createElement('span'); badge.className = 'channel-badge' + (isAnn ? ' announcement-badge' : ''); el.appendChild(badge); }
badge.textContent = count > 99 ? '99+' : count;
} else if (badge) {
badge.remove();
}
// If this is a sub-channel whose parent is currently collapsed, bubble an unread
// indicator up to the parent so the user knows to expand it.
if (el.dataset.parentId) {
const parentChannel = this.channels.find(c => c.id === parseInt(el.dataset.parentId));
if (parentChannel) {
const parentEl = document.querySelector(`.channel-item[data-code="${parentChannel.code}"]`);
if (parentEl) {
// Check if sub-channels are collapsed (arrow has 'collapsed' class)
const arrow = parentEl.querySelector('.channel-collapse-arrow');
if (arrow && arrow.classList.contains('collapsed')) {
// Count total unreads across all sub-channels of this parent
const siblingCodes = this.channels
.filter(c => c.parent_channel_id === parentChannel.id)
.map(c => c.code);
const siblingTotal = siblingCodes.reduce((sum, sc) => sum + (this.unreadCounts[sc] || 0), 0);
let parentBubble = parentEl.querySelector('.channel-badge-bubble');
if (siblingTotal > 0) {
if (!parentBubble) {
parentBubble = document.createElement('span');
parentBubble.className = 'channel-badge channel-badge-bubble';
parentEl.appendChild(parentBubble);
}
parentBubble.textContent = siblingTotal > 99 ? '99+' : siblingTotal;
} else if (parentBubble) {
parentBubble.remove();
}
} else {
// Sub-channels are expanded — remove any bubble from parent
const parentBubble = parentEl.querySelector('.channel-badge-bubble');
if (parentBubble) parentBubble.remove();
}
}
}
}
}
// Always update DM section badge, tab title, and desktop badge
// even if the individual channel item isn't in the DOM
this._updateDmSectionBadge();
this._updateTabTitle();
this._updateDesktopBadge();
},
_updateTabTitle() {
const validCodes = new Set((this.channels || []).map(c => c.code));
const total = Object.entries(this.unreadCounts).reduce((s, [k, v]) => validCodes.has(k) ? s + v : s, 0);
document.title = total > 0 ? `(${total}) Haven` : 'Haven';
},
_updateDesktopBadge() {
const validCodes = new Set((this.channels || []).map(c => c.code));
const total = Object.entries(this.unreadCounts).reduce((s, [k, v]) => validCodes.has(k) ? s + v : s, 0);
window.havenDesktop?.setUnreadBadge?.(total > 0);
},
/**
* Fire a native OS notification (toast) for an incoming message.
* Desktop app: always uses havenDesktop.notify() (Electron native).
* Browser: uses Notification API only when push subscription is NOT active
* to avoid duplicate notifications (server-side push handles the rest).
*/
_fireNativeNotification(message, channelCode, opts) {
// Check per-type notification toggles
const n = this.notifications;
if (opts && opts.isMention && n.mentionsEnabled) { /* allowed */ }
else if (opts && opts.isReply && n.repliesEnabled) { /* allowed */ }
else if (opts && opts.isDm && n.dmEnabled) { /* allowed */ }
else if (!n.enabled) return;
// Don't notify for own messages
if (message.user_id === this.user?.id) return;
const sender = this._getNickname(message.user_id, message.username);
const channel = this.channels?.find(c => c.code === channelCode);
const channelLabel = channel?.is_dm ? 'DM' : `#${channel?.name || channelCode}`;
const title = `${sender} in ${channelLabel}`;
let rawContent = message.content || '';
// Detect E2E encrypted envelope — show generic text instead of ciphertext
try { const p = JSON.parse(rawContent); if (p && p.v && p.ct) rawContent = ''; } catch { /* not JSON */ }
const body = rawContent.length > 120
? rawContent.slice(0, 117) + '...'
: (rawContent || 'Sent a message');
// Desktop app: always use native Electron notifications
if (window.havenDesktop?.notify) {
window.havenDesktop.notify(title, body, { silent: true, channelCode });
return;
}
// Browser: skip if push subscription is active (server sends push instead)
if (this._pushSubscription) return;
// Browser Notification API fallback
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
try {
const n = new Notification(title, {
body,
tag: `haven-${channelCode}`,
renotify: true,
silent: true,
icon: '/uploads/server-icon.png',
});
n.onclick = () => {
window.focus();
this.switchChannel(channelCode);
n.close();
};
// Auto-close after 5 seconds
setTimeout(() => n.close(), 5000);
} catch { /* Notification constructor can throw in some contexts */ }
}
},
_updateDmSectionBadge() {
const badge = document.getElementById('dm-unread-badge');
if (!badge) return;
const dmChannels = (this.channels || []).filter(c => c.is_dm);
const total = dmChannels.reduce((sum, ch) => sum + (this.unreadCounts[ch.code] || 0), 0);
if (total > 0) {
badge.textContent = total > 99 ? '99+' : total;
badge.style.display = '';
} else {
badge.textContent = '';
badge.style.display = 'none';
}
},
_updateChannelVoiceIndicators() {
document.querySelectorAll('.channel-item').forEach(el => {
const code = el.dataset.code;
let indicator = el.querySelector('.channel-voice-indicator');
const count = this.voiceCounts[code] || 0;
const users = this.voiceChannelUsers[code] || [];
if (count > 0) {
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'channel-voice-indicator';
// Insert before the ⋯ button so they don't overlap
const moreBtn = el.querySelector('.channel-more-btn');
if (moreBtn) el.insertBefore(indicator, moreBtn);
else el.appendChild(indicator);
}
indicator.innerHTML = `<span class="voice-icon">🔊</span>${count}`;
// Render voice user list below the channel item
let userList = el.nextElementSibling;
if (!userList || !userList.classList.contains('channel-voice-users')) {
userList = document.createElement('div');
userList.className = 'channel-voice-users';
el.after(userList);
}
userList.innerHTML = users.map(u =>
`<div class="channel-voice-user" data-user-id="${u.id}" data-username="${this._escapeHtml(u.username)}"><span class="cvu-mic${u.isMuted ? ' is-muted' : ''}" title="${u.isMuted ? 'Muted' : ''}">🎙️</span><span class="cvu-deafen${u.isDeafened ? ' is-deafened' : ''}" title="${u.isDeafened ? 'Deafened' : ''}">🔊</span>${this._escapeHtml(u.username)}</div>`
).join('');
// Right-click on a left-sidebar voice user → same voice options menu
userList.querySelectorAll('.channel-voice-user').forEach(item => {
item.addEventListener('contextmenu', (e) => {
const userId = parseInt(item.dataset.userId);
if (isNaN(userId) || userId === this.user.id) return;
e.preventDefault();
e.stopPropagation();
this._showVoiceUserMenu(item, userId, item.dataset.username || '');
});
});
} else {
if (indicator) indicator.remove();
// Remove voice user list
const userList = el.nextElementSibling;
if (userList && userList.classList.contains('channel-voice-users')) {
userList.remove();
}
}
});
},
// ── Keyboard Navigation ──────────────────────────────────
/**
* Get all visible channels in visual (DOM) order.
* Returns array of channel codes matching the sidebar ordering.
*/
_getVisualChannelOrder() {
const codes = [];
// Channels section
document.querySelectorAll('#channel-list .channel-item:not([style*="display: none"])').forEach(el => {
if (el.dataset.code) codes.push(el.dataset.code);
});
// DM section
document.querySelectorAll('#dm-list .channel-item:not([style*="display: none"])').forEach(el => {
if (el.dataset.code) codes.push(el.dataset.code);
});
return codes;
},
/**
* Navigate to the next or previous channel in visual order.
* @param {number} direction - 1 for next, -1 for previous
*/
_navigateChannel(direction) {
const order = this._getVisualChannelOrder();
if (!order.length) return;
const idx = order.indexOf(this.currentChannel);
const next = idx === -1 ? 0 : (idx + direction + order.length) % order.length;
this.switchChannel(order[next]);
},
/**
* Navigate to the next or previous unread channel in visual order.
* @param {number} direction - 1 for next, -1 for previous
*/
_navigateUnreadChannel(direction) {
const order = this._getVisualChannelOrder();
if (!order.length) return;
const idx = order.indexOf(this.currentChannel);
const start = idx === -1 ? 0 : idx;
for (let i = 1; i <= order.length; i++) {
const check = (start + i * direction + order.length) % order.length;
if ((this.unreadCounts[order[check]] || 0) > 0) {
this.switchChannel(order[check]);
return;
}
}
},
/**
* Open a Ctrl+K style quick channel/DM switcher overlay.
*/
_openQuickSwitcher() {
// Remove any existing overlay
document.getElementById('quick-switcher-overlay')?.remove();
const overlay = document.createElement('div');
overlay.id = 'quick-switcher-overlay';
overlay.innerHTML = `
<div class="quick-switcher-box">
<input type="text" id="quick-switcher-input" placeholder="Jump to channel or DM..." autocomplete="off" spellcheck="false">
<div id="quick-switcher-results"></div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#quick-switcher-input');
const results = overlay.querySelector('#quick-switcher-results');
let selectedIdx = 0;
const allChannels = (this.channels || []).map(ch => ({
code: ch.code,
name: ch.is_dm && ch.dm_target
? `@ ${this._getNickname(ch.dm_target.id, ch.dm_target.username)}`
: `# ${ch.name}`,
isDm: ch.is_dm,
unread: this.unreadCounts[ch.code] || 0,
}));
const render = (query) => {
const q = query.toLowerCase();
const filtered = q
? allChannels.filter(c => c.name.toLowerCase().includes(q))
: allChannels.filter(c => c.unread > 0).concat(
allChannels.filter(c => c.unread === 0)
);
const shown = filtered.slice(0, 12);
selectedIdx = Math.min(selectedIdx, Math.max(0, shown.length - 1));
results.innerHTML = shown.map((c, i) => `
<div class="quick-switcher-item${i === selectedIdx ? ' selected' : ''}" data-code="${this._escapeHtml(c.code)}">
<span class="qs-name">${this._escapeHtml(c.name)}</span>
${c.unread > 0 ? `<span class="qs-badge">${c.unread > 99 ? '99+' : c.unread}</span>` : ''}
</div>
`).join('');
results.querySelectorAll('.quick-switcher-item').forEach(el => {
el.addEventListener('click', () => { this.switchChannel(el.dataset.code); overlay.remove(); });
});
};
input.addEventListener('input', () => { selectedIdx = 0; render(input.value); });
input.addEventListener('keydown', (e) => {
const items = results.querySelectorAll('.quick-switcher-item');
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); render(input.value); }
else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); render(input.value); }
else if (e.key === 'Enter') {
e.preventDefault();
const sel = items[selectedIdx];
if (sel) { this.switchChannel(sel.dataset.code); overlay.remove(); }
}
});
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
render('');
setTimeout(() => input.focus(), 10);
},
};