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

1331 lines
63 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.
// Channels: switching to one, the topic bar and welcome screen, drawing the
// channel and DM sidebar, and moving between channels from the keyboard.
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._typingRender?.();
// Search panel persists per-context: hide/show it for the channel we just
// entered (public channels share one, each DM keeps its own). (search-overhaul)
this._searchOnChannelSwitch?.();
// Reset pin indicator until message-history reports the count for this channel
this._updatePinIndicator?.(this._pinnedCountByChannel?.[code] || 0);
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.is_group ? `👥 ${this._groupName(channel)}` : 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; }
// (#5280) Burn-after-read 🔥 button is DM-only, so toggle visibility on
// every channel switch and reset the per-message arming so a stale
// toggle from another DM doesn't accidentally arm the next message
// here in a non-DM channel.
const _burnBtn = document.getElementById('burn-btn');
const _burnDiv = document.getElementById('burn-divider');
if (_burnBtn) {
_burnBtn.style.display = isDm ? 'inline-flex' : 'none';
_burnBtn.classList.remove('active');
}
if (_burnDiv) _burnDiv.style.display = isDm ? 'inline-block' : 'none';
this._burnArmed = false;
// Self-destructing messages are for channels; DMs have burn-after-read.
// Only for people whose role lets them send one (send_self_destruct).
const _sdBtn = document.getElementById('self-destruct-btn');
const _sdDiv = document.getElementById('self-destruct-divider');
const _sdHidden = isDm || !this._hasPerm('send_self_destruct');
if (_sdBtn) _sdBtn.style.display = _sdHidden ? 'none' : '';
if (_sdDiv) _sdDiv.style.display = _sdHidden ? 'none' : '';
this._setSelfDestructArmed(false);
this._updateHighlightButton?.(isDm);
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 who can manage this
// channel's settings, on non-DM channels (#5467)
const codeSettingsBtn = document.getElementById('channel-code-settings-btn');
if (codeSettingsBtn) {
const canManageThis = this.user.isAdmin || !!(channel && channel.canManageSettings);
codeSettingsBtn.style.display = (!isDm && canManageThis) ? 'inline-flex' : 'none';
}
// Show the header actions box
const actionsBox = document.getElementById('header-actions-box');
if (actionsBox) actionsBox.style.display = 'flex';
this._labelCallButton?.();
// 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 indic = document.getElementById('voice-active-indicator');
if (indic) indic.style.display = 'none';
const _showJoin = this._voiceJoinAvailable();
const _scJoinBtn = document.getElementById('voice-join-btn');
if (_scJoinBtn) _scJoinBtn.style.display = _showJoin ? 'inline-flex' : 'none';
const mobileJoin = document.getElementById('voice-join-mobile');
if (mobileJoin) {
if (_showJoin) mobileJoin.style.removeProperty('display');
else mobileJoin.style.setProperty('display', 'none', 'important');
}
}
} else {
// Show just the join button (not the indicator), but hide it for text-only channels or users without voice permission
const _showJoin = this._voiceJoinAvailable();
const _scJoinBtn = document.getElementById('voice-join-btn');
if (_scJoinBtn) _scJoinBtn.style.display = _showJoin ? 'inline-flex' : 'none';
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) {
if (_showJoin) mobileJoin.style.removeProperty('display');
else mobileJoin.style.setProperty('display', 'none', 'important');
}
}
document.getElementById('search-toggle-btn').style.display = '';
document.getElementById('pinned-toggle-btn').style.display = '';
const _galleryBtn = document.getElementById('gallery-toggle-btn');
if (_galleryBtn) _galleryBtn.style.display = isDm ? 'none' : '';
// (#5506) Same reasoning as the gallery: DM content is end-to-end encrypted,
// so a server-built list of it would have nothing readable to show.
const _threadsBtn = document.getElementById('threads-toggle-btn');
if (_threadsBtn) _threadsBtn.style.display = isDm ? 'none' : '';
this._updateThreadsButtonBadge?.();
// Auto-close pinned panel and Pins PiP on channel switch so stale pins don't linger
document.getElementById('pinned-panel').style.display = 'none';
this._closePinsPiP?.();
// 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, unless picking a message
// there to put the selection under (app-move-to-thread.js).
if (this._moveSelectionActive && !this._moveThreadKeepsSelection?.(code)) this._exitMoveSelectionMode();
else if (this._moveSelectionActive) this._syncMoveThreadUi?.();
// Show/hide topic bar. DMs don't have topics; showing the placeholder
// overlaps the E2E encryption dropdown that lives in the same header.
if (isDm) {
const bar = document.getElementById('channel-topic-bar');
if (bar) bar.style.display = 'none';
} else {
this._updateTopicBar(channel?.topic || '');
}
// DM auto-cleanup notice (#5340): only visible in DMs when admin has enabled
// age-based cleanup. Lets users know old messages are pruned, instead of being
// surprised when history disappears.
this._updateDmCleanupNotice(channel);
// 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 the composer unless this viewer may actually post here.
// canOverrideReadOnly is decided per channel by the server. _hasPerm reads a
// flat list that merges every channel-scoped grant together, so holding the
// override in one channel used to reveal the composer in all of them, and the
// send was then refused. (#5468)
const _isReadOnly = channel && channel.read_only === 1 && !this.user?.isAdmin && !channel.canOverrideReadOnly;
if (msgInputArea) msgInputArea.style.display = (_isReadOnly || (_textOff && _mediaOff)) ? 'none' : '';
this._updateReadOnlyNote(channel);
// 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' : '';
// In a forum the composer starts topics, and says so. (#144)
if (_msgInput) {
const _forumCh = this.channels && this.channels.find(c => c.code === code);
_msgInput.placeholder = (_forumCh && _forumCh.is_forum)
? t('app.messages.placeholder_forum')
: t(window.innerWidth <= 480 ? 'app.messages.placeholder_short' : 'header.message_placeholder_commands');
}
const _timeBtn = document.getElementById('time-btn');
const _timeDivider = document.getElementById('time-divider');
if (_timeBtn) _timeBtn.style.display = _textOff ? 'none' : '';
if (_timeDivider) _timeDivider.style.display = _textOff ? 'none' : '';
// Upload button tied to media toggle
const _uploadBtn = document.getElementById('upload-btn');
if (_uploadBtn) _uploadBtn.style.display = _mediaOff ? 'none' : '';
this._applyReactionLock?.();
// 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);
// Refresh thread-mention pill for the channel we just entered
this._updateThreadMentionsPill?.();
document.getElementById('status-channel').textContent = isDm && channel.is_group ? this._groupName(channel) : 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.
// Must not be allowed to reject: the channel UI is already fully swapped in
// by this point, so a thrown key fetch would abandon every emit below it
// (get-messages, mark-read, get-channel-members), leaving you sitting in a DM
// with the *previous* channel's member list and @mentions quietly dead.
// A missing partner key only costs E2E, which the encrypt path handles.
if (isDm && channel) {
try {
await this._fetchDMPartnerKey(channel);
} catch (err) {
console.warn('[Haven] DM partner key fetch failed, continuing unencrypted:', err);
}
}
this.socket.emit('get-messages', this._getMessagesParams ? this._getMessagesParams(code) : { code });
// Belt-and-braces mark-read: if the server already told us the latest
// message id for this channel (channels-list snapshot), fire a
// mark-read IMMEDIATELY (not via the debounced _markRead path) so that
// a quick re-open of a different channel within the 500 ms debounce
// window can't clear the timer and silently drop the previous channel's
// mark-read. This was the root cause of "I've read this DM 6 times and
// it still shows unread". The user would open the DM, glance at it,
// switch away within 500 ms, the next switch's clearTimeout dropped the
// first emit, and the server never recorded the read. Server uses
// MAX(last_read, incoming) so an older snapshot id can't clobber a
// newer real id from the in-channel scroll handler. Also mirror the
// unread count locally so the badge clears immediately and doesn't
// bounce back to "1" on the next channels-list snapshot.
//
// (#5432) This emit MUST come after the get-messages emit above.
// Socket events are processed in order, so emitting mark-read first
// updated read_positions before the history query ran, so the history
// response then reported the user as fully caught up, and the
// "NEW MESSAGES" divider + auto-scroll from #5259 never appeared.
if (channel && channel.latestMessageId) {
try { this.socket.emit('mark-read', { code, messageId: channel.latestMessageId }); } catch (err) { console.warn('[Channels] could not mark the channel read', err); }
if (this.unreadCounts && this.unreadCounts[code]) {
this.unreadCounts[code] = 0;
try { this._updateBadge?.(code); } catch (err) { console.warn('[Channels] _updateBadge failed', err); }
try { this._updateDmSectionBadge?.(); } catch (err) { console.warn('[Channels] _updateDmSectionBadge failed', err); }
try { this._updateTabTitle?.(); } catch (err) { console.warn('[Channels] _updateTabTitle failed', err); }
try { this._updateDesktopBadge?.(); } catch (err) { console.warn('[Channels] _updateDesktopBadge failed', err); }
}
}
this.socket.emit('get-channel-members', { code });
// VOICE panel shows who's in voice in the channel you just opened.
this.socket.emit('request-voice-users', {
code,
iAmInVoice: !!(this.voice && this.voice.inVoice && this.voice.currentChannel === code)
});
// Safety net (#post-sleep-channel-desync round 2): if message-history
// doesn't arrive within 5 s for the channel we just switched to, the
// socket is likely a zombie (silent disconnect, write buffered but not
// flushed). Force a full resync: the 'connect' handler will re-emit
// enter-channel + get-messages and unstick the empty chat view. Cleared
// by the message-history listener in app-socket.js when a response for
// this code arrives.
if (this._switchChannelSafetyTimer) clearTimeout(this._switchChannelSafetyTimer);
this._pendingChannelHistoryCode = code;
this._switchChannelSafetyTimer = setTimeout(() => {
if (this._pendingChannelHistoryCode === code && this.currentChannel === code) {
console.warn(`[switch-channel] no message-history for ${code} within 5s, forcing resync`);
this._forceFullResync?.('switch-channel-timeout');
}
}, 5000);
this._clearReply();
this._closeThread();
// Auto-focus the message input for quick typing.
// Skip on touch devices: focusing an input opens the on-screen keyboard, which
// shrinks the visual viewport and can leave the layout shifted up after the
// keyboard closes (especially on Android web; see issue #5285).
const msgInput = document.getElementById('message-input');
const isTouchDevice = window.matchMedia('(hover: none) and (pointer: coarse)').matches
|| window.matchMedia('(pointer: coarse)').matches
|| 'ontouchstart' in window
|| navigator.maxTouchPoints > 0;
if (msgInput && !isTouchDevice) 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';
if (isDm) this._updateE2EIndicator();
// Close dropdown when switching channels
const e2eDropdown = document.getElementById('e2e-dropdown');
if (e2eDropdown) e2eDropdown.style.display = 'none';
},
_updateDmCleanupNotice(channel) {
// Build / locate the notice element. Sits just below the topic bar (or the
// header if no topic bar) so the layout is identical for everyone. The
// banner is the only thing that toggles.
let bar = document.getElementById('dm-cleanup-notice');
if (!bar) {
bar = document.createElement('div');
bar.id = 'dm-cleanup-notice';
bar.className = 'dm-cleanup-notice';
const topicBar = document.getElementById('channel-topic-bar');
const header = document.querySelector('.channel-header');
const anchor = topicBar || header;
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(bar, anchor.nextSibling);
}
}
const isDm = !!(channel && channel.is_dm);
const enabled = this.serverSettings && this.serverSettings.cleanup_enabled === 'true';
const days = parseInt(this.serverSettings && this.serverSettings.cleanup_max_age_days) || 0;
if (isDm && enabled && days > 0) {
bar.textContent = t('channels.dm_cleanup_notice', { days });
bar.style.display = 'block';
} else {
bar.style.display = 'none';
bar.textContent = '';
}
},
_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);
}
// The text and the fold arrow are separate targets. The arrow folds the bar
// to a thin strip for this browser only, and the fold survives channel
// switches and reloads (#5625). Clicking the folded strip opens it again.
let text = bar.querySelector('.channel-topic-text');
if (!text) {
bar.textContent = '';
text = document.createElement('span');
text.className = 'channel-topic-text';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'channel-topic-toggle';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
this._setTopicBarFolded(!bar.classList.contains('collapsed'));
});
bar.appendChild(text);
bar.appendChild(toggle);
}
const canEdit = this.user.isAdmin || this._hasPerm('set_channel_topic');
const editable = !!topic ? canEdit : canEdit;
if (topic || canEdit) {
text.textContent = topic || t('channels.topic_placeholder');
bar.style.display = '';
bar.title = topic ? (canEdit ? t('channels.topic_edit_hint') : topic) : '';
bar.style.cursor = editable ? 'pointer' : 'default';
bar.style.color = topic ? '' : 'var(--text-muted)';
bar.style.opacity = topic ? '1' : '';
bar.onclick = () => {
if (bar.classList.contains('collapsed')) { this._setTopicBarFolded(false); return; }
if (editable) this._editTopic();
};
} else {
bar.style.display = 'none';
}
this._setTopicBarFolded(null);
},
// null keeps the saved state and just applies it; true or false saves first.
_setTopicBarFolded(folded) {
const bar = document.getElementById('channel-topic-bar');
if (!bar) return;
if (folded !== null) {
try { localStorage.setItem('haven_topic_bar_folded', folded ? '1' : '0'); } catch { /* private mode */ }
}
let saved = false;
try { saved = localStorage.getItem('haven_topic_bar_folded') === '1'; } catch { /* private mode */ }
bar.classList.toggle('collapsed', saved);
const toggle = bar.querySelector('.channel-topic-toggle');
if (toggle) {
toggle.textContent = saved ? '\u25BE' : '\u25B4';
toggle.title = t(saved ? 'channels.topic_bar_show' : 'channels.topic_bar_hide');
toggle.setAttribute('aria-label', toggle.title);
}
},
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.setProperty('display', 'none', 'important');
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';
const dmNotice = document.getElementById('dm-cleanup-notice');
if (dmNotice) { dmNotice.style.display = 'none'; dmNotice.textContent = ''; }
},
_renderChannels() {
const list = document.getElementById('channel-list');
list.innerHTML = '';
// (#5409) Admin-hidden channels are dropped from the sidebar, except the one
// we're actively viewing (so a freshly-hidden current channel doesn't vanish
// out from under us until we navigate away). Hiding a parent hides its
// sub-channels too, since they're orphaned once the parent isn't rendered.
const _hiddenChannels = this._getHiddenChannels();
const regularChannels = this.channels.filter(c =>
!c.is_dm && (!_hiddenChannels.includes(c.code) || c.code === this.currentChannel));
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));
}
// Your own order, where you have arranged one (app-channel-personal-order.js).
const _personal = this._applyPersonalChannelOrder(parentChannels);
parentChannels.splice(0, parentChannels.length, ..._personal);
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 ? '📢' : (ch.is_forum ? '🗂️' : (ch.is_nsfw ? '🔞' : '#')))));
// NSFW channels stay out of sight when the user asked for that (phone in
// public), except the one they are actually in.
if (ch.is_nsfw && this._hideNsfw && this._hideNsfw() && ch.code !== this.currentChannel) el.style.display = 'none';
// Build small status indicators for channel features
const _badges = [];
if (!isSub) {
// An admin can hide the crossed-out icons for everyone (#5615): on a
// server where most channels have these off they were only clutter.
const showOff = !this.serverSettings || this.serverSettings.hide_disabled_channel_badges !== 'true';
if (showOff && ch.streams_enabled === 0) _badges.push(`<span class="ch-disabled-badge" title="${t('channels.screen_share_not_allowed')}">🖥️</span>`);
if (showOff && 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: this._fmtDateTime(ch.expires_at) })}"` : '';
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();
}
this._updateNestedIndicators();
});
}
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);
}
// Thread @mention indicator (bell, distinct from unread bubble)
const tmCount = ((this._threadMentions || {})[ch.code] || []).length;
if (tmCount > 0) {
const bell = document.createElement('span');
bell.className = 'channel-badge thread-mention-badge';
bell.title = `${tmCount} mention${tmCount === 1 ? '' : 's'} in thread${tmCount === 1 ? '' : 's'}`;
bell.textContent = `🔔${tmCount > 9 ? '9+' : tmCount}`;
// Opens the thread that mentioned you, at the reply (#5766).
bell.style.cursor = 'pointer';
bell.addEventListener('click', (e) => {
e.stopPropagation();
this._openThreadMentionFrom?.(ch.code);
});
el.appendChild(bell);
}
el.addEventListener('click', () => {
// Clicking the forum you are already in, with one of its topics open,
// goes back to the topic list (#5688).
if (ch.code === this.currentChannel && this._activeThreadParent && this._isForumChannel?.(ch.code)) this._closeThread();
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.user?.isGuest && !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('#arrange-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 = this._applyPersonalCategoryOrder([...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.toLowerCase()}`) === '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.className = 'cat-name';
catText.textContent = cat;
catLabel.appendChild(catText);
// Look, short name and tab grid: app-channel-categories.js (#5756).
this._decorateCategoryLabel?.(catLabel, cat, catCollapsed);
(this._categoryHeaderHolder?.(list) || list).appendChild(catLabel);
catLabel.addEventListener('click', () => {
const nowCollapsed = arrow.classList.toggle('collapsed');
localStorage.setItem(`haven_cat_collapsed_${cat.toLowerCase()}`, nowCollapsed);
this._decorateCategoryLabel?.(catLabel, 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';
}
this._updateNestedIndicators();
});
}
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 || t('channels.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 = sub.category || t('channels.untagged');
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' : '';
});
this._updateNestedIndicators();
});
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 || t('channels.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 () => {
// One create form for every kind of channel: open it with Temporary
// ticked instead of a second prompt that only made a temp channel.
const form = document.getElementById('create-section-body');
const nameInput = document.getElementById('new-channel-name');
const tmp = document.getElementById('new-channel-temporary');
if (form && nameInput && tmp) {
form.style.display = '';
const arrow = document.getElementById('create-section-arrow');
if (arrow) arrow.textContent = '▾';
tmp.checked = true;
tmp.dispatchEvent(new Event('change'));
nameInput.focus();
nameInput.scrollIntoView({ block: 'center' });
return;
}
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);
}
// ── Hidden channels restore bar (#5409) ──
// Only counts hidden channels that still exist and aren't the one currently
// being viewed (a hidden current channel is still shown in the list).
const _hiddenExisting = this._getHiddenChannels()
.filter(code => code !== this.currentChannel && this.channels.some(c => c.code === code));
if (_hiddenExisting.length) {
const hiddenBar = document.createElement('div');
hiddenBar.className = 'channel-item hidden-channels-bar';
hiddenBar.style.cssText = 'opacity:0.5;cursor:pointer;padding:4px 12px;font-size:0.8rem;display:flex;align-items:center;gap:6px';
hiddenBar.innerHTML = `<span style="font-size:0.9rem">🙈</span><span>${t('channels.hidden_channels_count', { count: _hiddenExisting.length })}</span>`;
hiddenBar.title = t('channels.hidden_channels_restore_title');
hiddenBar.addEventListener('click', () => this._openHiddenChannelsModal());
list.appendChild(hiddenBar);
}
// ── 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.is_group ? this._groupName(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">${ch.is_group ? '👥' : '@'}</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', () => {
// Single-click on a DM opens it in a floating PiP panel overlaid
// on the user's current channel. Does NOT switch channels.
// Users can flip this in Settings → Chat: when "Open DMs in
// fullscreen on single click" is on, single-click switches to the
// full DM and double-click opens the PiP. (#5295)
if (localStorage.getItem('haven_dm_fullscreen_default') === 'true') {
this._closeDMPiP?.();
this.switchChannel(ch.code);
} else {
this._openDMPiP?.(ch.code);
}
// On mobile, the sidebar covers the chat. Close it so the user
// can actually see the DM they just picked.
this._closeMobilePanels?.();
});
el.addEventListener('dblclick', () => {
if (localStorage.getItem('haven_dm_fullscreen_default') === 'true') {
this._openDMPiP?.(ch.code);
} else {
// Double-click switches to the full DM pane (legacy behavior).
this._closeDMPiP?.();
this.switchChannel(ch.code);
}
this._closeMobilePanels?.();
});
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);
// Set up drag-and-drop reordering
this._setupChannelDragDrop();
this._setupDmDragDrop();
this._setupPersonalChannelOrder();
this._decorateArrangeMode();
this._updateNestedIndicators();
// Forum dots are drawn apart from the counts above (app-forum-unread.js).
Object.keys(this.forumUnread || {}).forEach(code => this._updateForumDot?.(code));
// So are the dots for news in threads you take part in (app-thread-unread.js).
this._paintThreadDots?.();
},
// ── 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="${t('channels.quick_switcher_placeholder')}" 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.is_group ? `👥 ${this._groupName(ch)}` : 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);
},
};