mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-25 16:20:42 +00:00
Channels, Close and Create now use Haven's existing translations through window.t (English when a key is missing). The two empty catch blocks log a warning instead of swallowing the error.
860 lines
26 KiB
JavaScript
860 lines
26 KiB
JavaScript
/**
|
||
* @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 = `
|
||
<button type="button" class="channel-tab settings-tab" data-channel-tab="channels">
|
||
# <span class="channel-tab-label"></span>
|
||
<span class="channel-tab-notify-dot settings-tab" id="channels-channel-tab-notify-dot" style="display:none">
|
||
<span class="channel-tab-notify-dot-indicator"></span>
|
||
</span>
|
||
</button>
|
||
|
||
<button type="button" class="channel-tab settings-tab" data-channel-tab="DMs">
|
||
👥 <span class="channel-tab-label"></span>
|
||
<span class="channel-tab-notify-dot settings-tab" id="DMs-channel-tab-notify-dot" style="display:none">
|
||
<span class="channel-tab-notify-dot-indicator"></span>
|
||
</span>
|
||
</button>
|
||
|
||
<button type="button" class="join-create-btn" id="join-create-btn" title="Join or create channels" aria-label="Join or create channels">➕</button>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="modal modal-wide channel-tabs-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="channel-tabs-modal-title">
|
||
|
||
<h3 id="channel-tabs-modal-title">
|
||
➕ Join or Create Channel
|
||
</h3>
|
||
|
||
<div class="channel-tabs-modal-content"></div>
|
||
|
||
<div class="modal-actions">
|
||
<button type="button"
|
||
class="btn-sm"
|
||
id="close-join-create-btn"></button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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;
|
||
}
|