/**
* @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;
}