/** * @name Channel Tabs Layout * @description Reversible Channels/DMs sidebar tabs with a Join/Create modal. * @author birdcrazy * @version 1.0.0 */ class ChannelTabsLayout { static ID = 'ChannelTabsLayout'; constructor() { this._started = false; this._engaged = false; this._suspended = false; this._style = null; this._tabs = null; this._modal = null; this._modalContent = null; this._moved = []; this._createButtonText = null; this._notificationHook = null; this._hookTimer = null; this._listeners = []; this._generatedSplit = false; this._split = null; this._boundOwnerChange = this._onOwnerChange.bind(this); this._boundEditingChange = this._onEditingChange.bind(this); this._boundDelegatedClick = this._onDelegatedClick.bind(this); } // ───────────────────────────────────────────────────── // Lifecycle // ───────────────────────────────────────────────────── start() { if (this._started) return; this._started = true; this._suspended = document.documentElement.hasAttribute('data-haven-layout-editing'); document.addEventListener('haven:layout-owner-change', this._boundOwnerChange); document.addEventListener('haven:layout-editing', this._boundEditingChange); if (HavenApi.Data.load(ChannelTabsLayout.ID, 'layoutOn', '1') !== '0') { this._engage(false); } } stop() { if (!this._started) return; this._disengage(false); document.removeEventListener('haven:layout-owner-change', this._boundOwnerChange); document.removeEventListener('haven:layout-editing', this._boundEditingChange); this._started = false; } _onOwnerChange() { if (!this._engaged) return; const owner = document.documentElement.getAttribute('data-haven-layout-owner'); if (owner && owner !== ChannelTabsLayout.ID) { // Another layout has acquired ownership. Restore our DOM, // but do not release ownership from the new layout. this._disengage(false, false); } } _onEditingChange() { const editing = document.documentElement.hasAttribute('data-haven-layout-editing'); if (editing && this._engaged) { this._suspended = true; this._disengage(false); } else if (!editing && this._suspended) { this._suspended = false; if (HavenApi.Data.load(ChannelTabsLayout.ID, 'layoutOn', '1' ) !== '0') { this._engage(false); } } } _engage(persist = true) { if (this._engaged || this._suspended) return false; const channelsPane = document.getElementById('channels-pane'); const dmPane = document.getElementById('dm-pane'); if (!channelsPane || !dmPane) { console.warn('[Channel Tabs] Could not find #channels-pane and #dm-pane.'); return false; } if (!HavenApi.Layout.acquire(ChannelTabsLayout.ID)) { return false; } try { this._engaged = true; this._applyLayout(); if (persist) { HavenApi.Data.save(ChannelTabsLayout.ID, 'layoutOn', '1'); } return true; } catch (error) { this._restoreLayout(); this._engaged = false; try { HavenApi.Layout.release(ChannelTabsLayout.ID); } catch (releaseError) { console.warn('[Channel Tabs] Layout release failed:', releaseError); } console.error('[Channel Tabs] Failed to engage layout:', error); return false; } } _disengage(persist = true, releaseOwnership = true) { if (!this._engaged) return; this._engaged = false; this._restoreLayout(); if (releaseOwnership) { try { HavenApi.Layout.release(ChannelTabsLayout.ID); } catch (error) { console.warn('[Channel Tabs] Layout release failed:', error); } } if (persist) { HavenApi.Data.save(ChannelTabsLayout.ID, 'layoutOn', '0'); } } // Haven's own translation for strings core already has a key for. t() // returns the key itself when it has no text, so that falls back to English. _t(key, fallback) { const translate = typeof window !== 'undefined' ? window.t : null; if (typeof translate !== 'function') return fallback; const text = translate(key); return typeof text === 'string' && text && text !== key ? text : fallback; } // ───────────────────────────────────────────────────── // Reversible DOM manipulation // ───────────────────────────────────────────────────── // Only the position is remembered. Haven keeps changing these sections // while the layout is on (the Create section is shown or hidden as // permissions arrive), so putting back an old class or style would undo // Haven's own update. _moveElement(element, destination, before = null) { if (!element || !element.parentNode) return; this._moved.push({ element, parent: element.parentNode, nextSibling: element.nextSibling }); destination.insertBefore(element, before); } _restoreMovedElements() { // Restore in reverse order so sibling anchors that were moved // elsewhere are restored before their dependants. for (let i = this._moved.length - 1; i >= 0; i--) { const { element, parent, nextSibling } = this._moved[i]; if (!parent) continue; const anchor = nextSibling && nextSibling.parentNode === parent ? nextSibling : null; parent.insertBefore(element, anchor); } this._moved = []; // Put back the create button's own label, unless Haven changed it since. const text = this._createButtonText; if (text && text.element.textContent === text.label) { text.element.textContent = text.original; } this._createButtonText = null; } _applyLayout() { const channelsPane = document.getElementById('channels-pane'); const dmPane = document.getElementById('dm-pane'); if (!channelsPane || !dmPane) { throw new Error('Required sidebar panes were not found.'); } document.documentElement.setAttribute('data-sidebar-tabs-layout', '1'); this._installStyles(); this._ensureSplit(); this._createTabs(); this._createModal(); if (!this._installNotificationHook()) this._retryNotificationHook(); this._moveJoinCreateSections(); this._bindEvents(); this._setChannelTab(this._loadActiveTab() === 'DMs' ? 'DMs' : 'channels'); } // The open tab lives with the plugin's own data, not in a loose // localStorage key of its own. The first version used 'activeChannelTab'; // pick that up once and drop it. _loadActiveTab() { let legacy = null; try { legacy = localStorage.getItem('activeChannelTab'); if (legacy !== null) localStorage.removeItem('activeChannelTab'); } catch (error) { console.warn('[Channel Tabs] Could not read the old tab choice:', error); } if (legacy === 'DMs' || legacy === 'channels') { this._saveActiveTab(legacy); return legacy; } try { return HavenApi.Data.load(ChannelTabsLayout.ID, 'activeTab', 'channels'); } catch (error) { console.warn('[Channel Tabs] Could not read the tab choice:', error); return 'channels'; } } _saveActiveTab(tab) { try { HavenApi.Data.save(ChannelTabsLayout.ID, 'activeTab', tab); } catch (error) { // The tab still switches; it just is not remembered after a reload. console.warn('[Channel Tabs] Could not save the tab choice:', error); } } _ensureSplit() { const channelsPane = document.getElementById('channels-pane'); const dmPane = document.getElementById('dm-pane'); let split = document.getElementById('sidebar-split'); if (split) { this._split = split; return; } // Older Haven layouts may not have the shared split wrapper. // Create one while remembering the original positions of its children. split = document.createElement('div'); split.id = 'sidebar-split'; split.className = 'sidebar-split'; split.dataset.modId = 'channels'; channelsPane.parentNode.insertBefore(split, channelsPane); this._moveElement(channelsPane, split); const handle = document.getElementById('sidebar-split-handle'); if (handle) this._moveElement(handle, split); this._moveElement(dmPane, split); this._split = split; this._generatedSplit = true; } _createTabs() { if (this._tabs) return; const tabs = document.createElement('div'); tabs.id = 'channel-dm-select'; tabs.className = 'sidebar-section channel-tabs'; tabs.dataset.modId = 'channel-dm-select'; tabs.innerHTML = ` `; const labels = tabs.querySelectorAll('.channel-tab-label'); labels[0].textContent = this._t('app.sidebar.channels', 'Channels'); labels[1].textContent = 'DMs'; this._split.parentNode.insertBefore(tabs, this._split); this._tabs = tabs; } _createModal() { if (this._modal) return; const modal = document.createElement('div'); modal.id = 'join-create-modal'; modal.className = 'modal-overlay'; modal.style.display = 'none'; modal.innerHTML = ` `; modal.querySelector('#close-join-create-btn').textContent = this._t('modals.common.close', 'Close'); document.body.appendChild(modal); this._modal = modal; this._modalContent = modal.querySelector('.channel-tabs-modal-content'); } _updateChannelTabNotificationDots(channels = [], unreadCounts = {}) { const channelDot = document.getElementById('channels-channel-tab-notify-dot'); const dmDot = document.getElementById('DMs-channel-tab-notify-dot'); if (!channelDot && !dmDot) return; let hasChannelUnread = false; let hasDmUnread = false; // Muted channels do not count, the same as Haven's tab title and // desktop badge. let muted = new Set(); try { muted = new Set(JSON.parse(localStorage.getItem('haven_muted_channels') || '[]')); } catch (error) { console.warn('[Channel Tabs] Could not read muted channels:', error); } for (const ch of channels || []) { if (!ch || muted.has(ch.code)) continue; const counts = unreadCounts || {}; const count = Object.prototype.hasOwnProperty.call(counts, ch.code) ? counts[ch.code] : (ch.unreadCount || 0); if (!(count > 0)) continue; if (ch.is_dm) { hasDmUnread = true; } else { hasChannelUnread = true; } if (hasChannelUnread && hasDmUnread) break; } if (channelDot) { channelDot.style.display = hasChannelUnread ? '' : 'none'; } if (dmDot) { dmDot.style.display = hasDmUnread ? '' : 'none'; } } _installNotificationHook() { const app = window.app; if (!app || typeof app._renderChannels !== 'function') { return false; } if (this._notificationHook?.app === app) { return true; } this._removeNotificationHook(); const plugin = this; const hooks = []; // Refresh the dots after the channel list renders and whenever Haven // recounts unread messages. _updateBadge always ends in _updateTabTitle, // and the paths that skip _updateBadge (reading a DM in PiP, coming back // to the window) call _updateTabTitle directly. for (const method of ['_renderChannels', '_updateTabTitle']) { if (typeof app[method] !== 'function') continue; const original = app[method]; const own = Object.prototype.hasOwnProperty.call(app, method); const wrapped = function (...args) { const result = original.apply(this, args); plugin._updateChannelTabNotificationDots(this.channels, this.unreadCounts); return result; }; app[method] = wrapped; hooks.push({ method, original, wrapped, own }); } this._notificationHook = { app, hooks }; // Initialize the dots using the current state. this._updateChannelTabNotificationDots(app.channels, app.unreadCounts); return true; } // The layout can come on before Haven's app object exists (a saved // choice applied at page load); keep trying for a little while. _retryNotificationHook(attempt = 0) { clearTimeout(this._hookTimer); this._hookTimer = null; if (!this._engaged || attempt >= 40) return; this._hookTimer = setTimeout(() => { this._hookTimer = null; if (this._engaged && !this._installNotificationHook()) { this._retryNotificationHook(attempt + 1); } }, 250); } _removeNotificationHook() { clearTimeout(this._hookTimer); this._hookTimer = null; const hook = this._notificationHook; if (!hook) return; for (const { method, original, wrapped, own } of hook.hooks) { // Don't overwrite another component's wrapper. if (hook.app[method] !== wrapped) continue; // Haven's methods live on the prototype: deleting the wrapper brings // it back rather than leaving a copy on the instance. if (own) hook.app[method] = original; else delete hook.app[method]; } this._notificationHook = null; } _moveJoinCreateSections() { const joinSection = document.querySelector( '.sidebar-section[data-mod-id="join"][data-haven-region="join-channel"]' ); const createSection = document.getElementById('admin-controls'); if (joinSection) { this._moveElement(joinSection, this._modalContent); } if (createSection) { this._moveElement(createSection, this._modalContent); } const createButton = document.getElementById('create-channel-btn'); if (createButton) { const label = this._t('modals.create_sub.create_btn', 'Create'); this._createButtonText = { element: createButton, original: createButton.textContent, label }; createButton.textContent = label; } } // ───────────────────────────────────────────────────── // Tab behavior // ───────────────────────────────────────────────────── _setChannelTab(tab) { if (tab !== 'channels' && tab !== 'DMs') return; if (!this._tabs || !this._split) return; // Guests cannot use DMs, so their sidebar always shows channels. if (tab === 'DMs' && document.body?.classList.contains('is-guest')) tab = 'channels'; this._saveActiveTab(tab); this._tabs.querySelectorAll('[data-channel-tab]').forEach(button => { const active = button.dataset.channelTab === tab; button.classList.toggle('active', active); button.setAttribute('aria-selected', String(active)); }); const channelsPane = document.getElementById('channels-pane'); const dmPane = document.getElementById('dm-pane'); if (channelsPane) { channelsPane.classList.toggle('pane-hidden', tab !== 'channels'); } if (dmPane) { dmPane.classList.toggle('pane-hidden', tab !== 'DMs'); } } _onDelegatedClick(event) { const tabButton = event.target.closest('[data-channel-tab]'); if (tabButton && this._tabs?.contains(tabButton)) { this._setChannelTab(tabButton.dataset.channelTab); return; } if (event.target.closest('#join-create-btn')) { this._openModal(); return; } if (event.target.closest('#close-join-create-btn')) { this._closeModal(); return; } if (event.target === this._modal) { this._closeModal(); return; } // Haven's "Create Temp Channel" row opens the create form with // Temporary ticked; that form is in the modal now, so open it first // and let Haven's own handler run after this capture listener. if (event.target.closest('#channel-list .temp-channel-create-btn')) { this._openModal(); return; } // Selecting an item in either list selects the matching tab. if (event.target.closest('#channel-list .channel-item')) { this._setChannelTab('channels'); return; } if (event.target.closest('#dm-list .channel-item')) { this._setChannelTab('DMs'); return; } // Let Haven's existing join/create handlers process the form first. if (event.target.closest('#join-channel-btn')) { const input = document.getElementById('channel-code-input'); if (input?.value.trim()) { setTimeout(() => { if (this._modal) this._closeModal(); this._setChannelTab('channels'); }, 0); } return; } if (event.target.closest('#create-channel-btn')) { const input = document.getElementById('new-channel-name'); if (input?.value.trim()) { setTimeout(() => { if (this._modal) this._closeModal(); this._setChannelTab('channels'); }, 0); } return; } } _bindEvents() { document.addEventListener('click', this._boundDelegatedClick, true); this._listeners.push(() => { document.removeEventListener('click', this._boundDelegatedClick, true); }); const keyHandler = event => { if (event.key === 'Escape' && this._modal?.style.display !== 'none') { this._closeModal(); } }; document.addEventListener('keydown', keyHandler); this._listeners.push(() => { document.removeEventListener('keydown', keyHandler); }); } _openModal() { if (!this._modal) return; // Haven shows or hides the Create section by permission; leave it be. this._modal.style.display = 'flex'; document.getElementById('channel-code-input')?.focus(); } _closeModal() { if (!this._modal) return; this._modal.style.display = 'none'; } // ───────────────────────────────────────────────────── // Styling // ───────────────────────────────────────────────────── _installStyles() { if (this._style) return; const style = document.createElement('style'); style.id = 'sidebar-tabs-layout-styles'; style.textContent = ` html[data-sidebar-tabs-layout="1"] #sidebar-split .channel-section, html[data-sidebar-tabs-layout="1"] #sidebar-split .dm-section-pane { display: flex; flex-direction: column; flex: 1 1 0px !important; min-height: 0; overflow: hidden; } /* Haven hides the DM pane while there are no DMs; on its own tab it stays up so the tab is not blank. Guests have no DMs at all. */ html[data-sidebar-tabs-layout="1"] body:not(.is-guest) #sidebar-split #dm-pane:not(.pane-hidden) { display: flex !important; } html[data-sidebar-tabs-layout="1"] body.is-guest #channel-dm-select [data-channel-tab="DMs"] { display: none !important; } html[data-sidebar-tabs-layout="1"] .sidebar-mod-container { display: flex; flex-direction: column; min-height: 0; } html[data-sidebar-tabs-layout="1"] #sidebar-split { display: flex !important; flex: 1 1 0; flex-direction: column; min-height: 0; overflow: hidden; } html[data-sidebar-tabs-layout="1"] #sidebar-split-handle { display: none !important; } html[data-sidebar-tabs-layout="1"] #sidebar-split .channel-section, html[data-sidebar-tabs-layout="1"] #sidebar-split .dm-section-pane { display: flex; flex: 1 1 0; flex-direction: column; min-height: 0; overflow: hidden; } html[data-sidebar-tabs-layout="1"] #sidebar-split .channel-list, html[data-sidebar-tabs-layout="1"] #sidebar-split .dm-list-scroll { flex: 1 1 0; min-height: 0; overflow-x: hidden; overflow-y: auto; } html[data-sidebar-tabs-layout="1"] #sidebar-split .pane-hidden { display: none !important; } html[data-sidebar-tabs-layout="1"] .channel-tabs { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } html[data-sidebar-tabs-layout="1"] .channel-tab { position: relative; padding: 0.3125rem 0.875rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; } html[data-sidebar-tabs-layout="1"] .channel-tab:hover { background: var(--bg-tertiary); color: var(--text-primary); } html[data-sidebar-tabs-layout="1"] .channel-tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } html[data-sidebar-tabs-layout="1"] .channel-tab-notify-dot { position: absolute; top: -1px; right: -1px; width: 13px; height: 13px; background-color: var(--bg-secondary); padding: 0 0; border-top: none; border-right: none; border-radius: 0px 0px 0px 75%; } html[data-sidebar-tabs-layout="1"] .channel-tab-notify-dot-indicator { position: absolute; top: 0px; right: 0px; width: 8px; height: 8px; background-color: var(--danger); border-radius: 50%; } html[data-sidebar-tabs-layout="1"] .join-create-btn { margin-left: auto; border-radius: var(--radius-sm); background: transparent; font-size: 1.2em; } html[data-sidebar-tabs-layout="1"] .join-create-btn:hover { background: var(--bg-hover); } html[data-sidebar-tabs-layout="1"] #join-create-modal { z-index: 10000; align-items: center; justify-content: center; } html[data-sidebar-tabs-layout="1"] #join-create-modal .channel-tabs-modal { display: flex; flex-direction: column; width: min(450px, calc(100vw - 24px)); max-height: 85vh; min-width: min(320px, calc(100vw - 24px)); overflow: hidden; } html[data-sidebar-tabs-layout="1"] #join-create-modal .channel-tabs-modal-content { flex: 1 1 auto; min-height: 0; overflow-y: auto; } html[data-sidebar-tabs-layout="1"] #join-create-modal .channel-tabs-modal-content > .sidebar-section { margin-bottom: 8px; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); } html[data-sidebar-tabs-layout="1"] #join-create-modal .modal-actions { display: flex; justify-content: flex-end; flex: 0 0 auto; margin-top: auto; padding-top: 12px; } `; document.head.appendChild(style); this._style = style; } // ───────────────────────────────────────────────────── // Restoration // ───────────────────────────────────────────────────── _restoreLayout() { for (const cleanup of this._listeners.splice(0)) { try { cleanup(); } catch (error) { // Keep restoring the rest of the sidebar even if one listener fails. console.warn('[Channel Tabs] Cleanup step failed:', error); } } // Sections go home before the modal holding them is removed. this._restoreMovedElements(); if (this._modal) { this._modal.remove(); this._modal = null; this._modalContent = null; } if (this._tabs) { this._tabs.remove(); this._tabs = null; } for (const id of ['channels-pane', 'dm-pane']) { document.getElementById(id)?.classList.remove('pane-hidden'); } this._removeNotificationHook(); if (this._generatedSplit && this._split) { this._split.remove(); } this._split = null; this._generatedSplit = false; if (this._style) { this._style.remove(); this._style = null; } document.documentElement.removeAttribute('data-sidebar-tabs-layout'); } } if (typeof module !== 'undefined') { module.exports = ChannelTabsLayout; } if (typeof _win !== 'undefined') { _win.ChannelTabsLayout = ChannelTabsLayout; }