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

1973 lines
84 KiB
JavaScript

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 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);
} else {
// Show just the join button (not the indicator), but hide it for text-only channels
const _scJoinBtn = document.getElementById('voice-join-btn');
if (_scJoinBtn) _scJoinBtn.style.display = channel && channel.voice_enabled === 0 ? '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 ? 'none' : '';
}
document.getElementById('search-toggle-btn').style.display = '';
document.getElementById('pinned-toggle-btn').style.display = '';
// 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;
if (msgInputArea) msgInputArea.style.display = (_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
? `DM: ${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();
// 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 ? 'Click to edit topic' : topic;
bar.onclick = canEdit ? () => this._editTopic() : null;
bar.style.cursor = canEdit ? 'pointer' : 'default';
} else {
if (canEdit) {
bar.textContent = 'Click to set a topic...';
bar.style.display = 'block';
bar.style.opacity = '0.4';
bar.style.cursor = 'pointer';
bar.onclick = () => this._editTopic();
} else {
bar.style.display = 'none';
}
}
if (topic) bar.style.opacity = '1';
},
async _editTopic() {
const channel = this.channels.find(c => c.code === this.currentChannel);
const current = channel?.topic || '';
const newTopic = await this._showPromptModal('Channel Topic', 'Set channel topic (max 256 chars):', 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 = 'Select a 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 = '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';
});
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 "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) ? '🔕 Unmute Channel' : '🔔 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;
if (joinVoiceBtn) joinVoiceBtn.style.display = (inThisChannel || isVoiceOff) ? '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');
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);
// 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');
}
},
_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;
// 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('DM unmuted', 'success'); }
else { muted.push(code); this._showToast('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('⚠️ Delete this DM?\nAll messages will be permanently deleted for both users.')) 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) ? '🔕 Unmute DM' : '🔔 Mute DM';
// 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">No sub-channels on this server.</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 = '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 = '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 ? 'Unsubscribe (mute notifications)' : 'Subscribe (enable notifications)'}">
<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(`Subscribed to ${ch.name}`, 'success');
} else {
// Unsubscribe: add to muted
if (idx < 0) muted.push(ch.code);
this._showToast(`Unsubscribed from ${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 = '📦 Move Channel';
descEl.textContent = `Select a new parent for "${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>Promote to top-level channel</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">(${subCount} sub-ch)</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">No valid parent channels available</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
? `Promote "${ch.name}" to a top-level channel?`
: `Move "${ch.name}" under "${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._organizeCatOrder = JSON.parse(localStorage.getItem('haven_cat_order___server__') || '[]');
this._organizeCatSort = localStorage.getItem('haven_cat_sort___server__') || 'az';
document.getElementById('organize-modal-title').textContent = '📋 Organize Channels';
document.getElementById('organize-modal-parent-name').textContent = 'Reorder channels and assign category tags';
// Server-level sort is stored in localStorage (no single parent channel to hold it)
const sortSel = document.getElementById('organize-global-sort');
const savedSort = localStorage.getItem('haven_server_sort_mode') || 'manual';
sortSel.value = savedSort;
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';
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 = '📋 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));
}
this._renderOrganizeList();
document.getElementById('organize-modal').style.display = 'flex';
},
_renderOrganizeList() {
const listEl = document.getElementById('organize-channel-list');
const globalSort = document.getElementById('organize-global-sort').value;
let displayList = [...(this._organizeList || [])];
// Collect unique tags (including __untagged__ as a sortable entry)
const realTags = [...new Set(displayList.filter(c => c.category).map(c => c.category))];
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 tagItems = sortGroup(displayList.filter(c => c.category === key), 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) : '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' : ''}>Manual</option>
<option value="alpha"${group.sort === 'alpha' ? ' selected' : ''}>A→Z</option>
<option value="created"${group.sort === 'created' ? ' selected' : ''}>Newest</option>
<option value="oldest"${group.sort === 'oldest' ? ' selected' : ''}>Oldest</option>
<option value="dynamic"${group.sort === 'dynamic' ? ' selected' : ''}>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="Double-click to organize sub-channels">▶</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 ? 'No channels yet' : '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));
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) {
const globalSort = document.getElementById('organize-global-sort').value;
const tagKey = ch.category || '__untagged__';
const effectiveSort = this._organizeTagSorts[tagKey] || globalSort;
// Collect channels in the same tag group
const group = ch.category
? this._organizeList.filter(c => c.category === ch.category)
: 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 Set(displayList.filter(c => c.category).map(c => c.category))];
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');
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) : 'Unknown';
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">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">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">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">No webhooks yet. Create one above.</p>';
return;
}
container.innerHTML = webhooks.map(wh => {
const maskedToken = wh.token.slice(0, 8) + '••••••••';
const statusLabel = wh.is_active ? '🟢 Active' : '🔴 Disabled';
const toggleLabel = wh.is_active ? 'Disable' : '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('Delete this webhook? This cannot be undone.')) {
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 !== tagB) {
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 serverSortMode = localStorage.getItem('haven_server_sort_mode') || 'manual';
const serverTagOverrides = JSON.parse(localStorage.getItem('haven_tag_sorts___server__') || '{}');
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
const serverCatOrder = JSON.parse(localStorage.getItem('haven_cat_order___server__') || '[]');
const serverCatSort = localStorage.getItem('haven_cat_sort___server__') || 'az';
if (parentHasTags) {
const tagGroup = (a, b) => {
const tagA = a.category || '';
const tagB = b.category || '';
if (tagA !== tagB) {
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 hashIcon = isSub ? (ch.is_private ? '🔒' : '↳') : (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="Screen sharing not allowed">🖥️</span>');
if (ch.music_enabled === 0) _badges.push('<span class="ch-disabled-badge" title="Music not allowed">🎵</span>');
if (ch.slow_mode_interval > 0) _badges.push('<span title="Slow mode: ' + ch.slow_mode_interval + 's" style="opacity:0.5;font-size:0.65rem">🐢</span>');
if (ch.cleanup_exempt === 1) _badges.push('<span title="Exempt from auto-cleanup" 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="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="Temporary — expires ${new Date(ch.expires_at).toLocaleString()}"` : '';
el.innerHTML = `
${hasSubs ? `<span class="channel-collapse-arrow${isCollapsed ? ' collapsed' : ''}" title="Expand/collapse sub-channels">▾</span>` : ''}
<span class="channel-hash"${expiryTitle}>${hashIcon}</span>
<span class="channel-name">${this._escapeHtml(ch.name)}</span>
${indicators}
<button class="channel-more-btn" title="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;
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 ──
const categories = new Map();
parentChannels.forEach(ch => {
const cat = ch.category || '';
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) {
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';
catLabel.textContent = cat;
list.appendChild(catLabel);
}
categories.get(cat).forEach(ch => {
list.appendChild(renderChannelItem(ch, false));
const subs = subChannelMap[ch.id] || [];
const isCollapsed = localStorage.getItem(`haven_subs_collapsed_${ch.code}`) === 'true';
const subHasTags = subs.some(s => s.category);
let lastSubTag = undefined;
subs.forEach(sub => {
if (subHasTags && sub.category !== lastSubTag) {
const tagLabel = document.createElement('div');
tagLabel.className = 'sub-channel-item sub-tag-label';
tagLabel.dataset.parentId = ch.id;
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';
tagLabel.textContent = sub.category || 'Untagged';
if (isCollapsed) tagLabel.style.display = 'none';
list.appendChild(tagLabel);
lastSubTag = sub.category;
}
const subEl = renderChannelItem(sub, true);
if (isCollapsed) subEl.style.display = 'none';
list.appendChild(subEl);
});
// If collapsed and sub-channels have unreads, bubble a badge onto the parent
if (isCollapsed && 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);
}
}
}
});
}
// ── 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) : 'Unknown';
// 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 = '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">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) return;
let badge = el.querySelector('.channel-badge');
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();
}
}
}
}
// Update the DM section header total badge
this._updateDmSectionBadge();
// Update browser tab title with total unread count
this._updateTabTitle();
// Notify desktop shell to set/clear taskbar badge
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) {
if (!this.notifications.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}`;
const body = (message.content || '').length > 120
? message.content.slice(0, 117) + '...'
: (message.content || 'Sent an attachment');
// 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-icon">🎤</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);
},
};