/** * @name Chrome Layout * @description Compacted sidebar chrome: moves channel creation to a header + menu, docks direct messages and people into the footer, places voice controls on each channel row, and provides a quick settings menu on the home server icon. * @author Amnibro * @version 1.0.0 */ class ChromeLayout { constructor() { this._started = false; this._stopping = false; this._transitioning = false; this._engaged = false; this._blocked = false; this._suspended = false; this._permListeners = []; this._listeners = []; this._injectedElements = new Set(); this._modifiedClasses = new Map(); } start() { if (this._started) return; this._started = true; this._stopping = false; this._transitioning = false; this._engaged = false; this._blocked = false; this._suspended = typeof document !== 'undefined' && Boolean(document.documentElement?.hasAttribute?.('data-haven-layout-editing')); try { // Shortcut Ctrl+Alt+S (and Ctrl+Shift+S) to toggle layout const kd = (e) => { if (e.ctrlKey && (e.altKey || e.shiftKey) && (e.key === 'S' || e.key === 's')) { e.preventDefault(); this._toggle(); } }; this._listenPerm(document, 'keydown', kd, true); // Mod Mode layout editing hooks this._listenPerm(document, 'haven:layout-editing', (event) => { if (this._stopping) return; const active = event.detail?.active === true; if (active) { if (this._engaged && !this._suspended) this._suspend(); return; } if (this._getSavedLayoutOn() === '0') return; if (event.detail?.owner && event.detail.owner !== 'ChromeLayout') return; if (this._engaged) this._resume(); else this._engage(false); }); // Layout owner transitions this._listenPerm(document, 'haven:layout-owner-change', (event) => { if (event.detail?.owner) { if (event.detail.owner !== 'ChromeLayout' && this._engaged) { this._disengage(false); } return; } if (this._stopping || this._transitioning || document.documentElement?.hasAttribute?.('data-haven-layout-editing')) return; if (this._getSavedLayoutOn() === '0') return; if (this._engaged && this._suspended) this._resume(); else if (!this._engaged) this._engage(false); }); const layoutOn = this._getSavedLayoutOn() !== '0'; if (layoutOn && !this._suspended) { this._engage(false); } } catch (error) { this.stop(); throw error; } } stop() { if (!this._started) return; this._started = false; this._stopping = true; try { this._disengage(false); } finally { for (const [target, type, fn, opts] of this._permListeners || []) { target.removeEventListener?.(type, fn, opts); } this._permListeners = []; this._stopping = false; } } _toggle() { this._engaged ? this._disengage() : this._engage(); } _getSavedLayoutOn() { try { const api = typeof HavenApi !== 'undefined' ? HavenApi.Data : window.HavenApi?.Data; return api?.load('ChromeLayout', 'layoutOn', '1') ?? '1'; } catch { return '1'; } } _saveLayoutOn(value) { const api = typeof HavenApi !== 'undefined' ? HavenApi.Data : window.HavenApi?.Data; api?.save('ChromeLayout', 'layoutOn', value); } _engage(persist = true) { if (this._engaged) return; if (typeof document !== 'undefined' && document.documentElement?.hasAttribute?.('data-haven-layout-editing')) return; const layoutApi = typeof HavenApi !== 'undefined' ? HavenApi.Layout : window.HavenApi?.Layout; if (layoutApi && !layoutApi.acquire('ChromeLayout')) { this._blocked = true; return; } this._blocked = false; this._engaged = true; this._suspended = false; this._transitioning = true; try { if (persist) this._saveLayoutOn('1'); if (typeof document !== 'undefined') { document.documentElement?.setAttribute('data-chrome-layout', '1'); } const domApi = typeof HavenApi !== 'undefined' ? HavenApi.DOM : window.HavenApi?.DOM; domApi?.addStyle('ChromeLayout', ChromeLayout.CSS); this._applyChrome(); this._startObserver(); this._dispatch('haven:chrome-layout', { on: true }); } catch (error) { this._engaged = false; try { this._restoreChrome(); } finally { if (typeof document !== 'undefined') { document.documentElement?.removeAttribute('data-chrome-layout'); } const domApi = typeof HavenApi !== 'undefined' ? HavenApi.DOM : window.HavenApi?.DOM; domApi?.removeStyle('ChromeLayout'); const layoutApi = typeof HavenApi !== 'undefined' ? HavenApi.Layout : window.HavenApi?.Layout; layoutApi?.release('ChromeLayout'); } throw error; } finally { this._transitioning = false; } } _disengage(persist = true) { if (!this._engaged) return; this._engaged = false; this._transitioning = true; try { this._stopObserver(); this._restoreChrome(); if (typeof document !== 'undefined') { document.documentElement?.removeAttribute('data-chrome-layout'); document.documentElement?.classList.remove('dms-open'); document.querySelector('.sidebar')?.classList.remove('dms-open'); } const domApi = typeof HavenApi !== 'undefined' ? HavenApi.DOM : window.HavenApi?.DOM; domApi?.removeStyle('ChromeLayout'); if (persist) { try { this._saveLayoutOn('0'); } catch (err) { // The layout is already off on screen; only the saved choice is lost. console.warn('[ChromeLayout] could not save the layout choice', err); } } const layoutApi = typeof HavenApi !== 'undefined' ? HavenApi.Layout : window.HavenApi?.Layout; layoutApi?.release('ChromeLayout'); this._blocked = Boolean( layoutApi?.owner && layoutApi.owner !== 'ChromeLayout' ); this._dispatch('haven:chrome-layout', { on: false }); } finally { this._transitioning = false; } } _suspend() { if (!this._engaged || this._suspended) return; this._suspended = true; this._transitioning = true; try { this._stopObserver(); this._restoreChrome(); if (typeof document !== 'undefined') { document.documentElement?.removeAttribute('data-chrome-layout'); document.documentElement?.classList.remove('dms-open'); document.querySelector('.sidebar')?.classList.remove('dms-open'); } const domApi = typeof HavenApi !== 'undefined' ? HavenApi.DOM : window.HavenApi?.DOM; domApi?.removeStyle('ChromeLayout'); const layoutApi = typeof HavenApi !== 'undefined' ? HavenApi.Layout : window.HavenApi?.Layout; layoutApi?.release('ChromeLayout'); } finally { this._transitioning = false; } } _resume() { if (!this._engaged || !this._suspended) return; if (typeof document !== 'undefined' && document.documentElement?.hasAttribute?.('data-haven-layout-editing')) return; const layoutApi = typeof HavenApi !== 'undefined' ? HavenApi.Layout : window.HavenApi?.Layout; if (layoutApi && !layoutApi.acquire('ChromeLayout')) { this._blocked = true; return; } this._blocked = false; this._suspended = false; this._transitioning = true; try { if (typeof document !== 'undefined') { document.documentElement?.setAttribute('data-chrome-layout', '1'); } const domApi = typeof HavenApi !== 'undefined' ? HavenApi.DOM : window.HavenApi?.DOM; domApi?.addStyle('ChromeLayout', ChromeLayout.CSS); this._applyChrome(); this._startObserver(); } finally { this._transitioning = false; } } _listenPerm(target, type, fn, opts) { if (!target?.addEventListener) return; target.addEventListener(type, fn, opts); this._permListeners.push([target, type, fn, opts]); } _listen(target, type, fn, opts) { if (!target?.addEventListener) return; target.addEventListener(type, fn, opts); this._listeners.push([target, type, fn, opts]); } // 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; } _dispatch(type, detail) { if (typeof document === 'undefined') return; const event = typeof CustomEvent === 'function' ? new CustomEvent(type, { detail }) : { type, detail }; document.dispatchEvent(event); } // ── DOM Transformations ───────────────────────────────── _applyChrome() { if (typeof document === 'undefined') return; this._setupChannelHeader(); this._setupBottomDock(); this._setupHomeServerMenu(); this._setupChannelVoiceButtons(); this._setupThreadMentionsBadge(); } _restoreChrome() { for (const [target, type, fn, opts] of this._listeners || []) { target.removeEventListener?.(type, fn, opts); } this._listeners = []; // Remove classes added if (this._modifiedClasses) { for (const [el, classes] of this._modifiedClasses.entries()) { if (el?.classList) { for (const cls of classes) el.classList.remove(cls); } } this._modifiedClasses.clear(); } // Put Haven's DM badge back before the dock button holding it goes away this._returnDmBadge(); // Remove injected elements if (this._injectedElements) { for (const el of this._injectedElements) { el.remove?.(); } this._injectedElements.clear(); } // Clean up channel row voice buttons if (typeof document !== 'undefined') { document.querySelectorAll?.('.channel-join-voice')?.forEach(el => el.remove?.()); const home = document.getElementById('home-server'); if (home) delete home.dataset.homeMenuBound; } } _addClass(el, cls) { if (!el?.classList) return; el.classList.add(cls); if (!this._modifiedClasses.has(el)) this._modifiedClasses.set(el, new Set()); this._modifiedClasses.get(el).add(cls); } _createInjected(tag, id, className, html = '') { const el = document.createElement(tag); if (id) el.id = id; if (className) el.className = className; if (html) el.innerHTML = html; this._injectedElements.add(el); return el; } // 1. Channel Header: + Button and Sheet Menu _setupChannelHeader() { const channelsToggle = document.getElementById('channels-toggle') || document.querySelector('[data-haven-region="channels"] h5') || document.querySelector('.channels-toggle'); if (!channelsToggle) return; // Subtle styling on utility buttons const subBtn = document.getElementById('sub-channel-panel-btn'); const orgBtn = document.getElementById('organize-channels-btn'); if (subBtn) this._addClass(subBtn, 'channel-header-util'); if (orgBtn) this._addClass(orgBtn, 'channel-header-util'); // Add prominent + button if not already present let addBtn = document.getElementById('channel-actions-btn'); if (!addBtn) { addBtn = this._createInjected('button', 'channel-actions-btn', 'icon-btn small channel-actions-add-btn chrome-chip', ` `); addBtn.type = 'button'; addBtn.title = 'Join or create a channel'; addBtn.setAttribute('aria-haspopup', 'menu'); addBtn.setAttribute('aria-expanded', 'false'); addBtn.style.position = 'relative'; addBtn.style.zIndex = '2'; channelsToggle.appendChild(addBtn); } // Add sheet menu let menu = document.getElementById('channel-actions-menu'); if (!menu) { menu = this._createInjected('span', 'channel-actions-menu', 'channel-actions-menu'); for (const [action, key, fallback] of [ ['join', 'app.sidebar.join_channel', 'Join channel'], ['create', 'app.sidebar.create_channel', 'Create channel'], ['temp', 'channels.create_temp_channel', 'Temporary channel'], ]) { const item = document.createElement('button'); item.type = 'button'; item.setAttribute('role', 'menuitem'); item.dataset.action = action; item.textContent = this._t(key, fallback); menu.appendChild(item); } menu.hidden = true; menu.setAttribute('role', 'menu'); channelsToggle.appendChild(menu); } // Bind + button toggle const toggleMenu = (e) => { e?.stopPropagation?.(); const willOpen = menu.hidden; this._setChannelActionsMenu(!willOpen); }; this._listen(addBtn, 'click', toggleMenu); // Refresh permissions on actions const app = typeof window !== 'undefined' ? window.app : null; const canCreate = !!(app?.user?.isAdmin || app?._hasGlobalPerm?.('create_channel')); const canCreateTemp = !!(app?.user?.isAdmin || app?._hasPerm?.('create_temp_channel') || canCreate); const createItem = menu.querySelector?.('[data-action="create"]'); if (createItem) { createItem.classList.toggle('is-disabled', !canCreate); createItem.setAttribute('aria-disabled', canCreate ? 'false' : 'true'); } const tempItem = menu.querySelector?.('[data-action="temp"]'); if (tempItem) { tempItem.classList.toggle('is-disabled', !canCreateTemp); tempItem.setAttribute('aria-disabled', canCreateTemp ? 'false' : 'true'); } // Handle menu item selection const onMenuClick = (e) => { e.stopPropagation(); const item = e.target.closest?.('[data-action]'); if (!item || item.classList.contains('is-disabled')) return; const action = item.dataset.action; this._setChannelActionsMenu(false); this._handleChannelAction(action, canCreate); }; this._listen(menu, 'click', onMenuClick); // Outside click & Escape to dismiss this._listen(document, 'pointerdown', (e) => { if (!menu.hidden && !menu.contains(e.target) && !addBtn.contains(e.target)) { this._setChannelActionsMenu(false); } }, true); this._listen(document, 'keydown', (e) => { if (e.key === 'Escape' && !menu.hidden) { this._setChannelActionsMenu(false); } }); } _setChannelActionsMenu(open) { const menu = document.getElementById('channel-actions-menu'); const btn = document.getElementById('channel-actions-btn'); if (menu) menu.hidden = !open; if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); } // Opens a collapsed Join or Create section through Haven's own header // toggle, so the layout leaves no inline styles behind and Haven remembers // the section as open the same way it does for a click. _expandSection(name) { const body = document.getElementById(`${name}-section-body`); if (body?.classList?.contains('collapsed')) { document.getElementById(`${name}-section-toggle`)?.click?.(); } } _handleChannelAction(action, canCreate) { const app = typeof window !== 'undefined' ? window.app : null; if (action === 'join') { this._expandSection('join'); document.getElementById('channel-code-input')?.focus(); return; } if (action === 'temp' && !canCreate) { // Without create_channel Haven hides the create form, so ask for a name. app?._showPromptModal?.( this._t('channels.create_temp_channel_title', 'Temporary Channel'), this._t('channels.create_temp_channel_hint', 'Enter a name for the auto-expiring channel:') ).then((name) => { if (name?.trim() && app.socket) { app.socket.emit('create-temp-channel', { name: name.trim() }); } }); return; } if (action !== 'create' && action !== 'temp') return; // Haven itself shows the create form to everyone who may create channels. this._expandSection('create'); const tmp = document.getElementById('new-channel-temporary'); if (tmp) { tmp.checked = action === 'temp'; tmp.dispatchEvent?.(new Event('change')); } document.getElementById('new-channel-name')?.focus(); } // 2. Footer Dock: People & Direct Messages _setupBottomDock() { const bottomBar = document.querySelector('.sidebar-bottom-bar') || document.querySelector('[data-haven-region="sidebar-actions"]'); if (!bottomBar) return; let peopleBtn = document.getElementById('people-dock-btn'); if (!peopleBtn) { peopleBtn = this._createInjected('button', 'people-dock-btn', 'sidebar-bottom-btn chrome-chip', ` `); peopleBtn.type = 'button'; peopleBtn.title = 'People'; peopleBtn.setAttribute('aria-pressed', 'false'); bottomBar.prepend(peopleBtn); } let dmBtn = document.getElementById('dm-dock-btn'); if (!dmBtn) { dmBtn = this._createInjected('button', 'dm-dock-btn', 'sidebar-bottom-btn chrome-chip', ` `); dmBtn.type = 'button'; dmBtn.title = this._t('app.sidebar.direct_messages', 'Direct messages'); dmBtn.setAttribute('aria-pressed', 'false'); bottomBar.insertBefore(dmBtn, peopleBtn.nextSibling); } this._adoptDmBadge(dmBtn); // Toggle the People panel with Haven's own panel toggle, which also // remembers the choice, and keep the dock button's pressed state in step // however the panel is toggled. const panelToggle = document.getElementById('sidebar-toggle-btn'); const syncPeoplePressed = () => { const right = document.getElementById('right-sidebar'); const open = Boolean(right && !right.classList.contains('collapsed')); peopleBtn.setAttribute('aria-pressed', open ? 'true' : 'false'); }; syncPeoplePressed(); this._listen(panelToggle, 'click', syncPeoplePressed); this._listen(peopleBtn, 'click', (e) => { e.preventDefault(); e.stopPropagation(); panelToggle?.click?.(); }); // Toggle DM pane drawer this._listen(dmBtn, 'click', (e) => { e.preventDefault(); e.stopPropagation(); this._toggleDmDock(); }); // Outside click & Escape to close DM dock this._listen(document, 'pointerdown', (e) => { if (document.documentElement.classList.contains('dms-open')) { if (!e.target.closest('#dm-pane, #dm-dock-btn, #people-dock-btn')) { this._setDmDockOpen(false); } } }, true); this._listen(document, 'keydown', (e) => { if (e.key === 'Escape' && document.documentElement.classList.contains('dms-open')) { this._setDmDockOpen(false); } }); // In the drawer, clicking the DM header closes the drawer. It is caught // on the way down so Haven's own handler does not also collapse the DM // list and save that, which would leave the list collapsed in Original. this._listen(document, 'click', (e) => { const dmHeader = document.getElementById('dm-toggle-header'); if (!dmHeader?.contains(e.target) || e.target?.closest?.('button')) return; e.stopPropagation(); this._setDmDockOpen(false); }, true); } _toggleDmDock() { const isOpen = document.documentElement.classList.contains('dms-open'); this._setDmDockOpen(!isOpen); } _setDmDockOpen(open) { if (typeof document === 'undefined') return; document.documentElement.classList.toggle('dms-open', !!open); const sidebar = document.querySelector('.sidebar'); sidebar?.classList.toggle('dms-open', !!open); const btn = document.getElementById('dm-dock-btn'); if (btn) btn.setAttribute('aria-pressed', open ? 'true' : 'false'); } // Haven keeps its DM unread badge up to date by id, and that badge sits in // the DM header, which is hidden while the drawer is closed. Moving the one // real badge onto the dock button (and back on disengage) keeps it live // without a second element carrying the same id. _adoptDmBadge(dmBtn) { const badge = document.getElementById('dm-unread-badge'); if (!badge || !dmBtn || badge.parentNode === dmBtn) return; if (!this._dmBadgeHome) { this._dmBadgeHome = { parent: badge.parentNode, next: badge.nextSibling }; } dmBtn.appendChild(badge); } _returnDmBadge() { const home = this._dmBadgeHome; this._dmBadgeHome = null; const badge = typeof document !== 'undefined' ? document.getElementById('dm-unread-badge') : null; if (!home?.parent || !badge) return; const next = home.next?.parentNode === home.parent ? home.next : null; home.parent.insertBefore(badge, next); } // 3. Home Server Menu _setupHomeServerMenu() { const homeBtn = document.getElementById('home-server'); if (!homeBtn || homeBtn.dataset.homeMenuBound === '1') return; homeBtn.dataset.homeMenuBound = '1'; let menu = document.getElementById('home-server-menu'); if (!menu) { menu = this._createInjected('div', 'home-server-menu', 'home-server-menu'); // Server Settings and App Settings have no matching Haven string yet, // so they stay in English. for (const [action, icon, key, fallback] of [ ['add-server', '➕', 'app.sidebar.add_server', 'Add Server'], ['manage-servers', '⚙️', 'app.sidebar.manage_servers', 'Manage Servers'], ['sync-servers', '🔄', 'app.sidebar.sync_servers', 'Sync Servers'], ['server-settings', '🛠️', null, 'Server Settings'], ['app-settings', '🔧', null, 'App Settings'], ]) { const item = document.createElement('button'); item.type = 'button'; item.dataset.homeAction = action; item.textContent = `${icon} ${key ? this._t(key, fallback) : fallback}`; menu.appendChild(item); } menu.hidden = true; document.body.appendChild(menu); } const place = () => { const r = homeBtn.getBoundingClientRect?.() || { left: 0, bottom: 40 }; menu.style.left = `${Math.max(8, Math.min(r.left, (window.innerWidth || 1200) - 200))}px`; menu.style.top = `${r.bottom + 6}px`; }; const toggle = (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); menu.hidden = !menu.hidden; homeBtn.setAttribute('aria-expanded', (!menu.hidden).toString()); if (!menu.hidden) { const app = typeof window !== 'undefined' ? window.app : null; const serverItem = menu.querySelector?.('[data-home-action="server-settings"]'); if (serverItem) serverItem.hidden = !app?._hasAnyAdminSettingsAccess?.(); place(); } }; this._listen(homeBtn, 'click', toggle); this._listen(menu, 'click', (e) => { const item = e.target.closest?.('[data-home-action]'); if (!item) return; menu.hidden = true; homeBtn.setAttribute('aria-expanded', 'false'); const action = item.dataset.homeAction; const app = typeof window !== 'undefined' ? window.app : null; if (action === 'add-server') document.getElementById('add-server-btn')?.click(); else if (action === 'manage-servers') document.getElementById('manage-servers-btn')?.click(); else if (action === 'sync-servers') document.getElementById('sync-servers-btn')?.click(); else if (action === 'server-settings' || action === 'app-settings') { // Open Settings through Haven's own button (it always opens on the // User tab), then switch tabs for Server Settings. document.getElementById('open-settings-btn')?.click(); if (action === 'server-settings') app?._switchSettingsTab?.('admin'); } }); this._listen(document, 'click', (e) => { if (!menu.hidden && !menu.contains(e.target) && !homeBtn.contains(e.target)) { menu.hidden = true; homeBtn.setAttribute('aria-expanded', 'false'); } }); this._listen(window, 'resize', () => { if (!menu.hidden) place(); }); } // 4. Channel Rows: Join/Leave Voice Button _setupChannelVoiceButtons() { if (typeof document === 'undefined') return; const app = typeof window !== 'undefined' ? window.app : null; const canUseVoice = !!(app?.user?.isAdmin || app?.user?.isGuest || app?._hasPerm?.('use_voice') || !app?.user); const inVoice = !!(app?.voice && app.voice.inVoice); const currentChannel = app?.voice?.currentChannel; const channelRows = document.querySelectorAll?.('#channel-list .channel-item'); if (!channelRows?.length) return; channelRows.forEach((row) => { // Core tags each channel row with data-code. const code = row.dataset?.code; if (!code) return; const ch = app?.channels?.find?.(c => c.code === code); if (ch?.is_dm || ch?.voice_enabled === 0 || !canUseVoice) return; let btn = row.querySelector?.('.channel-join-voice'); const inThisVoice = inVoice && currentChannel === code; if (!btn) { btn = document.createElement('button'); btn.type = 'button'; btn.className = 'channel-join-voice'; btn.dataset.joinVoice = code; this._renderVoiceButton(btn, inThisVoice); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (app?.voice?.inVoice && app.voice.currentChannel === code) { if (app._leaveVoice) app._leaveVoice(); else if (app.voice.leaveVoice) app.voice.leaveVoice(); } else if (app?.switchChannel && app._joinVoice) { // Core's _joinVoice joins the channel being viewed, so open this // row's channel first, the same way double-clicking a row does. app.switchChannel(code); setTimeout(() => app._joinVoice(), 300); } }); const moreBtn = row.querySelector?.('.channel-more-btn'); if (moreBtn) row.insertBefore(btn, moreBtn); else row.appendChild(btn); } else { this._renderVoiceButton(btn, inThisVoice); } }); } // Writes the button only when its state flips. Rewriting innerHTML on every // pass is itself a mutation inside #channel-list, which retriggered the // observer and redrew the buttons on every animation frame. _renderVoiceButton(btn, inThisVoice) { const state = inThisVoice ? 'leave' : 'join'; if (btn.dataset.voiceState === state) return; btn.dataset.voiceState = state; btn.classList.toggle('is-live', inThisVoice); btn.classList.toggle('is-leave', inThisVoice); btn.innerHTML = inThisVoice ? ChromeLayout._LEAVE_SVG : '🎤'; btn.title = inThisVoice ? this._t('context_menu.channel.disconnect_voice', 'Disconnect Voice') : this._t('context_menu.channel.join_voice', 'Join Voice'); btn.setAttribute('aria-label', btn.title); } // 5. Thread Mentions Badge _setupThreadMentionsBadge() { const threadBtn = document.getElementById('threads-toggle-btn'); if (!threadBtn) return; this._addClass(threadBtn, 'header-icon-with-badge'); let badge = document.getElementById('threads-toggle-badge'); if (!badge) { badge = this._createInjected('span', 'threads-toggle-badge', 'header-icon-badge'); badge.hidden = true; threadBtn.appendChild(badge); } } // ── Observer for dynamic updates ───────────────────────── _startObserver() { if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return; this._stopObserver(); let scheduled = false; this._obs = new MutationObserver(() => { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; if (!this._engaged || this._suspended) return; this._setupChannelVoiceButtons(); }); }); const target = document.getElementById('channel-list') || document.body; if (target?.nodeType) { this._obs.observe(target, { childList: true, subtree: true }); } } _stopObserver() { if (this._obs) { this._obs.disconnect(); this._obs = null; } } } ChromeLayout._LEAVE_SVG = ''; ChromeLayout.CSS = ` /* ── Chrome Layout Plugin CSS ────────────────────────────── */ /* Category group dividers */ html[data-chrome-layout="1"] .category-label { border-top: 1px solid var(--border); margin-top: 0.625rem; padding-top: 0.5rem; } html[data-chrome-layout="1"] .category-label:first-child { border-top: none; margin-top: 0; padding-top: 0; } /* Primary + Button in channel header */ html[data-chrome-layout="1"] .channels-toggle { overflow: visible; } html[data-chrome-layout="1"] .channels-toggle .channel-actions-add-btn { width: 1.875rem; height: 1.875rem; border-radius: var(--radius-sm, 6px); background: var(--bg-modifier-selected, rgba(255, 255, 255, 0.08)); border: 1px solid var(--border); color: var(--text-primary); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; } html[data-chrome-layout="1"] .channels-toggle .channel-actions-add-btn:hover { background: var(--bg-hover); color: var(--accent); border-color: var(--accent); } /* Subtle utility buttons */ html[data-chrome-layout="1"] .channels-toggle .channel-header-util { width: 1.5rem; height: 1.5rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm, 6px); color: var(--text-muted); opacity: 0.65; cursor: pointer; transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; } html[data-chrome-layout="1"] .channels-toggle .channel-header-util:hover { opacity: 1; color: var(--text-primary); background: var(--bg-modifier-hover, rgba(255, 255, 255, 0.05)); } /* Channel Action Sheet Menu */ html[data-chrome-layout="1"] .channel-actions-menu { position: absolute; right: 0; top: calc(100% + 0.25rem); z-index: 32; min-width: 11.5rem; padding: 0.25rem; background: var(--bg-card, var(--bg-secondary)); border: 1px solid var(--border); border-radius: 0.5rem; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); } html[data-chrome-layout="1"] .channel-actions-menu button { display: block; width: 100%; text-align: left; background: transparent; border: 0; border-radius: 0.375rem; color: var(--text-primary); cursor: pointer; font: inherit; font-size: 0.8125rem; padding: 0.4rem 0.55rem; } html[data-chrome-layout="1"] .channel-actions-menu button:hover { background: var(--bg-hover); } html[data-chrome-layout="1"] .channel-actions-menu button.is-disabled { opacity: 0.45; color: var(--text-muted); cursor: not-allowed; } html[data-chrome-layout="1"] .channel-actions-menu button.is-disabled:hover { background: transparent; } /* Chrome chips & Footer dock */ html[data-chrome-layout="1"] .chrome-chip { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 2.125rem; height: 2.125rem; padding: 0; box-sizing: border-box; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 0.5rem; color: var(--text-secondary); opacity: 1; line-height: 1; overflow: visible; } html[data-chrome-layout="1"] .chrome-chip:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent); } html[data-chrome-layout="1"] #people-dock-btn, html[data-chrome-layout="1"] #dm-dock-btn { position: relative; width: 2.125rem; height: 2.125rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; overflow: visible; } html[data-chrome-layout="1"] #people-dock-btn[aria-pressed="true"], html[data-chrome-layout="1"] #dm-dock-btn[aria-pressed="true"], html[data-chrome-layout="1"].dms-open #dm-dock-btn, html[data-chrome-layout="1"] .sidebar.dms-open #dm-dock-btn { background: var(--bg-active, var(--bg-hover)); color: var(--text-primary); border-color: var(--accent); } /* DM unread badge */ html[data-chrome-layout="1"] #dm-dock-btn #dm-unread-badge { display: inline-flex; position: absolute; top: -0.35rem; right: -0.35rem; margin-left: 0; min-width: 1.05rem; height: 1.05rem; padding: 0 0.28rem; border-radius: 999px; background: var(--danger, #ed4245); color: var(--danger-text, #fff); font-size: 0.625rem; font-weight: 700; line-height: 1.05rem; text-align: center; pointer-events: none; box-shadow: 0 0 0 2px var(--bg-secondary); align-items: center; justify-content: center; } /* Hide split handle and expand channel section */ html[data-chrome-layout="1"] .sidebar-split-handle, html[data-chrome-layout="1"] .sidebar-split-handle:hover, html[data-chrome-layout="1"] .sidebar-split-handle.dragging { display: none !important; } html[data-chrome-layout="1"] .sidebar-split .channel-section { flex: 1 1 100% !important; } /* DM drawer with smooth collapse */ html[data-chrome-layout="1"] .sidebar-split .dm-section-pane { display: flex !important; position: absolute; left: 0; right: 0; bottom: 0; height: min(58%, 22rem); z-index: 20; background: var(--bg-secondary); border-top: 1px solid var(--border); box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.22); padding: 0.35rem 0.65rem 0.5rem; flex: none; min-height: 0; overflow: hidden; flex-direction: column; border-bottom: none; transform: translateY(100%); opacity: 0; pointer-events: none; visibility: hidden; transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.18s ease-out, visibility 0.22s; } html[data-chrome-layout="1"].dms-open .sidebar-split .dm-section-pane, html[data-chrome-layout="1"] .sidebar.dms-open .sidebar-split .dm-section-pane { transform: translateY(0); opacity: 1; pointer-events: auto; visibility: visible; } /* The drawer always shows the DM list, even if it was collapsed in Original, without changing that saved choice */ html[data-chrome-layout="1"] .sidebar-split #dm-list { display: block !important; } html[data-chrome-layout="1"] .sidebar-split #dm-toggle-arrow.collapsed { transform: none; } /* Suppress other layout controls from bottom bar */ html[data-chrome-layout="1"] #braid-return-pill, html[data-chrome-layout="1"] [data-compact-layout-control] { display: none !important; } /* Channel row voice controls */ html[data-chrome-layout="1"] .channel-join-voice { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; /* Last in the row, after the channel options button: that button only takes room while the row is hovered, so a mic before it slid away just as the pointer reached it. */ order: 1; margin-left: 0.25rem; padding: 0; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem; line-height: 1; cursor: pointer; } html[data-chrome-layout="1"] .channel-join-voice:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent); } html[data-chrome-layout="1"] .channel-join-voice.is-live { color: var(--success, #43b581); border-color: color-mix(in srgb, var(--success, #43b581) 45%, var(--border)); } html[data-chrome-layout="1"] .channel-join-voice.is-leave { background: var(--danger, #ed4245); color: var(--danger-text, #fff); border-color: var(--danger, #ed4245); } html[data-chrome-layout="1"] .channel-join-voice.is-leave:hover { filter: brightness(1.08); color: var(--danger-text, #fff); } /* Home server menu */ html[data-chrome-layout="1"] .home-server-menu { position: fixed; z-index: 90; min-width: 12.5rem; padding: 0.25rem; background: var(--bg-card, var(--bg-secondary)); border: 1px solid var(--border); border-radius: 0.625rem; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28); } html[data-chrome-layout="1"] .home-server-menu button { display: block; width: 100%; text-align: left; background: transparent; border: 0; border-radius: 0.4rem; color: var(--text-primary); cursor: pointer; font: inherit; font-size: 0.8125rem; padding: 0.45rem 0.65rem; } html[data-chrome-layout="1"] .home-server-menu button:hover { background: var(--bg-hover); } html[data-chrome-layout="1"] .home-server-menu button[hidden] { display: none; } html[data-chrome-layout="1"] #home-server { cursor: pointer; } /* Header badge for thread mentions */ html[data-chrome-layout="1"] .header-icon-with-badge { position: relative; } html[data-chrome-layout="1"] .header-icon-badge { position: absolute; top: -0.3rem; right: -0.3rem; min-width: 1rem; height: 1rem; padding: 0 0.25rem; border-radius: 999px; background: var(--danger, #d35a5a); color: var(--danger-text, #fff); font-size: 0.625rem; font-weight: 700; line-height: 1rem; text-align: center; box-shadow: 0 0 0 2px var(--bg-secondary); } /* Hide default redundantly placed buttons */ html[data-chrome-layout="1"] #voice-join-btn, html[data-chrome-layout="1"] #voice-active-indicator, html[data-chrome-layout="1"] #voice-leave-header-btn, html[data-chrome-layout="1"] #add-server-btn, html[data-chrome-layout="1"] #manage-servers-btn, html[data-chrome-layout="1"] #sync-servers-btn, html[data-chrome-layout="1"] #desktop-app-banner, html[data-chrome-layout="1"] #android-beta-banner { display: none !important; } `; if (typeof module !== 'undefined') module.exports = ChromeLayout; if (typeof window !== 'undefined') window.ChromeLayout = ChromeLayout; if (typeof _win !== 'undefined') _win.ChromeLayout = ChromeLayout;