/**
* @name BetterChat
* @author BetterChat Contributors
* @description BetterChat for Discord — pin a Discord channel/thread alongside your main view.
* @version 0.1.227
* @source https://github.com/DylDigitals/Splitview-BD
* @updateUrl https://raw.githubusercontent.com/DylDigitals/Splitview-BD/main/BetterChat.plugin.js
*/
// Keep the legacy storage/style namespace so existing installs retain their state.
const PLUGIN_NAME = 'SplitView';
const PLUGIN_VERSION = '0.1.227';
const SETTINGS_KEY = 'settings';
const LOCAL_STORAGE_KEY = `${PLUGIN_NAME}:settings`;
const SESSION_ACTIVE_KEY = `${PLUGIN_NAME}:activeChannelId`;
const CRASH_LOG_KEY = `${PLUGIN_NAME}:crashLog`;
const MAX_CRASH_LOG_EVENTS = 160;
const DEFAULT_WIDTH = 480;
const MIN_WIDTH = 280;
const MIN_FLOATING_HEIGHT = 360;
const DEFAULT_FLOATING_RECT = { left: 96, top: 72, width: 520, height: 680 };
// Product contract: BetterChat piggybacks on Discord's existing routing model.
// Primary target is real threads, but the context-menu affordance must also work
// from normal guild text/announcement channels because that is where users start.
// DMs, group DMs, forums, voice, and synthetic panes remain out of scope.
const SPLIT_TARGET_TYPES = new Set([0, 5, 10, 11, 12]);
const THREAD_TYPES = new Set([10, 11, 12]);
const PIN_ICON_PATH = 'M16 2 22 8l-2 2-2-1-4 4 1 3-2 2-4-4-6 7-1-1 7-6-4-4 2-2 3 1 4-4-1-2z';
const isDiscordId = value => typeof value === 'string' && /^[0-9]{1,20}$/.test(value);
function normalizeRememberedSplits(raw) {
const result = Object.create(null);
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return result;
for (const [accountId, guilds] of Object.entries(raw)) {
if (!isDiscordId(accountId) || !guilds || typeof guilds !== 'object' || Array.isArray(guilds)) continue;
const targets = Object.create(null);
for (const [guildId, channelId] of Object.entries(guilds)) {
if (isDiscordId(guildId) && isDiscordId(channelId)) targets[guildId] = channelId;
}
result[accountId] = targets;
}
return result;
}
function normalizeFloatingRect(raw) {
const value = raw && typeof raw === 'object' ? raw : {};
return {
left: Number.isFinite(value.left) ? value.left : DEFAULT_FLOATING_RECT.left,
top: Number.isFinite(value.top) ? value.top : DEFAULT_FLOATING_RECT.top,
width: Number.isFinite(value.width) ? Math.max(MIN_WIDTH, value.width) : DEFAULT_FLOATING_RECT.width,
height: Number.isFinite(value.height) ? Math.max(MIN_FLOATING_HEIGHT, value.height) : DEFAULT_FLOATING_RECT.height,
};
}
function hasSavedFloatingRect(raw) {
return !!raw && typeof raw === 'object' &&
Number.isFinite(raw.left) && Number.isFinite(raw.top) &&
Number.isFinite(raw.width) && Number.isFinite(raw.height);
}
module.exports = class BetterChat {
constructor() {
this._settings = null;
this._modules = {};
this._unpatchers = [];
this._splitChannelId = null;
this._paneEl = null;
this._paneTitle = null;
this._paneBody = null;
this._dockedResizeObserver = null;
this._dockedObservedHost = null;
this._dockedObservedHeader = null;
this._dockedGeometryRaf = null;
this._resizing = false;
this._compactToolbar = null;
this._resizeStartX = 0;
this._resizeStartWidth = 0;
this._floatingDragStart = null;
this._floatingResizeStart = null;
this._floatingSaveTimer = null;
this._hasSavedFloatingRect = false;
// Native render state
this._renderMode = 'none'; // 'none' | 'placeholder' | 'native'
this._reactRoot = null; // createRoot handle or { _legacy, _el } sentinel
this._SsvErrorBoundary = null; // lazily created fallback ErrorBoundary class
this._layoutObserver = null;
this._remountTimer = null;
this._nativeRenderTimers = new Set();
this._redockTimers = new Set();
this._restoreTimer = null;
this._restoreAttempt = 0;
this._memberListTimer = null;
this._scrollTimers = new Set();
this._openEffectsEpoch = 0;
this._selectedChannelListener = null;
this._persistenceStores = [];
this._accountId = undefined;
this._splitGuildId = null;
this._restoreKey = null;
this._lastMainChannelId = null;
this._lastMainGuildId = null;
this._contextChannelCache = new Map();
this._breakouts = new Map();
this._breakoutCounter = 0;
this._dispatcher = null;
this._onBreakoutMessage = null;
this._attachmentScanBridge = null;
this._attachmentScanWarned = false;
this._releaseSplitAttachmentScan = null;
this._origSidebarInput = null;
this._debugAPI = null;
this._crashLog = [];
this._crashHandlers = null;
this._allowDuplicateChannelForDiagnostics = false;
this._nativeRenderVariant = 'sidebar';
this._stopped = true;
}
// ─── Settings (width + debug only for MVP) ──────────────────────────────────
_getBrowserStorage(kind) {
try {
const root = typeof window !== 'undefined' ? window : globalThis;
const storage = root?.[kind];
return storage && typeof storage.getItem === 'function' ? storage : null;
} catch {
return null;
}
}
_getLocalStorage() {
return this._getBrowserStorage('localStorage');
}
_getSessionStorage() {
return this._getBrowserStorage('sessionStorage');
}
_readSavedSettings() {
let saved = {};
try { saved = BdApi.Data.load(PLUGIN_NAME, SETTINGS_KEY) ?? {}; } catch (e) { this._dbg('BdApi settings load failed:', e.message); }
if (!hasSavedFloatingRect(saved.floatingRect)) {
try {
const local = this._getLocalStorage();
const fallback = local ? JSON.parse(local.getItem(LOCAL_STORAGE_KEY) || '{}') : {};
if (fallback && typeof fallback === 'object') saved = { ...fallback, ...saved, floatingRect: saved.floatingRect ?? fallback.floatingRect };
} catch (e) {
this._dbg('localStorage settings load failed:', e.message);
}
}
return saved && typeof saved === 'object' ? saved : {};
}
_readSessionActiveSplit() {
try {
const session = this._getSessionStorage();
const value = session?.getItem?.(SESSION_ACTIVE_KEY);
const saved = value ? JSON.parse(value) : null;
const accountId = this._getCurrentAccountId();
return accountId && saved?.accountId === accountId && isDiscordId(saved.channelId) ? saved.channelId : null;
} catch (e) {
this._dbg('sessionStorage active split load failed:', e.message);
return null;
}
}
_writeSessionActiveSplit(channelId) {
try {
const session = this._getSessionStorage();
if (!session) return;
const accountId = this._getCurrentAccountId();
if (isDiscordId(channelId) && accountId) session.setItem(SESSION_ACTIVE_KEY, JSON.stringify({ accountId, channelId }));
else session.removeItem(SESSION_ACTIVE_KEY);
} catch (e) {
this._dbg('sessionStorage active split save failed:', e.message);
}
}
_serializeSettings() {
return {
currentWidth: this._settings.currentWidth,
debug: this._settings.debug,
hideParentChannelInSplit: this._settings.hideParentChannelInSplit === true,
rememberSplitPerServer: this._settings.rememberSplitPerServer === true,
rememberedSplits: normalizeRememberedSplits(this._settings.rememberedSplits),
// Default-off targets remain session-only and account-bound.
activeChannelId: null,
paneMode: this._settings.paneMode === 'floating' ? 'floating' : 'docked',
floatingRect: normalizeFloatingRect(this._settings.floatingRect),
};
}
_loadSettings() {
const saved = this._readSavedSettings();
this._hasSavedFloatingRect = hasSavedFloatingRect(saved.floatingRect);
return {
currentWidth: typeof saved.currentWidth === 'number' ? saved.currentWidth : DEFAULT_WIDTH,
debug: typeof saved.debug === 'boolean' ? saved.debug : false,
hideParentChannelInSplit: saved.hideParentChannelInSplit === true,
rememberSplitPerServer: saved.rememberSplitPerServer === true,
rememberedSplits: normalizeRememberedSplits(saved.rememberedSplits),
activeChannelId: this._readSessionActiveSplit(),
paneMode: saved.paneMode === 'floating' ? 'floating' : 'docked',
floatingRect: normalizeFloatingRect(saved.floatingRect),
};
}
_saveSettings() {
const settings = this._serializeSettings();
try { BdApi.Data.save(PLUGIN_NAME, SETTINGS_KEY, settings); } catch (e) { this._dbg('BdApi settings save failed:', e.message); }
try { this._getLocalStorage()?.setItem?.(LOCAL_STORAGE_KEY, JSON.stringify(settings)); } catch (e) { this._dbg('localStorage settings save failed:', e.message); }
}
getSettingsPanel() {
this._settings ??= this._loadSettings();
return BdApi.UI.buildSettingsPanel({
settings: [{ type: 'switch', id: 'hideParentChannelInSplit',
name: 'Hide parent channel in split-mode headers',
note: 'Hide the parent breadcrumb, not the selected thread title or Threads control.',
value: this._settings.hideParentChannelInSplit === true },
{ type: 'switch', id: 'rememberSplitPerServer', name: 'Remember split chat per server',
note: 'Restore this account’s split for each server, including after restarting Discord. Turning off clears this account’s remembered splits without closing the visible pane.',
value: this._settings.rememberSplitPerServer === true }],
onChange: (_category, id, value) => {
if (id === 'rememberSplitPerServer' && typeof value === 'boolean') {
this._syncPersistenceAccount();
this._settings.rememberSplitPerServer = value;
this._cancelSplitRestore();
if (!value) {
if (this._accountId) delete this._settings.rememberedSplits[this._accountId];
this._settings.activeChannelId = null;
this._writeSessionActiveSplit(null);
} else if (this._splitChannelId) this._rememberActiveSplit(this._splitChannelId);
this._saveSettings();
return;
}
if (id !== 'hideParentChannelInSplit' || typeof value !== 'boolean') return;
this._settings.hideParentChannelInSplit = value;
this._saveSettings();
this._syncHeaderTitles();
this._positionSplitSearch();
for (const record of this._breakouts.values()) record.refreshHeader?.();
},
});
}
_scheduleFloatingRectSave() {
if (this._floatingSaveTimer) return;
this._floatingSaveTimer = window.setTimeout(() => {
this._floatingSaveTimer = null;
if (!this._stopped && this._settings?.paneMode === 'floating') this._persistFloatingRect(this._settings.floatingRect);
}, 250);
}
// ─── Logging ─────────────────────────────────────────────────────────────────
_log(...a) { console.log('[BetterChat]', ...a); this._recordCrashEvent?.('log', { args: this._stringifyLogArgs(a) }); }
_dbg(...a) { if (this._settings?.debug) console.log('[BetterChat:dbg]', ...a); this._recordCrashEvent?.('debug', { args: this._stringifyLogArgs(a) }); }
_err(...a) { console.error('[BetterChat]', ...a); this._recordCrashEvent?.('error', { args: this._stringifyLogArgs(a) }); }
_stringifyLogArgs(args) {
return args.map(arg => {
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean' || arg == null) return arg;
try { return JSON.parse(JSON.stringify(arg)); } catch { return String(arg); }
});
}
_collectStatusSnapshot() {
const modules = Object.fromEntries(
Object.entries(this._modules || {}).map(([k, v]) => [k, v != null])
);
return {
ts: new Date().toISOString(),
pluginVersion: PLUGIN_VERSION,
stopped: this._stopped,
paneActive: !!this._paneEl,
paneAttached: this._isPaneAttached?.() ?? false,
splitChannelId: this._splitChannelId ?? null,
activeChannelId: this._settings?.activeChannelId ?? null,
selectedMainChannelId: this._getSelectedChannelId?.() ?? null,
selectedMainGuildId: this._getSelectedGuildId?.() ?? null,
splitGuildId: this._getChannelGuildId?.(this._splitChannelId) ?? null,
renderMode: this._renderMode,
nativeRenderVariant: this._nativeRenderVariant,
paneMode: this._settings?.paneMode ?? 'docked',
allowDuplicateChannelForDiagnostics: this._allowDuplicateChannelForDiagnostics,
modules,
};
}
_readCrashLog() {
try {
const parsed = JSON.parse(this._getLocalStorage()?.getItem?.(CRASH_LOG_KEY) || '[]');
return Array.isArray(parsed) ? parsed.slice(-MAX_CRASH_LOG_EVENTS) : [];
} catch {
return [];
}
}
_persistCrashLog() {
try { this._getLocalStorage()?.setItem?.(CRASH_LOG_KEY, JSON.stringify(this._crashLog.slice(-MAX_CRASH_LOG_EVENTS))); } catch { /* ignore */ }
}
_recordCrashEvent(type, detail = {}) {
if (!this._crashLog) this._crashLog = this._readCrashLog();
const event = {
ts: new Date().toISOString(),
type,
detail,
snapshot: this._collectStatusSnapshot?.() ?? null,
};
this._crashLog.push(event);
if (this._crashLog.length > MAX_CRASH_LOG_EVENTS) this._crashLog = this._crashLog.slice(-MAX_CRASH_LOG_EVENTS);
this._persistCrashLog();
return event;
}
_installCrashDiagnostics() {
if (this._crashHandlers) return;
this._crashLog = this._readCrashLog();
const onError = (event) => {
this._recordCrashEvent('window-error', {
message: event?.message,
source: event?.filename,
line: event?.lineno,
column: event?.colno,
error: event?.error ? this._stringifyLogArgs([event.error])[0] : null,
});
};
const onUnhandledRejection = (event) => {
this._recordCrashEvent('unhandled-rejection', {
reason: this._stringifyLogArgs([event?.reason])[0],
});
};
window.addEventListener('error', onError);
window.addEventListener('unhandledrejection', onUnhandledRejection);
this._crashHandlers = { onError, onUnhandledRejection };
this._recordCrashEvent('diagnostics-installed');
}
_removeCrashDiagnostics() {
if (!this._crashHandlers) return;
window.removeEventListener('error', this._crashHandlers.onError);
window.removeEventListener('unhandledrejection', this._crashHandlers.onUnhandledRejection);
this._crashHandlers = null;
this._recordCrashEvent('diagnostics-removed');
}
_getCrashLog() {
this._crashLog = this._readCrashLog();
return {
generatedAt: new Date().toISOString(),
currentStatus: this._collectStatusSnapshot(),
events: this._crashLog,
};
}
_formatCrashLog() {
return JSON.stringify(this._getCrashLog(), null, 2);
}
_toast(msg, type = 'info') {
try { BdApi.UI?.showToast?.(`[BetterChat] ${msg}`, { type }); } catch { /* ignore */ }
}
// ─── Module discovery ────────────────────────────────────────────────────────
discoverModules() {
const W = BdApi.Webpack;
if (!W) {
this._err('BdApi.Webpack unavailable — module discovery skipped');
return {};
}
const F = W.Filters;
const discovered = {};
const tryGet = (name, fn) => {
try {
const result = fn();
discovered[name] = result ?? null;
this._dbg(`[${result != null ? '✓' : '✗'}] ${name}`);
} catch (e) {
discovered[name] = null;
this._dbg(`[!] ${name}: ${e.message}`);
}
};
// React / ReactDOM — prefer BdApi's direct accessors, fall back to webpack
tryGet('React', () => BdApi.React ?? W.getModule(F.byKeys('createElement', 'useEffect', 'useRef')));
tryGet('ReactDOM', () => BdApi.ReactDOM ?? W.getModule(F.byKeys('createRoot', 'render', 'unmountComponentAtNode')));
// Discord's ErrorBoundary (optional — we create a fallback if absent)
tryGet('ErrorBoundary', () => W.getModule(m =>
typeof m === 'function' &&
m.prototype?.componentDidCatch != null &&
typeof m.prototype?.render === 'function'
));
// Stores — BD exposes these directly
tryGet('ChannelStore', () => W.getStore?.('ChannelStore'));
tryGet('GuildStore', () => W.getStore?.('GuildStore'));
tryGet('SelectedChannelStore', () => W.getStore?.('SelectedChannelStore'));
tryGet('SelectedGuildStore', () => W.getStore?.('SelectedGuildStore'));
tryGet('UserStore', () => W.getStore?.('UserStore'));
tryGet('PermissionStore', () => W.getStore?.('PermissionStore'));
tryGet('Permissions', () => W.getModule(F.byKeys('VIEW_CHANNEL', 'SEND_MESSAGES'), { searchExports: true }));
// Action modules
tryGet('ChatInputTypes', () => W.getModule(F.byKeys('FORM', 'SIDEBAR'), { searchExports: true }));
tryGet('NavigationUtils', () => W.getModule(m => typeof m?.transitionTo === 'function' && typeof m?.replaceWith === 'function'));
tryGet('ChannelActions', () => W.getModule(F.byKeys('selectChannel', 'selectPrivateChannel')));
// Patch targets
tryGet('ThreadGuardModule', () => W.getWithKey?.(F.byStrings('Thread must have a parent ID')));
// Internal chat component used by Discord sidebar-style chat surfaces
tryGet('SplitViewComponent', () => W.getModule(e =>
e?.$$typeof?.toString?.() === 'Symbol(react.memo)' &&
/chatInputType/.test(e.type?.toString?.()) &&
/filterAfterTimestamp/.test(e.type?.toString?.())
));
const headerLike = (m) => typeof m === 'function' && m.Icon && m.Title && m.Divider && m.Caret;
// MiniChat-style external popout modules. These are optional: BetterChat stays
// stable when any of them are missing, while Breakout Chat reports diagnostics.
tryGet('PopoutActions', () => W.getModule(F.byKeys('open', 'close', 'setAlwaysOnTop')));
tryGet('PopoutWindow', () => W.getModule(e => {
try {
const s = e?.render?.toString?.() || '';
return s.includes('guestWindow') && s.includes('windowKey');
} catch { return false; }
}));
tryGet('PopoutWindowStore', () => W.getStore?.('PopoutWindowStore'));
tryGet('Native', () => W.getModule(m => m?.setAlwaysOnTop?.toString?.()?.includes?.('window.setAlwaysOnTop')));
tryGet('Header', () => W.getModule(m => headerLike(m) && m.toString?.().includes('isAuthenticated')));
tryGet('Bar', () => W.getModule(m => headerLike(m) && !m.toString?.().includes('isAuthenticated')));
tryGet('IconUtils', () => W.getModule(F.byKeys('getGuildIconURL')));
tryGet('AckActions', () => W.getModule(F.byKeys('ack')));
tryGet('UserGuildSettingsStore', () => W.getStore?.('UserGuildSettingsStore'));
this._modules = discovered;
const found = Object.values(discovered).filter(v => v != null).length;
const total = Object.keys(discovered).length;
this._log(`Module discovery: ${found}/${total} found`);
if (this._settings?.debug) {
console.table(
Object.fromEntries(Object.entries(discovered).map(([k, v]) => [k, v != null ? '✓' : '✗']))
);
}
return discovered;
}
// ─── Optional native attachment scanning ─────────────────────────────────────
_warnAttachmentScan() {
if (this._attachmentScanWarned) return;
this._attachmentScanWarned = true;
// No message data or persistent diagnostics: this adapter is optional.
console.warn('[BetterChat] Optional native attachment scan bridge unavailable; chat remains native.');
}
_retainAttachmentScanTarget(channelId) {
const noop = () => {};
if (this._stopped || typeof channelId !== 'string' || !channelId) return noop;
let bridge = this._attachmentScanBridge;
if (!bridge) {
try {
const W = BdApi.Webpack;
// This is a service instance, NOT a Flux store. Fail closed on source
// drift rather than guessing a module ID or changing native routing.
const service = W?.getModule(m => {
try {
const actions = m?.actions;
if (!['SIDEBAR_VIEW_CHANNEL', 'MESSAGE_CREATE', 'MESSAGE_UPDATE',
'LOAD_MESSAGES_SUCCESS', 'LOAD_PINNED_MESSAGES_SUCCESS',
'LOAD_ARCHIVED_THREADS_SUCCESS'].every(key => typeof actions?.[key] === 'function')) return false;
const source = Function.prototype.toString.call(actions.MESSAGE_CREATE);
return ['getCurrentSidebarChannelId', 'optimistic', 'isPushNotification', 'jitter']
.every(token => source.includes(token));
} catch { return false; }
}, { searchExports: true });
const isDispatcher = m => typeof m?.subscribe === 'function' &&
typeof m?.unsubscribe === 'function' && typeof m?.dispatch === 'function';
const userDispatcher = this._modules.UserStore?._dispatcher;
const dispatcher = isDispatcher(userDispatcher) ? userDispatcher : W?.getModule(isDispatcher, { searchExports: true });
if (!service || !dispatcher) { this._warnAttachmentScan(); return noop; }
bridge = { service, dispatcher, targets: new Map(), subscriptions: [] };
this._attachmentScanBridge = bridge;
for (const type of ['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'LOAD_MESSAGES_SUCCESS']) {
const listener = event => {
if (this._stopped || this._attachmentScanBridge !== bridge) return;
if (type === 'MESSAGE_CREATE' && (event?.optimistic || event?.isPushNotification)) return;
const id = event?.channelId ?? event?.message?.channel_id;
this._queueAttachmentScan(bridge, id);
};
// Record before subscribing so a partially successful subscribe can
// still be rolled back if the dispatcher throws.
bridge.subscriptions.push([type, listener]);
dispatcher.subscribe(type, listener);
}
} catch {
this._removeAttachmentScanBridge();
this._warnAttachmentScan();
return noop;
}
}
let target = bridge.targets.get(channelId);
if (!target) {
target = { refs: 0, queued: false };
bridge.targets.set(channelId, target);
this._queueAttachmentScan(bridge, channelId);
}
target.refs++;
let released = false;
return () => {
if (released) return;
released = true;
if (--target.refs > 0) return;
if (bridge.targets.get(channelId) === target) bridge.targets.delete(channelId);
if (this._attachmentScanBridge === bridge && !bridge.targets.size) this._removeAttachmentScanBridge();
};
}
_queueAttachmentScan(bridge, channelId) {
const target = bridge.targets.get(channelId);
if (!target || target.queued) return;
target.queued = true;
// Run after Flux store processing, and coalesce a burst per active channel.
queueMicrotask(() => {
if (this._stopped || this._attachmentScanBridge !== bridge || bridge.targets.get(channelId) !== target) return;
target.queued = false;
try {
// Invoke ONLY the scoped service handler. Global dispatch would mutate
// sidebar routing. Native settings, scan requests, dedup and timers stay
// owned by Discord; never rewrite attachment flags or signed URLs.
bridge.service.actions.SIDEBAR_VIEW_CHANNEL({ channelId });
} catch { this._warnAttachmentScan(); }
});
}
_removeAttachmentScanBridge() {
const bridge = this._attachmentScanBridge;
this._attachmentScanBridge = null;
if (!bridge) return;
bridge.targets.clear(); // invalidates queued work even on same-channel reopen
for (const [type, listener] of bridge.subscriptions) {
try { bridge.dispatcher.unsubscribe(type, listener); } catch { this._warnAttachmentScan(); }
}
}
// ─── CSS ─────────────────────────────────────────────────────────────────────
installStyles() {
const w = this._settings.currentWidth;
BdApi.DOM.addStyle(PLUGIN_NAME, `
:root { --ssv-split-width: ${w}px; }
.ssv-pane {
display: flex;
flex-direction: row;
width: var(--ssv-split-width);
min-width: ${MIN_WIDTH}px;
max-width: 80vw;
flex: 0 0 auto;
position: relative;
background: var(--background-primary, #313338);
border-left: 1px solid var(--background-modifier-accent, #3f4147);
overflow: hidden;
}
/* Keep the native virtual scroller measured while removing the pane from
the layout. display:none reports a zero viewport and loses its anchor. */
.ssv-pane[data-ssv-suspended="true"] {
position: fixed !important;
left: -100000px !important;
top: 0 !important;
width: var(--ssv-suspended-width) !important;
height: var(--ssv-suspended-height) !important;
max-width: none;
box-sizing: border-box;
visibility: hidden;
pointer-events: none;
}
.ssv-pane:not(.ssv-floating) {
box-sizing: border-box;
max-width: none; /* bounded by the verified host, not the viewport */
min-height: 0;
align-self: stretch;
background: var(--background-base-low, var(--background-primary, #313338));
border: 1px solid var(--border-subtle, var(--background-modifier-accent, #3f4147));
border-radius: var(--radius-sm, 8px);
}
.ssv-pane:not(.ssv-floating) > .ssv-pane-inner {
min-width: 0;
margin-left: 0; /* the 4px resize target overlays one divider, not a gutter */
padding-top: max(0px, calc(var(--ssv-header-offset, 0px) - 1px));
}
.ssv-pane:not(.ssv-floating) .ssv-pane-header {
box-sizing: border-box;
height: var(--ssv-header-height, 49px);
min-width: 0;
padding: 0 8px 0 16px;
background: var(--background-base-low, var(--background-primary, #313338));
border-bottom-color: var(--border-subtle, var(--background-modifier-accent, #3f4147));
font-family: var(--font-primary, sans-serif);
}
.ssv-pane:not(.ssv-floating) .ssv-pane-header-title { min-width: 0; }
.ssv-pane:not(.ssv-floating) .ssv-pane-header-btn { width: 32px; height: 32px; }
.ssv-pane .ssv-pane-header-btn:focus-visible {
outline: 2px solid var(--focus-primary, #00a8fc);
outline-offset: 2px;
}
/* Grid changes visual order only; React retains all native node ownership.
Invisible native actions overlap More's cell, retaining real anchors
for Discord popouts instead of display:none or offscreen/0,0 controls. */
[data-ssv-toolbar="row"] {
display: grid !important;
grid-template-columns: 32px minmax(0, 1fr) 32px 32px;
grid-template-rows: 32px;
align-items: center;
column-gap: 8px;
}
[data-ssv-toolbar="controls"] { display: contents !important; }
[data-ssv-toolbar="title"] { grid-area: 1 / 2; min-width: 0; max-width: 100%; overflow: hidden; }
[data-ssv-toolbar="title"] [class*="titleWrapper_"],
[data-ssv-toolbar="title"] h1, [data-ssv-toolbar="title"] h2 {
min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
[data-ssv-toolbar="title"] [class*="titleWrapper_"] { flex: 0 1 auto; max-width: 28ch; }
[data-ssv-toolbar="title"] h1, [data-ssv-toolbar="title"] h2 { display: block; }
[data-ssv-parent-hidden="true"] { display: none !important; }
[data-ssv-toolbar="title"]::after { display: none; }
[data-ssv-toolbar="search"] { position: fixed !important; left: var(--ssv-search-x); top: var(--ssv-search-y); width: var(--ssv-search-width) !important; height: 32px; min-width: 0 !important; margin: 0 !important; display: flex; align-items: center; z-index: 101; }
.ssv-pane-header-search-slot { flex: 0 1 180px; min-width: 96px; height: 32px; }
.ssv-floating .ssv-pane-header-search-slot { display: none; }
.ssv-pane-header-controls { display: flex; flex: 0 0 auto; gap: 4px; margin-left: auto; }
.ssv-header-unavailable { display: flex; gap: 4px; }
.ssv-pane-header-controls[data-ssv-ready="true"] .ssv-header-unavailable { display: none; }
.ssv-pane-header-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ssv-pane-header-parent { color: var(--text-muted); }
[data-ssv-toolbar="search"] [class*="search_"] { width: 100% !important; min-width: 0 !important; max-width: 100%; }
[data-ssv-toolbar="search"] [class*="searchBar_"] { width: 100% !important; box-sizing: border-box; }
[data-ssv-toolbar="threads"] { grid-area: 1 / 1; margin: 0 !important; }
[data-ssv-thread-inline="true"] { grid-template-columns: minmax(0, 1fr) 32px 32px; }
[data-ssv-thread-inline="true"] [data-ssv-toolbar="title"] { grid-area: 1 / 1; }
[data-ssv-toolbar="pins"] { grid-area: 1 / -3 / auto / -2; margin: 0 !important; }
[data-ssv-toolbar="thread-slot"] { visibility: hidden; }
[data-ssv-thread-inline="true"] [data-ssv-toolbar="threads"] {
position: fixed; left: var(--ssv-threads-x); top: var(--ssv-threads-y);
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; z-index: 101;
}
[data-ssv-toolbar="source"], [data-ssv-toolbar="more"] {
grid-area: 1 / -2 / auto / -1;
width: 32px !important;
height: 32px !important;
margin: 0 !important;
}
[data-ssv-toolbar="source"] { opacity: 0 !important; pointer-events: none !important; }
[data-ssv-toolbar="more"] {
padding: 0; border: 0; border-radius: 4px; background: transparent;
color: var(--interactive-normal, #b5bac1); cursor: pointer;
font: 24px/1 var(--font-primary, sans-serif);
}
[data-ssv-toolbar="more"]:hover { color: var(--interactive-hover, #dbdee1); background: var(--background-modifier-hover); }
[data-ssv-toolbar="more"]:focus-visible { outline: 2px solid var(--focus-primary, #00a8fc); outline-offset: 2px; }
.ssv-pane.ssv-floating {
position: fixed;
z-index: 10000;
min-height: ${MIN_FLOATING_HEIGHT}px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
border: 1px solid var(--background-modifier-accent, #3f4147);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.ssv-pane.ssv-floating .ssv-pane-header {
cursor: grab;
}
.ssv-pane.ssv-floating.ssv-floating-dragging .ssv-pane-header {
cursor: grabbing;
}
.ssv-breakout-root {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--background-primary, #313338);
}
.ssv-breakout-root > * {
min-width: 0;
min-height: 0;
}
.ssv-breakout-header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 10px 0 14px;
border-bottom: 1px solid var(--background-modifier-accent, rgba(255,255,255,0.08));
background: var(--background-secondary, #2b2d31);
color: var(--header-primary, #f2f3f5);
font: 600 14px/1.2 var(--font-primary, sans-serif);
user-select: none;
}
section.ssv-breakout-header { display: block; padding: 0 8px; }
section.ssv-breakout-header > div { width: 100%; min-width: 0; height: 48px; }
.ssv-breakout-header [class*="children_"] { min-width: 0; flex: 1 1 auto; }
.ssv-breakout-header [class*="toolbar_"] { flex: 0 0 auto; gap: 4px; }
.ssv-breakout-header [class*="searchBar_"] { width: 130px; }
.ssv-breakout-root .ssv-pane-header-btn { flex: 0 0 auto; width: 32px; height: 32px; border: 0; padding: 0; border-radius: 4px; background: transparent; color: var(--interactive-normal); cursor: pointer; font-size: 20px; }
.ssv-breakout-root .ssv-pane-header-btn:hover { background: var(--background-modifier-hover); }
.ssv-breakout-header [class*="children_"] { gap: 8px; }
.ssv-breakout-search { width: 150px; min-width: 80px; height: 28px; border: 1px solid var(--background-modifier-accent); border-radius: 6px; padding: 0 8px; color: var(--text-normal); background: var(--background-tertiary); }
.ssv-breakout-search-results {
position: absolute; top: 48px; right: 0; bottom: 0; z-index: 5;
width: min(440px, 100%); display: flex; flex-direction: column;
background: var(--background-secondary); box-shadow: -3px 0 12px #0004;
}
.ssv-breakout-search-results > :last-child { flex: 1; min-height: 0; width: 100%; }
.ssv-breakout-title-stack {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ssv-breakout-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ssv-breakout-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted, #949ba4);
font-size: 11px;
font-weight: 500;
}
.ssv-breakout-close {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
color: var(--interactive-normal, #b5bac1);
background: transparent;
font: 18px/1 var(--font-primary, sans-serif);
cursor: pointer;
}
.ssv-breakout-close:hover {
color: var(--interactive-hover, #dbdee1);
background: var(--background-modifier-hover, rgba(255,255,255,0.08));
}
.ssv-breakout-body {
flex: 1;
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.ssv-breakout-body > * {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
}
.ssv-breakout-diagnostic {
padding: 16px;
color: var(--text-normal, #dbdee1);
font: 13px/1.4 var(--font-primary, sans-serif);
white-space: pre-wrap;
}
.ssv-floating-resize-corner {
display: none;
}
.ssv-pane.ssv-floating .ssv-floating-resize-corner {
display: block;
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
z-index: 11;
background: linear-gradient(135deg, transparent 50%, var(--interactive-muted, #6d6f78) 50%);
opacity: 0.65;
}
.ssv-pane.ssv-floating .ssv-floating-resize-corner:hover,
.ssv-pane.ssv-floating.ssv-floating-resizing .ssv-floating-resize-corner {
opacity: 1;
}
.ssv-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
z-index: 10;
background: transparent;
transition: background 0.15s;
}
.ssv-resize-handle:hover,
.ssv-resize-handle.ssv-resizing {
background: var(--brand-experiment, #5865f2);
}
.ssv-pane.ssv-floating > .ssv-resize-handle {
display: none;
}
.ssv-pane-inner {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin-left: 4px;
}
.ssv-pane-header {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
height: 48px;
flex: 0 0 auto;
background: var(--background-primary, #313338);
border-bottom: 1px solid var(--background-modifier-accent, #3f4147);
}
.ssv-pane-header-title {
flex: 1 1 120px;
min-width: 40px;
max-width: 28ch;
font-size: 15px;
font-weight: 600;
color: var(--header-primary, #f2f3f5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ssv-pane-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--interactive-normal, #b5bac1);
cursor: pointer;
border-radius: 4px;
font-size: 18px;
flex: 0 0 auto;
padding: 0;
line-height: 1;
}
.ssv-pane-header-btn:hover {
color: var(--interactive-hover, #dcddde);
background: var(--background-modifier-hover, rgba(79,84,92,0.16));
}
.ssv-pane-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
min-width: 0;
min-height: 0;
gap: 0;
color: var(--text-muted, #949ba4);
font-size: 13px;
}
.ssv-pane-body.ssv-placeholder {
align-items: center;
justify-content: center;
}
.ssv-pane-body.ssv-native > * {
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 0;
}
.ssv-pane-body.ssv-native,
.ssv-pane-body.ssv-native :is([class*="chat"], [class*="chatContent"], [class*="messagesWrapper"], [class*="scroller"], [class*="form"], [role="log"]) {
max-width: none !important;
width: 100% !important;
min-width: 0 !important;
box-sizing: border-box;
}
.ssv-pane-body.ssv-native :is([class*="chat"], [class*="chatContent"], [class*="messagesWrapper"]) {
flex: 1 1 auto;
align-self: stretch;
}
.ssv-pane.ssv-duplicate-main-channel .ssv-pane-body.ssv-native :is(
[class*="channelTextArea"],
[class*="typing"]
) {
visibility: hidden !important;
pointer-events: none !important;
}
.ssv-pane.ssv-duplicate-main-channel .ssv-pane-body.ssv-native :is(
[class*="channelTextArea"],
[class*="typing"]
) * {
pointer-events: none !important;
}
.ssv-pane.ssv-duplicate-main-channel .ssv-pane-body {
position: relative;
}
.ssv-pane.ssv-duplicate-main-channel .ssv-pane-body::after {
content: 'Same channel — BetterChat keeps its own scroll; send from the focused main composer';
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
padding: 6px 9px;
border-radius: 6px;
background: color-mix(in srgb, var(--background-floating, #111214) 88%, transparent);
color: var(--text-muted, #949ba4);
font-size: 11px;
line-height: 1.3;
text-align: center;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
z-index: 20;
opacity: 0.72;
}
.ssv-pane.ssv-native-composerless .ssv-pane-body.ssv-native :is(
[class*="channelTextArea"],
[class*="typing"],
form[class*="form"]
) {
display: none !important;
pointer-events: none !important;
}
.ssv-pane.ssv-native-composerless .ssv-pane-body {
position: relative;
}
.ssv-pane.ssv-native-composerless .ssv-pane-body::after {
content: 'Composerless fallback active; writable sidebar mode is the default in v${PLUGIN_VERSION}';
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
padding: 6px 9px;
border-radius: 6px;
background: color-mix(in srgb, var(--background-floating, #111214) 88%, transparent);
color: var(--text-muted, #949ba4);
font-size: 11px;
line-height: 1.3;
text-align: center;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
z-index: 20;
opacity: 0.72;
}
.ssv-placeholder-icon {
font-size: 32px;
opacity: 0.4;
}
.ssv-placeholder-heading {
font-size: 14px;
font-weight: 600;
color: var(--header-secondary, #b5bac1);
}
.ssv-placeholder-detail {
text-align: center;
line-height: 1.5;
white-space: pre-line;
max-width: 220px;
}
.ssv-placeholder-diagnostic {
font-family: monospace;
font-size: 11px;
opacity: 0.7;
margin-top: 8px;
}
body.ssv-resizing,
body.ssv-resizing *,
body.ssv-floating-resizing,
body.ssv-floating-resizing * {
user-select: none !important;
}
body.ssv-resizing,
body.ssv-resizing * {
cursor: ew-resize !important;
}
body.ssv-floating-resizing,
body.ssv-floating-resizing * {
cursor: nwse-resize !important;
}
`);
}
removeStyles() {
BdApi.DOM.removeStyle(PLUGIN_NAME);
}
// ─── Layout container ────────────────────────────────────────────────────────
_queryMainDiscordElement(selectors) {
for (const selector of selectors) {
const matches = Array.from(document.querySelectorAll(selector));
const match = matches.find(el => !el.closest('[data-ssv="pane"]'));
if (match) return match;
}
return null;
}
_isNativeLayoutElement(el) {
if (!(el instanceof HTMLElement) || el.ownerDocument !== document || !el.isConnected) return false;
if (el.closest('[data-ssv], .ssv-breakout-root, [role="dialog"], [role="menu"], [aria-modal="true"], [class*="layerContainer"], [class*="modal"], [class*="popout"], [class*="standardSidebarView"]')) return false;
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
_hasGridArea(el, name) {
// Chromium can serialize a named area as either "page" or four longhands.
const parts = getComputedStyle(el).gridArea?.split('/').map(part => part.trim());
return !!parts?.length && parts.every(part => part === name);
}
_discoverDockedLayout() {
const anchors = Array.from(document.querySelectorAll('[class*="chatContent"], [class*="chat-"][class*="content"], [class*="messagesWrapper"]'))
.filter(el => this._isNativeLayoutElement(el));
// chatContent and its messagesWrapper are one surface, not two panes.
const surfaces = anchors.filter(el => !anchors.some(other => other !== el && other.contains(el)));
const nodes = new Set();
for (const anchor of surfaces) {
for (let node = anchor.parentElement; node && node !== document.body; node = node.parentElement) {
if (this._hasGridArea(node, 'page') || String(node.className).includes('page_')) nodes.add(node);
}
}
const candidates = Array.from(nodes, host => {
const style = getComputedStyle(host);
const rect = host.getBoundingClientRect();
let reason = null;
if (!this._isNativeLayoutElement(host)) reason = 'not-native-content';
else if (!this._hasGridArea(host, 'page') || !getComputedStyle(host.parentElement).display.includes('grid')) reason = 'not-grid-page';
else if (style.display !== 'flex' || style.flexDirection !== 'row' || ['fixed', 'absolute'].includes(style.position)) reason = 'not-content-row';
else if (!surfaces.length || !surfaces.every(anchor => host.contains(anchor))) reason = 'incomplete-native-row';
// The bar is a sibling in a grid ancestor, not a fixed y/height heuristic.
// A hidden OS/system bar is not a fabricated in-client title bar.
const bars = new Set();
for (let node = host; node && node !== document.body; node = node.parentElement) {
for (const child of node.children) {
if (this._isNativeLayoutElement(child) && this._hasGridArea(child, 'titleBar')) bars.add(child);
}
}
if (!reason && (bars.size > 1 || Array.from(bars).some(bar => host.contains(bar) || rect.top < bar.getBoundingClientRect().bottom - 1))) reason = 'unsafe-titlebar-boundary';
return { host, globalBar: bars.size === 1 ? Array.from(bars)[0] : null, reason };
});
const valid = candidates.filter(candidate => !candidate.reason);
const chosen = valid.length === 1 ? valid[0] : null;
const nativeRow = chosen && Array.from(chosen.host.children).find(row =>
this._isNativeLayoutElement(row) && surfaces.every(surface => row.contains(surface)) &&
getComputedStyle(row).display === 'flex' && getComputedStyle(row).flexDirection === 'row');
return {
layout: chosen ? { host: chosen.host, nativeRow, globalBar: chosen.globalBar, mainHeader: this._findNativeHeader(chosen.host, surfaces[0]), surfaces, surfaceCount: surfaces.length, ...this._dockedSidebars(chosen.host, surfaces) } : null,
candidates,
reason: chosen ? null : valid.length > 1 ? 'ambiguous-page-host' : surfaces.length ? 'no-safe-page-host' : 'no-native-chat',
};
}
_findNativeHeader(host, anchor) {
// Measure the outer toolbar SECTION, never its 32px inner icon row.
for (let scope = anchor?.parentElement; scope; scope = scope.parentElement) {
const headers = Array.from(scope.querySelectorAll('section')).filter(section => {
if (!this._isNativeLayoutElement(section) || section.contains(anchor) || anchor.contains(section)) return false;
if (!section.querySelector('[class*="toolbar_"], [role="toolbar"]')) return false;
const r = section.getBoundingClientRect(), a = anchor.getBoundingClientRect();
return r.top >= host.getBoundingClientRect().top && r.bottom <= a.top + 1 && r.left < a.right && r.right > a.left;
});
if (headers.length === 1) return headers[0];
if (headers.length > 1 || scope === host) break;
}
return null;
}
_resolveDockedLayout() {
return this._discoverDockedLayout().layout;
}
_findLayoutContainer() {
return this._resolveDockedLayout()?.host ?? null;
}
_dockedSidebars(host, surfaces) {
const sidebars = Array.from(host.querySelectorAll('[class*="membersWrap"], [class*="searchResultsWrap"]'))
.filter(el => this._isNativeLayoutElement(el));
const members = sidebars.filter(el => String(el.className).includes('membersWrap'));
// Only the one native members lane paired with a chat surface is movable.
// Multiple candidates, nested sidebar lookalikes and transformed fixed-position
// containing blocks are ambiguous: leave native DOM/styles alone and suspend.
let sidebarsSafe = members.length <= 1 && !sidebars.some(el =>
sidebars.some(other => other !== el && other.contains(el)) || surfaces.some(surface => el.contains(surface)));
const member = members.length === 1 ? members[0] : null;
if (member) {
const parent = member.parentElement, style = getComputedStyle(parent);
sidebarsSafe &&= style.display === 'flex' && style.flexDirection === 'row' &&
surfaces.some(surface => parent.contains(surface));
for (let node = parent; node && sidebarsSafe; node = node.parentElement) {
const s = getComputedStyle(node);
if ([s.transform, s.perspective, s.filter, s.backdropFilter].some(value => value && value !== 'none') ||
/paint|layout|strict|content/.test(s.contain || '') || /transform|perspective|filter/.test(s.willChange || '')) sidebarsSafe = false;
}
}
return { sidebars, member, sidebarsSafe };
}
_dockedNativeThreads(layout) {
return Array.from(layout.host.querySelectorAll('[class*="chatLayerWrapper"]')).flatMap(wrapper => {
const surfaces = layout.surfaces.filter(surface => wrapper.contains(surface));
// Native overlay mode has a zero-width absolute wrapper with a visible
// full-width chat child. Visibility belongs to the chat, not its wrapper.
if (!surfaces.length) return [];
const frames = surfaces.map(surface => {
let width = surface.getBoundingClientRect().width;
for (let node = surface.parentElement; node && node !== wrapper; node = node.parentElement) {
width = Math.max(width, node.getBoundingClientRect().width);
}
return width;
});
const width = Math.max(wrapper.getBoundingClientRect().width, ...frames);
const style = getComputedStyle(wrapper), row = layout.nativeRow;
let gap = Math.max(0, parseFloat(style.marginLeft) || 0, parseFloat(style.marginRight) || 0,
row ? parseFloat(getComputedStyle(row).columnGap) || 0 : 0);
const main = layout.surfaces.find(surface => !wrapper.contains(surface));
let mainFrame = main;
while (mainFrame?.parentElement && mainFrame.parentElement !== row && mainFrame.parentElement !== layout.host) mainFrame = mainFrame.parentElement;
if (mainFrame && row?.contains(mainFrame)) {
// Discord can reserve the absolute preview with main-chat margin rather
// than flex gap. Measure that reservation, never a historical 450/480px.
gap = Math.max(gap, (parseFloat(getComputedStyle(mainFrame).marginRight) || 0) - width);
const r = wrapper.getBoundingClientRect(), m = mainFrame.getBoundingClientRect();
if (r.width > 0 && wrapper.offsetParent === row && r.right <= row.getBoundingClientRect().right + 1) gap = Math.max(gap, r.left - m.right);
}
const safe = row?.contains(wrapper) && style.position !== 'fixed' &&
(style.position !== 'absolute' || wrapper.offsetParent === layout.host || wrapper.offsetParent === row);
return [{ wrapper, width, gap, safe }];
});
}
_dockedWidth(layout) {
if (!layout || !layout.sidebarsSafe) return null;
const conflict = this._dockedMemberConflict;
if (conflict?.host === layout.host && conflict.member === layout.member && conflict.pane === this._paneEl) return null;
// Measure native panels, not their historical/default widths. The owned member
// lane is border-box so measuring it again cannot grow its width every pass.
const sidebarWidth = layout.sidebars.reduce((sum, el) => {
const style = getComputedStyle(el);
return sum + el.getBoundingClientRect().width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0);
}, 0);
const width = layout.host.clientWidth - sidebarWidth;
// Preserve the preferred width while native panels temporarily consume space.
const threads = this._dockedNativeThreads(layout);
if (threads.some(thread => !thread.safe)) return null;
// Budget against the full host, never the already-shrunken native row or the
// visible split. Hiding the split therefore cannot immediately reopen it.
const nativeWidth = Math.max(MIN_WIDTH * layout.surfaceCount,
MIN_WIDTH + threads.reduce((sum, thread) => sum + thread.width + thread.gap, 0));
const cap = Math.floor(Math.min(width - nativeWidth, layout.surfaceCount >= 2 ? width / 3 : Infinity));
if (cap < MIN_WIDTH) return null;
const preferred = Number.isFinite(this._settings?.currentWidth) ? this._settings.currentWidth : DEFAULT_WIDTH;
return Math.max(MIN_WIDTH, Math.min(preferred, cap));
}
_syncDockedMembers(layout) {
const member = layout?.member, pane = this._paneEl;
let owned = this._dockedMembers;
if (owned && (owned.host !== layout?.host || owned.member !== member || owned.parent !== member?.parentElement || owned.pane !== pane)) {
this._restoreDockedMembers(false); owned = null;
}
if (!member) return true;
if (owned?.styles.some(entry => entry.el.style.getPropertyValue(entry.name) !== entry.written ||
entry.el.style.getPropertyPriority(entry.name) !== '')) {
// A later Discord/theme write wins. Stay suspended for this exact lane rather
// than reacquiring and overwriting it on the next ResizeObserver delivery.
this._dockedMemberConflict = { host: layout.host, member, pane };
this._restoreDockedMembers(false);
return false;
}
const host = layout.host.getBoundingClientRect(), top = member.parentElement.getBoundingClientRect().top;
const width = member.getBoundingClientRect().width;
const values = [
[member, 'position', 'fixed'], [member, 'left', `${host.right - width}px`],
[member, 'top', `${top}px`], [member, 'width', `${width}px`],
[member, 'height', `${Math.max(0, host.bottom - top)}px`], [member, 'box-sizing', 'border-box'],
[pane, 'margin-right', `${width}px`],
];
if (!owned) {
owned = this._dockedMembers = { host: layout.host, member, parent: member.parentElement, pane,
styles: values.map(([el, name]) => ({ el, name, value: el.style.getPropertyValue(name), priority: el.style.getPropertyPriority(name) })) };
}
values.forEach(([el, name, value], i) => {
const entry = owned.styles[i];
if (el.style.getPropertyValue(name) !== value || el.style.getPropertyPriority(name) !== '') el.style.setProperty(name, value);
entry.written = value;
});
return true;
}
_restoreDockedMembers(clearConflict = true) {
const owned = this._dockedMembers;
this._dockedMembers = null;
if (clearConflict) this._dockedMemberConflict = null;
for (const entry of owned?.styles || []) {
const style = entry.el.style;
if (style.getPropertyValue(entry.name) !== entry.written || style.getPropertyPriority(entry.name) !== '') continue;
if (entry.value) style.setProperty(entry.name, entry.value, entry.priority);
else style.removeProperty(entry.name);
}
}
_applyDockedLayout(container = null) {
if (this._stopped || this._settings?.paneMode === 'floating' || !this._paneEl) {
this._restoreDockedMembers();
this._restoreCompactToolbar();
return false;
}
const layout = this._resolveDockedLayout();
if (!layout || (container && container !== layout.host) || this._paneEl.parentElement !== layout.host) {
this._removeDockedGeometryObserver();
this._restoreNativeRowPosition();
this._restoreDockedMembers();
this._restoreCompactToolbar();
this._setDockedSuspended(true);
return false;
}
let width = this._dockedWidth(layout);
if (!this._syncDockedMembers(width === null ? null : layout)) width = null;
this._syncNativeRowPosition(width === null ? null : layout);
this._setDockedSuspended(width === null);
this._syncCompactToolbar(width === null ? null : layout);
if (width !== null) this._applyWidth(width);
const r = layout.mainHeader?.getBoundingClientRect();
// Include any native parent top border as space before the header, rather
// than stretching the header itself. Both outer boundaries then line up.
this._setPaneStyle('--ssv-header-offset', `${r ? Math.max(0, r.top - layout.host.getBoundingClientRect().top) : 0}px`);
this._setPaneStyle('--ssv-header-height', `${r ? r.height : 49}px`);
this._observeDockedGeometry(layout);
this._positionSplitSearch();
return width !== null;
}
// Native nodes stay in their React-owned parents. Only known English header
// shapes opt in; unknown actions/localizations retain the untouched toolbar.
_compactToolbarShape(layout) {
const header = layout?.mainHeader;
if (this._stopped || !this._splitChannelId || this._settings?.paneMode === 'floating' ||
!this._paneEl?.isConnected || this._paneEl.parentElement !== layout?.host ||
this._paneEl.getAttribute('data-ssv-suspended') === 'true' || this._paneEl.style.display === 'none' || !header || !this._isNativeLayoutElement(header)) return null;
if (typeof BdApi.ContextMenu?.open !== 'function' || typeof BdApi.ContextMenu?.buildMenu !== 'function' ||
typeof BdApi.ContextMenu?.close !== 'function' || typeof BdApi.React?.createElement !== 'function' ||
typeof BdApi.React?.useEffect !== 'function') return null;
const channelId = this._getSelectedChannelId(), channel = this._getChannel(channelId);
if (!channelId || !channel?.guild_id || !SPLIT_TARGET_TYPES.has(channel.type)) return null;
// Native results belong to the current guild. Cross-guild layouts retain
// untouched native controls rather than presenting incorrectly scoped search.
if (this._getChannel(this._splitChannelId)?.guild_id !== channel.guild_id) return null;
const upper = Array.from(header.children).find(el => String(el.className).includes('upperContainer_'));
if (!upper || upper.children.length !== 2) return null;
const title = Array.from(upper.children).find(el => String(el.className).includes('children_'));
const toolbar = Array.from(upper.children).find(el => String(el.className).includes('toolbar_'));
if (!title || !toolbar) return null;
let search = null, threads = null;
const actions = [];
const seen = new Set();
for (const el of toolbar.children) {
if (el === this._compactToolbar?.button) continue;
const label = el.getAttribute('aria-label');
if (el.matches('button, [role="button"]')) {
let key;
if (label === 'Threads') { if (threads) return null; threads = el; continue; }
if (label === 'Notification Settings') key = 'notifications';
else if (label === 'Pinned Messages') key = 'pins';
else if (label === 'Show Member List' || label === 'Hide Member List') key = 'members';
else if (label === 'More' && THREAD_TYPES.has(channel.type)) key = 'thread';
if (!key || seen.has(key)) return null;
seen.add(key); actions.push({ key, el, label });
} else if (!search && /(?:^|\s)search__/.test(String(el.className)) && el.querySelector('[class*="search_"]')) {
search = el;
} else return null;
}
if (!search || !seen.has('notifications') || !seen.has('pins') || !seen.has('members')) return null;
// In thread breadcrumbs the second channel icon is decoration, not an action.
// Keep its space and put the existing native Threads button over that slot.
const icons = Array.from(title.children).filter(el => String(el.className).includes('channelIcon_'));
const threadSlot = threads && THREAD_TYPES.has(channel.type) && icons.length === 2 &&
!icons[1].matches('button, [role="button"]') && icons[1].querySelector('svg[aria-hidden="true"]') ? icons[1] : null;
return { header, upper, title, toolbar, search, threads, threadSlot, actions, channelId, splitChannelId: this._splitChannelId };
}
_syncCompactToolbar(layout) {
const shape = this._compactToolbarShape(layout);
const old = this._compactToolbar;
const same = shape && old && ['header', 'upper', 'title', 'toolbar', 'search', 'threads', 'threadSlot', 'channelId', 'splitChannelId'].every(k => shape[k] === old[k]) &&
shape.actions.length === old.actions.length && shape.actions.every((a, i) => a.el === old.actions[i].el && a.label === old.actions[i].label);
if (same && old.button.parentElement === shape.toolbar) {
this._syncHeaderTitles();
this._syncSplitHeaderControls(old);
return;
}
this._restoreCompactToolbar();
if (!shape) return;
const marks = [[shape.upper, 'row'], [shape.title, 'title'], [shape.toolbar, 'controls'], [shape.search, 'search']];
if (shape.threads) marks.push([shape.threads, 'threads']);
if (shape.threadSlot) marks.push([shape.threadSlot, 'thread-slot']);
shape.actions.forEach(a => marks.push([a.el, a.key === 'pins' ? 'pins' : 'source']));
if (marks.some(([el]) => el.getAttribute('data-ssv-toolbar') !== null)) return; // foreign ownership
if (!this._installSplitSearchScope()) return;
const state = this._compactToolbar = { ...shape, owned: [], observer: null, frame: null, timer: null, menuOpen: false, handingOff: false };
const own = (el, name, value) => {
state.owned.push({ el, name, before: el.getAttribute(name), value });
el.setAttribute(name, value);
};
marks.forEach(([el, value]) => own(el, 'data-ssv-toolbar', value));
if (shape.threadSlot) own(shape.upper, 'data-ssv-thread-inline', 'true');
for (const { el } of shape.actions.filter(a => a.key !== 'pins')) {
own(el, 'tabindex', '-1'); own(el, 'aria-hidden', 'true');
}
const button = state.button = document.createElement('button');
button.type = 'button';
button.setAttribute('data-ssv-toolbar', 'more');
button.setAttribute('aria-label', 'More');
button.setAttribute('aria-haspopup', 'menu');
button.setAttribute('aria-expanded', 'false');
button.title = 'More channel actions'; button.textContent = '⋯';
button.addEventListener('click', event => this._openCompactToolbarMenu(state, event));
button.addEventListener('keydown', event => {
if (event.key === 'ArrowDown') { event.preventDefault(); this._openCompactToolbarMenu(state, event); }
if (event.key === 'Escape' && state.menuOpen) { event.preventDefault(); BdApi.ContextMenu.close(); }
// Enter/Space use the native button click, not a second synthetic action.
});
shape.toolbar.appendChild(button);
this._syncHeaderTitles();
this._syncSplitHeaderControls(state);
this._positionThreadsButton();
this._positionSplitSearch();
if (typeof MutationObserver === 'function') {
state.observer = new MutationObserver(() => {
if (this._compactToolbar !== state || state.frame !== null) return;
state.frame = window.requestAnimationFrame(() => {
state.frame = null;
if (this._compactToolbar === state) this._syncCompactToolbar(this._resolveDockedLayout());
});
});
state.observer.observe(shape.header, { subtree: true, childList: true, attributes: true,
attributeFilter: ['aria-label', 'aria-disabled', 'disabled'] });
}
}
_compactToolbarIsCurrent(state) {
if (this._compactToolbar !== state || !state.button.isConnected) return false;
const shape = this._compactToolbarShape(this._resolveDockedLayout());
return !!shape && ['header', 'upper', 'title', 'toolbar', 'search', 'threads', 'threadSlot', 'channelId', 'splitChannelId'].every(k => shape[k] === state[k]) &&
shape.actions.length === state.actions.length && shape.actions.every((a, i) => a.el === state.actions[i].el && a.label === state.actions[i].label);
}
_ownHeaderAttribute(state, el, name, value) {
let owned = state.owned.find(o => o.el === el && o.name === name);
if (owned && el.getAttribute(name) !== owned.value) return; // later owner wins
if (!owned) {
owned = { el, name, before: el.getAttribute(name), value };
state.owned.push(owned);
}
owned.value = value;
if (el.getAttribute(name) !== value) el.setAttribute(name, value);
}
_syncHeaderTitles() {
const state = this._compactToolbar;
const hide = this._settings?.hideParentChannelInSplit === true;
if (state) {
for (const el of state.title.querySelectorAll('h1, h2')) {
if (el.textContent) this._ownHeaderAttribute(state, el, 'title', el.textContent);
}
const parent = state.title.querySelector('[class*="parentChannelName_"]');
const wrapper = parent?.closest('[class*="titleWrapper_"]');
const separator = Array.from(state.title.children).find(el => (el.getAttribute?.('class') || String(el.className)).includes('caret_'));
if (wrapper && separator) {
for (const el of [wrapper, separator]) this._ownHeaderAttribute(state, el, 'data-ssv-parent-hidden', String(hide));
const children = Array.from(state.title.children);
const icon = children.slice(0, children.indexOf(wrapper)).find(el =>
String(el.className).includes('channelIcon_') && !el.matches('button, [role="button"]'));
if (icon) this._ownHeaderAttribute(state, icon, 'data-ssv-parent-hidden', String(hide));
}
}
const channel = this._getChannel(this._splitChannelId);
if (!this._paneTitle || !channel) return;
const parent = THREAD_TYPES.has(channel.type) ? this._getChannel(channel.parent_id) : null;
const name = `${THREAD_TYPES.has(channel.type) ? '' : '#'}${channel.name ?? channel.id}`;
const fullName = parent?.name ? `#${parent.name} › ${name}` : name;
const label = !hide && parent?.name ? fullName : name;
if (this._paneTitle.textContent !== label) this._paneTitle.textContent = label;
if (this._paneTitle.title !== fullName) this._paneTitle.title = fullName;
}
_getNativePinsAdapter(state) {
const pins = state.actions.find(a => a.key === 'pins')?.el;
try {
let fiber = BdApi.ReactUtils?.getInternalInstance(pins);
for (let depth = 0; fiber && depth < 16; depth++, fiber = fiber.return) {
const props = fiber.memoizedProps, owner = fiber.stateNode;
if (typeof props?.renderPopout !== 'function' || typeof owner?.render !== 'function' ||
typeof owner.forceUpdate !== 'function') continue;
const content = props.renderPopout({ closePopout() {} });
// Observed native wrapper: explicit channel/onJump content, not the
// top Pins button (which subscribes TOGGLE_CHANNEL_PINS globally).
if (typeof content?.type !== 'function' || content.props?.channel?.id !== state.channelId ||
typeof content.props.onJump !== 'function' || !fiber.type?.prototype?.render) continue;
return { owner, Popout: fiber.type, Content: content.type, props };
}
} catch { /* changed native fibers: keep the split controls unavailable */ }
return null;
}
_splitHeaderIsCurrent(controls) {
return this._splitHeaderControls === controls && controls.slot.isConnected &&
this._splitChannelId === controls.channelId && this._compactToolbarIsCurrent(controls.toolbar);
}
_nativeChannelMenuTarget(channelId) {
const channel = this._getChannel(channelId);
if (!channel?.guild_id) return null;
// Invoke only an existing native channel/thread opener with this exact URL.
// No selected-channel callback is borrowed for the other pane.
const href = `/channels/${channel.guild_id}/${channelId}`;
return Array.from(document.querySelectorAll?.('a[href]') ?? []).find(el =>
el.getAttribute('href') === href && !this._paneEl?.contains(el)) ?? null;
}
_guardHeaderMenuTree(tree, isCurrent) {
const React = BdApi.React;
if (Array.isArray(tree)) return tree.map(child => this._guardHeaderMenuTree(child, isCurrent));
if (!React.isValidElement(tree)) return tree;
const props = {};
if (tree.props.children) props.children = this._guardHeaderMenuTree(tree.props.children, isCurrent);
if (typeof tree.props.action === 'function') props.action = function (...args) {
if (isCurrent() && !tree.props.disabled) return tree.props.action.apply(this, args);
};
return React.cloneElement(tree, props);
}
_splitHeaderMenuTree(tree, controls) {
if (!this._splitHeaderIsCurrent(controls)) return null;
const React = BdApi.React;
if (!React.isValidElement(tree) || !['thread-context', 'channel-context'].includes(tree.props.navId)) return tree;
const channel = this._getChannel(controls.channelId);
if (!channel) return null;
const prune = node => {
if (Array.isArray(node)) return node.map(prune);
if (!React.isValidElement(node)) return node;
// Native thread "open" is Discord's split-view command. Only remove it
// in this split-header projection, never from the original native menu.
if (['open', 'ssv-open-in-split', 'ssv-breakout-chat'].includes(node.props.id)) return null;
return node.props.children ? React.cloneElement(node, { children: prune(node.props.children) }) : node;
};
const breakout = BdApi.ContextMenu.buildItem({
type: 'button', id: 'ssv-breakout-chat', label: 'Break out chat',
action: () => this.openBreakout(channel.id, channel, { source: 'split' }),
});
return this._guardHeaderMenuTree(React.cloneElement(tree, {
children: [breakout, ...React.Children.toArray(prune(tree.props.children))],
}), () => this._splitHeaderIsCurrent(controls));
}
_openSplitChannelMenu(controls, event) {
if (!this._splitHeaderIsCurrent(controls)) return;
const target = this._nativeChannelMenuTarget(controls.channelId);
const channel = this._getChannel(controls.channelId);
const adapter = !target && channel ? this._getBreakoutHeaderAdapter(channel) : null;
if ((!target && !adapter) || typeof BdApi.ContextMenu?.patch !== 'function') return;
controls.menuUnpatch?.();
const disposers = ['channel-context', 'thread-context'].map(id => BdApi.ContextMenu.patch(id, (tree, props) => {
if (props?.channel?.id !== controls.channelId || !this._splitHeaderIsCurrent(controls)) return;
controls.menuOpen = true;
const guarded = this._splitHeaderMenuTree(tree, controls);
// ContextMenu.patch mutates the native return tree; actions/confirmations
// remain native. Capture stale guards before handing it back to Discord.
if (guarded?.props) {
const onClose = guarded.props.onClose;
tree.props = { ...guarded.props, onClose: (...args) => {
controls.menuOpen = false;
controls.menuUnpatch?.();
return onClose?.(...args);
} };
}
}));
controls.menuUnpatch = () => { disposers.forEach(dispose => dispose?.()); controls.menuUnpatch = null; };
if (target) {
const rect = event.currentTarget.getBoundingClientRect();
target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, view: window,
clientX: rect.left, clientY: rect.bottom, button: 2 }));
} else {
const method = this.isThreadChannel(channel) ? adapter.openThreadMenu : adapter.openChannelMenu;
try { method.call({ props: { guild: this._modules.GuildStore?.getGuild?.(channel.guild_id) } }, event, channel); }
catch (error) { controls.menuUnpatch?.(); this._dbg('Split menu unavailable:', error.message); }
}
}
_syncSplitHeaderControls(toolbar) {
const slot = this._paneEl?.querySelector('.ssv-pane-header-controls');
if (!slot || !this._compactToolbarIsCurrent(toolbar)) return;
if (this._splitHeaderControls?.toolbar === toolbar) return;
this._restoreSplitHeaderControls();
const React = BdApi.React, ReactDOM = this._modules.ReactDOM;
const adapter = this._getNativePinsAdapter(toolbar);
if (!adapter || typeof ReactDOM?.createPortal !== 'function' || typeof BdApi.Patcher?.after !== 'function') return;
const plugin = this;
const controls = this._splitHeaderControls = { toolbar, slot, channelId: this._splitChannelId, adapter };
const nativeThreadMenu = THREAD_TYPES.has(this._getChannel(controls.channelId)?.type) ? this._getNativeThreadMenu(toolbar) : null;
function ThreadMenu(props) {
if (!plugin._splitHeaderIsCurrent(controls)) return null;
return plugin._splitHeaderMenuTree(nativeThreadMenu.type(props), controls);
}
function SplitHeaderControls() {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
const [moreOpen, setMoreOpen] = React.useState(false);
const moreRef = React.useRef(null);
const current = plugin._splitHeaderIsCurrent(controls);
const channel = current ? plugin._getChannel(controls.channelId) : null;
const close = () => { setOpen(false); ref.current?.focus(); };
if (!channel) return null;
const moreEnabled = !!nativeThreadMenu || (typeof BdApi.ContextMenu?.patch === 'function' &&
(!!plugin._nativeChannelMenuTarget(controls.channelId) || !!plugin._getBreakoutHeaderAdapter(channel)));
const openMore = event => {
if (!plugin._splitHeaderIsCurrent(controls)) return;
if (nativeThreadMenu) setMoreOpen(value => !value);
else plugin._openSplitChannelMenu(controls, event);
};
const pins = React.createElement(adapter.Popout, {
targetElementRef: ref, shouldShow: open, animation: adapter.props.animation,
position: 'bottom', align: 'right', autoInvert: false, ignoreModalClicks: true, clickTrap: true,
onRequestClose: close,
renderPopout: props => plugin._splitHeaderIsCurrent(controls) ? React.createElement(adapter.Content, {
...props, channel: plugin._getChannel(controls.channelId),
onJump: event => { if (!event?.shiftKey) close(); },
}) : null,
children: trigger => React.createElement('button', { ...trigger, ref, type: 'button',
className: 'ssv-pane-header-btn', 'aria-label': 'Pinned Messages', title: 'Pinned Messages',
'aria-haspopup': 'dialog', 'aria-expanded': open,
onClick: () => { if (plugin._splitHeaderIsCurrent(controls)) setOpen(value => !value); },
onKeyDown: event => { if (event.key === 'Escape' && open) { event.preventDefault(); close(); } },
}, React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
React.createElement('path', { d: PIN_ICON_PATH }))),
});
const moreButton = trigger => React.createElement('button', { ...trigger, ref: moreRef, type: 'button', className: 'ssv-pane-header-btn', 'aria-label': 'More',
title: moreEnabled ? 'More channel actions' : 'Channel actions unavailable: native channel entry not found',
disabled: !moreEnabled, 'aria-haspopup': 'menu', 'aria-expanded': moreOpen,
onClick: openMore,
onKeyDown: event => { if (event.key === 'ArrowDown' && !moreOpen) { event.preventDefault(); openMore(event); } },
}, '⋯');
const more = nativeThreadMenu ? React.createElement(adapter.Popout, {
targetElementRef: moreRef, shouldShow: moreOpen, animation: adapter.props.animation,
position: 'bottom', align: 'left', autoInvert: false,
onRequestClose: () => setMoreOpen(false),
renderPopout: props => React.createElement(ThreadMenu, { ...props, channel,
closePopout: () => setMoreOpen(false) }), children: moreButton,
}) : moreButton({});
return React.createElement(React.Fragment, null, pins, more);
}
// Portal from the live native Popout owner, preserving Discord's layer,
// focus and Mana providers. No independent root, copied provider snapshots,
// chat remount, selected-channel mutation or global Pins subscription.
controls.unpatch = BdApi.Patcher.after(PLUGIN_NAME, adapter.owner, 'render', (_self, _args, tree) => {
if (!this._splitHeaderIsCurrent(controls)) return tree;
return React.createElement(React.Fragment, null, tree,
ReactDOM.createPortal(React.createElement(SplitHeaderControls), slot, `betterchat-header-${controls.channelId}`));
});
slot.setAttribute('data-ssv-ready', 'true');
adapter.owner.forceUpdate();
}
_restoreSplitHeaderControls() {
const controls = this._splitHeaderControls;
this._splitHeaderControls = null;
if (!controls) return;
controls.slot.removeAttribute('data-ssv-ready');
controls.menuUnpatch?.();
if (controls.menuOpen) BdApi.ContextMenu.close();
controls.unpatch?.();
try { controls.adapter.owner.forceUpdate(); } catch { /* native owner already unmounted */ }
}
_getNativeThreadMenu(state) {
const action = state.actions.find(a => a.key === 'thread');
if (!action || action.el.disabled || action.el.getAttribute?.('aria-disabled') === 'true') return null;
// Reuse the native opener's component and channel, not a hand-maintained
// list of thread actions or permission checks. Unknown shapes retain fallback.
try {
let fiber = BdApi.ReactUtils?.getInternalInstance(action.el);
for (let depth = 0; fiber && depth < 12; depth++, fiber = fiber.return) {
const render = fiber.memoizedProps?.renderPopout;
if (typeof render !== 'function') continue;
const menu = render({ closePopout() {} });
if (typeof menu?.type === 'function' && menu.props?.channel?.id === state.channelId) return menu;
}
} catch { /* Discord changed this optional native menu adapter. */ }
return null;
}
_mergeNativeThreadMenu(state, tree, items) {
if (!this._compactToolbarIsCurrent(state)) return null;
if (tree?.props?.navId !== 'thread-context') return tree;
const React = BdApi.React, plugin = this;
const guard = element => {
if (Array.isArray(element)) return element.map(guard);
if (!React.isValidElement(element)) return element;
const props = {};
if (element.props.children) props.children = guard(element.props.children);
if (typeof element.props.action === 'function') props.action = function (...args) {
if (plugin._compactToolbarIsCurrent(state) && !element.props.disabled) return element.props.action.apply(this, args);
};
return React.cloneElement(element, props);
};
return React.cloneElement(tree, { children: [
...BdApi.ContextMenu.buildMenuChildren([{ type: 'group', items }]),
...React.Children.toArray(guard(tree.props.children)),
] });
}
_mainHeaderBreakoutItem(state) {
const channel = this._getChannel(state.channelId);
if (!this.isSplitTargetChannel(channel)) return null;
return {
type: 'text', id: 'ssv-breakout-chat', label: 'Break out chat',
action: () => {
if (this._compactToolbarIsCurrent(state)) this.openBreakout(channel.id, channel, { source: 'main' });
},
};
}
_openCompactToolbarMenu(state, inputEvent) {
if (!this._compactToolbarIsCurrent(state)) { this._syncCompactToolbar(this._resolveDockedLayout()); return; }
if (state.menuOpen || state.timer !== null) return;
const nativeMenu = typeof BdApi.Patcher?.after === 'function' && typeof BdApi.ContextMenu.buildMenuChildren === 'function'
? this._getNativeThreadMenu(state) : null;
const items = state.actions.filter(a => !nativeMenu || a.key !== 'thread').map(action => ({
type: 'text', id: `betterchat-${action.key}`, label: action.key === 'thread' ? 'Thread actions' : action.label,
disabled: action.el.getAttribute('aria-disabled') === 'true' || action.el.disabled === true,
action: () => {
if (!this._compactToolbarIsCurrent(state) || action.el.getAttribute('aria-disabled') === 'true' || action.el.disabled === true) return;
state.handingOff = true;
BdApi.ContextMenu.close();
// Let the compact menu finish closing before opening a native popout/menu.
// React's real control remains a 32px anchor over More (never display:none).
state.timer = window.setTimeout(() => {
state.timer = null;
if (!this._compactToolbarIsCurrent(state) || action.el.getAttribute('aria-disabled') === 'true' || action.el.disabled === true) return;
const r = state.button.getBoundingClientRect();
if (!r.width || !r.height) return;
action.el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window,
clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 }));
// Only the native menu opener is invoked, never one of its actions.
state.handingOff = false;
}, 0);
},
}));
const breakout = this._mainHeaderBreakoutItem(state);
if (breakout) items.unshift(breakout);
const r = state.button.getBoundingClientRect();
// Native context menus need the originating DOM target, not an undispatched event.
const event = inputEvent?.target ? inputEvent : {
target: state.button, currentTarget: state.button, clientX: r.right, clientY: r.bottom,
preventDefault() {}, stopPropagation() {},
};
state.menuOpen = true; state.handingOff = false;
state.button.setAttribute('aria-expanded', 'true');
try {
const onClosed = () => {
state.menuRenderUnpatch?.(); state.menuRenderUnpatch = null;
if (!state.menuOpen) return;
state.menuOpen = false; state.button.setAttribute('aria-expanded', 'false');
if (!state.handingOff && this._compactToolbarIsCurrent(state)) state.button.focus();
};
let Menu;
if (nativeMenu) {
// Patch this local render owner only. Discord's global menu export and
// other menus are untouched; native hooks still run as a React component.
const owner = { render: nativeMenu.type };
state.menuRenderUnpatch = BdApi.Patcher.after(PLUGIN_NAME, owner, 'render', (_self, _args, tree) =>
this._mergeNativeThreadMenu(state, tree, items));
Menu = props => BdApi.React.createElement(owner.render, { ...nativeMenu.props, betterChatCompactToolbar: true,
closePopout: props.onClose, onSelect: props.onSelect });
} else Menu = BdApi.ContextMenu.buildMenu(items);
// BdApi.open injects the native onClose prop. Observe component unmount as
// well as Escape so outside clicks cannot leave More permanently expanded.
const CompactMenu = props => {
BdApi.React.useEffect(() => onClosed, []);
return BdApi.React.createElement(Menu, { ...props, onClose: (...args) => {
props.onClose?.(...args); onClosed();
} });
};
BdApi.ContextMenu.open(event, CompactMenu);
} catch (error) {
this._restoreCompactToolbar();
this._dbg('Compact toolbar menu unavailable:', error.message);
}
}
_positionThreadsButton() {
const state = this._compactToolbar;
if (!state?.threadSlot || !state.threads) return;
const r = state.threadSlot.getBoundingClientRect();
for (const [key, value] of Object.entries({ '--ssv-threads-x': `${r.left + (r.width - 32) / 2}px`, '--ssv-threads-y': `${r.top + (r.height - 32) / 2}px` })) {
if (state.threads.style.getPropertyValue(key) !== value) state.threads.style.setProperty(key, value);
}
}
_positionSplitSearch() {
this._positionThreadsButton();
const search = this._compactToolbar?.search;
const slot = this._paneEl?.querySelector('.ssv-pane-header-search-slot');
if (!search || !slot) return;
this._refreshSplitSearchTarget();
const r = slot.getBoundingClientRect();
for (const [key, value] of Object.entries({
'--ssv-search-x': `${r.left}px`, '--ssv-search-y': `${r.top}px`, '--ssv-search-width': `${r.width}px`,
})) if (search.style.getPropertyValue(key) !== value) search.style.setProperty(key, value);
}
_refreshSplitSearchTarget() {
const state = this._compactToolbar;
if (!state || !this._compactToolbarIsCurrent(state)) return false;
const channel = this._getChannel(this._splitChannelId);
if (!channel?.guild_id || this._splitSearchTarget === this._splitChannelId) return false;
this._splitSearchTarget = this._splitChannelId;
try {
const store = BdApi.Webpack?.getStore?.('SearchMessageStore');
const native = this._getNativeSearchService();
const context = { type: 'GUILD', guildId: channel.guild_id };
const query = native?.service.getSearchInputText(context);
// Do not turn an unsent search draft into a request. Refresh only an
// existing result set; native fetch cancels the previous target request.
if (!store?.hasSearchState?.(channel.guild_id) || !query?.trim()) return false;
native.service.fetchMessages({ searchContext: context, searchQueryString: query, offset: 0, searchEverywhere: false });
return true;
} catch (error) { this._dbg('Split search refresh unavailable:', error.message); return false; }
}
_installSplitSearchScope() {
if (this._splitSearchUnpatch) return true;
// Native SearchFetcher owns parsing, requests, result stores and pagination.
// Only the visible, same-guild split search is scoped; no custom renderer.
let factory;
try {
factory = BdApi.Webpack?.getModule(m => typeof m?.create === 'function' &&
String(m.create).includes('searchType') && typeof m.cancel === 'function' && typeof m.get === 'function', { searchExports: true });
} catch { return false; } // Discord updates must not break the chat pane.
if (!factory || typeof BdApi.Patcher?.before !== 'function') return false;
this._splitSearchUnpatch = BdApi.Patcher.before(PLUGIN_NAME, factory, 'create', (_self, args) => {
const state = this._compactToolbar, request = args[0];
if (!state || !this._compactToolbarIsCurrent(state)) return;
const target = this._getChannel(this._splitChannelId);
if (request?.searchType !== 'GUILD' || request.id !== target?.guild_id || !request.searchQuery) return;
// Replace, never append: another in: filter or Search Everywhere cannot
// silently widen a search advertised as belonging to this split channel.
args[0] = { ...request, searchQuery: { ...request.searchQuery, channel_id: [this._splitChannelId], search_everywhere: false } };
});
return typeof this._splitSearchUnpatch === 'function';
}
_restoreCompactToolbar() {
this._restoreSplitHeaderControls();
this._splitSearchUnpatch?.();
this._splitSearchUnpatch = null;
const state = this._compactToolbar;
this._compactToolbar = null;
if (!state) return;
state.observer?.disconnect();
if (state.frame !== null) window.cancelAnimationFrame(state.frame);
if (state.timer !== null) window.clearTimeout(state.timer);
if (state.menuOpen) BdApi.ContextMenu.close();
state.menuRenderUnpatch?.(); state.menuRenderUnpatch = null;
if (state.threads) for (const key of ['--ssv-threads-x', '--ssv-threads-y']) state.threads.style.removeProperty(key);
state.button.remove();
for (const key of ['--ssv-search-x', '--ssv-search-y', '--ssv-search-width']) state.search.style.removeProperty(key);
for (const { el, name, before, value } of state.owned.reverse()) {
if (el.getAttribute(name) !== value) continue; // don't undo a later native/plugin write
if (before === null) el.removeAttribute(name);
else el.setAttribute(name, before);
}
}
_syncNativeRowPosition(layout) {
const row = layout?.nativeRow;
const needsBoundary = row && Array.from(row.querySelectorAll('[class*="chatLayerWrapper"]')).some(thread =>
layout.surfaces.some(surface => thread.contains(surface)) && getComputedStyle(thread).position === 'absolute' &&
(thread.offsetParent === layout.host || thread.offsetParent === row));
// Keep ownership across geometry observer rebinds; never fight a later native write.
if (needsBoundary && this._nativeRowPosition?.row === row) return;
this._restoreNativeRowPosition();
if (!needsBoundary || getComputedStyle(row).position !== 'static') return;
this._nativeRowPosition = {
row, value: row.style.getPropertyValue('position'), priority: row.style.getPropertyPriority('position'),
};
row.style.setProperty('position', 'relative');
}
_restoreNativeRowPosition() {
const owned = this._nativeRowPosition;
this._nativeRowPosition = null;
if (!owned) return;
const style = owned.row.style;
if (style.getPropertyValue('position') !== 'relative' || style.getPropertyPriority('position') !== '') return;
if (owned.value) style.setProperty('position', owned.value, owned.priority);
else style.removeProperty('position');
}
_setDockedSuspended(suspended) {
const pane = this._paneEl;
if (!pane || (pane.getAttribute('data-ssv-suspended') === 'true') === suspended) return;
if (suspended) {
const r = pane.getBoundingClientRect();
this._setPaneStyle('--ssv-suspended-width', `${r.width}px`);
this._setPaneStyle('--ssv-suspended-height', `${r.height}px`);
pane.setAttribute('data-ssv-suspended', 'true');
pane.setAttribute('aria-hidden', 'true');
} else {
pane.removeAttribute('data-ssv-suspended');
pane.removeAttribute('aria-hidden');
pane.style.removeProperty('--ssv-suspended-width');
pane.style.removeProperty('--ssv-suspended-height');
}
}
_setPaneStyle(name, value) {
if (!this._paneEl) return;
const style = this._paneEl.style;
if (name.startsWith('--')) {
if (style.getPropertyValue(name) !== value) style.setProperty(name, value);
} else if (style[name] !== value) style[name] = value;
}
_observeDockedGeometry(layout) {
const extras = [...layout.sidebars, ...(layout.member ? [layout.member.parentElement] : []),
...this._dockedNativeThreads(layout).map(thread => thread.wrapper)];
if (this._dockedObservedHost === layout.host && this._dockedObservedHeader === layout.mainHeader &&
extras.length === this._dockedObservedExtras?.length && extras.every((el, i) => el === this._dockedObservedExtras[i])) return;
this._removeDockedGeometryObserver();
if (typeof ResizeObserver !== 'function') return;
const observer = new ResizeObserver(() => {
if (this._stopped || this._dockedResizeObserver !== observer || this._dockedGeometryRaf !== null) return;
this._dockedGeometryRaf = window.requestAnimationFrame(() => {
this._dockedGeometryRaf = null;
if (!this._stopped && this._dockedResizeObserver === observer) this._ensurePaneDockedRight('native-geometry');
});
});
this._dockedResizeObserver = observer;
this._dockedObservedHost = layout.host;
this._dockedObservedHeader = layout.mainHeader;
this._dockedObservedExtras = extras;
observer.observe(layout.host);
if (layout.mainHeader) observer.observe(layout.mainHeader);
for (const el of extras) observer.observe(el);
}
_removeDockedGeometryObserver() {
this._dockedResizeObserver?.disconnect();
this._dockedResizeObserver = null;
this._dockedObservedExtras = null;
this._dockedObservedHost = null;
this._dockedObservedHeader = null;
if (this._dockedGeometryRaf !== null) window.cancelAnimationFrame(this._dockedGeometryRaf);
this._dockedGeometryRaf = null;
}
_inspectLayout() {
// Passive, geometry-only: deliberately not the ID-bearing status snapshot.
const result = this._discoverDockedLayout();
const rect = el => {
if (!el?.isConnected || el.ownerDocument !== document) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
};
const layout = result.layout;
return {
status: layout ? 'supported' : 'unavailable', reason: result.reason,
globalBar: rect(layout?.globalBar), mainHeader: rect(layout?.mainHeader),
paneHeader: rect(this._paneEl?.querySelector('.ssv-pane-header')), pane: rect(this._paneEl),
host: rect(layout?.host),
candidates: result.candidates.map(candidate => ({ rect: rect(candidate.host), valid: !candidate.reason, reason: candidate.reason })),
};
}
_scheduleDockedRight(reason = 'layout-check') {
if (this._stopped || !this._splitChannelId || this._settings?.paneMode === 'floating') return;
if (this._redockTimers.size > 0) return;
for (const delay of [40, 140, 320, 700]) {
const timer = window.setTimeout(() => {
this._redockTimers.delete(timer);
if (!this._stopped) this._ensurePaneDockedRight(`${reason}+${delay}ms`);
}, delay);
this._redockTimers.add(timer);
}
}
_ensurePaneDockedRight(reason = 'layout-check') {
if (this._stopped || !this._splitChannelId || !this._paneEl || !document.body.contains(this._paneEl)) return false;
if (this._settings?.paneMode === 'floating') return false;
const container = this._findLayoutContainer();
if (!container) {
this._applyDockedLayout(); // suspend the shell; never keep an unsafe visible parent
return false;
}
const isWrongParent = this._paneEl.parentElement !== container;
const isNotRightmost = this._paneEl.nextElementSibling !== null;
if (!isWrongParent && !isNotRightmost) {
this._applyDockedLayout(container);
return false;
}
// Discord may insert the native thread preview/sidebar after our pane during
// navigation. Move the existing pane back to the end of the current chat row
// instead of destroying/remounting React; this preserves the split target and
// keeps the layout stable as main channel | thread preview | BetterChat.
container.appendChild(this._paneEl);
document.body.classList.add('ssv-active');
this._applyDockedLayout(container);
this._dbg(`Re-docked split pane to the right after ${reason}`);
return true;
}
// Legacy diagnostics remain callable but never create or paint an overlay.
_syncTitlebarDragStrip() {
return { status: 'retired', visible: false, reason: 'native-shared-titlebar' };
}
_inspectTitlebarDragStrip() { return this._syncTitlebarDragStrip(); }
_setTitlebarDragStripProbe() { return this._syncTitlebarDragStrip(); }
// ─── Scroll stabilization ────────────────────────────────────────────────────
_getMainChatScope() {
return this._queryMainDiscordElement([
'[class*="chatContent"]',
'[class*="chat-"][class*="content"]',
]);
}
_findScrollableMessages(scope) {
if (!scope) return [];
const selectors = [
'[data-list-id*="chat-messages"]',
'[class*="messagesWrapper"] [class*="scroller"]',
'[class*="chatContent"] [class*="scroller"]',
'[class*="scrollerInner"]',
'[role="log"]',
];
const candidates = new Set([scope]);
for (const selector of selectors) {
try {
scope.querySelectorAll(selector).forEach(el => {
candidates.add(el);
if (el.parentElement) candidates.add(el.parentElement);
});
} catch { /* selector unsupported in this Discord build */ }
}
return Array.from(candidates).filter(el => {
if (!el || !(el instanceof HTMLElement)) return false;
if (!scope.contains(el) && el !== scope) return false;
if (scope !== this._paneBody && el.closest('[data-ssv="pane"]')) return false;
const style = getComputedStyle(el);
const canScroll = /(auto|scroll)/.test(style.overflowY) || el.scrollHeight > el.clientHeight;
return canScroll && el.scrollHeight > el.clientHeight + 8;
});
}
_scrollScopeToBottom(scope, reason) {
const scrollers = this._findScrollableMessages(scope);
for (const el of scrollers) {
try {
el.scrollTop = el.scrollHeight;
el.lastElementChild?.scrollIntoView?.({ block: 'end', inline: 'nearest' });
} catch { /* ignore stale DOM during Discord route changes */ }
}
if (scrollers.length) this._dbg(`Scrolled ${scrollers.length} message scroller(s) to bottom after ${reason}`);
return scrollers.length;
}
_scrollToBottom(target = 'both', reason = 'navigation') {
if (target === 'main' || target === 'both') this._scrollScopeToBottom(this._getMainChatScope(), `${reason}:main`);
if ((target === 'split' || target === 'both') && this._paneBody) this._scrollScopeToBottom(this._paneBody, `${reason}:split`);
}
_scheduleScrollToBottom(target = 'both', reason = 'navigation') {
const epoch = this._openEffectsEpoch;
// Discord restores/render-loads messages asynchronously. Run a short series
// of bottom-scroll attempts so both the default chat and the split pane land
// on the most recent messages after tab/channel switches.
for (const delay of [60, 180, 420, 900]) {
const timer = window.setTimeout(() => {
this._scrollTimers.delete(timer);
if (!this._stopped && epoch === this._openEffectsEpoch) this._scrollToBottom(target, reason);
}, delay);
this._scrollTimers.add(timer);
}
}
// ─── Pane DOM ────────────────────────────────────────────────────────────────
createDockedPane(quiet = false) {
if (this._paneEl) return this._paneEl;
const floating = this._settings?.paneMode === 'floating';
const container = floating ? document.body : this._findLayoutContainer();
if (!container) {
this._err('Cannot create BetterChat pane: safe layout container not found');
if (!quiet) this._toast('BetterChat could not find a safe Discord chat row to mount into', 'error');
return null;
}
if (!floating && this._dockedWidth(this._resolveDockedLayout()) === null) {
if (!quiet) this._toast('Not enough room for BetterChat beside native chat', 'warning');
return null;
}
const pane = document.createElement('div');
pane.className = floating ? 'ssv-pane ssv-floating' : 'ssv-pane';
pane.setAttribute('data-ssv', 'pane');
// Resize handle on the left edge
const handle = document.createElement('div');
handle.className = 'ssv-resize-handle';
handle.setAttribute('aria-hidden', 'true');
handle.addEventListener('mousedown', this._onResizeStart);
const floatingResizeCorner = document.createElement('div');
floatingResizeCorner.className = 'ssv-floating-resize-corner';
floatingResizeCorner.setAttribute('aria-hidden', 'true');
floatingResizeCorner.addEventListener('mousedown', this._onFloatingResizeStart);
// Inner flex column
const inner = document.createElement('div');
inner.className = 'ssv-pane-inner';
// Header row
const header = document.createElement('div');
header.className = 'ssv-pane-header';
const title = document.createElement('span');
title.className = 'ssv-pane-header-title';
title.textContent = 'BetterChat';
this._paneTitle = title;
const closeBtn = document.createElement('button');
closeBtn.className = 'ssv-pane-header-btn';
closeBtn.title = 'Close BetterChat';
closeBtn.setAttribute('aria-label', 'Close BetterChat');
closeBtn.type = 'button';
closeBtn.innerHTML = '';
closeBtn.addEventListener('click', () => this.close());
header.addEventListener('mousedown', this._onFloatingDragStart);
const searchSlot = document.createElement('div');
searchSlot.className = 'ssv-pane-header-search-slot';
searchSlot.setAttribute('aria-hidden', 'true');
const controls = document.createElement('div');
controls.className = 'ssv-pane-header-controls';
const unavailable = document.createElement('span');
unavailable.className = 'ssv-header-unavailable';
for (const [label, icon] of [['Pinned Messages', null], ['More', '⋯']]) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'ssv-pane-header-btn'; button.disabled = true;
button.setAttribute('aria-label', label);
button.title = `${label} unavailable in this native header context`;
if (icon) button.textContent = icon;
else button.innerHTML = ``;
unavailable.appendChild(button);
}
controls.appendChild(unavailable);
header.append(closeBtn, title, controls, searchSlot);
// Body — placeholder until native Discord chat mounts
const body = document.createElement('div');
body.className = 'ssv-pane-body ssv-placeholder';
this._paneBody = body;
this._renderPlaceholder('No split target selected', 'Right-click a channel or thread and choose\n"Split this chat"');
inner.append(header, body);
pane.append(handle, inner, floatingResizeCorner);
container.appendChild(pane);
this._paneEl = pane;
document.body.classList.add('ssv-active');
this._applyPanePlacement();
this._log('Pane created');
return pane;
}
destroyDockedPane() {
this._restoreDockedMembers();
this._restoreCompactToolbar();
this._removeDockedGeometryObserver();
this._restoreNativeRowPosition();
if (!this._paneEl) return;
// Unmount any React root before removing the DOM node
this._unmountReactRoot();
// Clean up any in-progress resize to avoid listener leaks
if (this._resizing) {
this._resizing = false;
document.body.classList.remove('ssv-resizing');
document.removeEventListener('mousemove', this._onResizeMove);
document.removeEventListener('mouseup', this._onResizeEnd);
window.removeEventListener('blur', this._cancelResize);
}
const handle = this._paneEl.querySelector('.ssv-resize-handle');
if (handle) handle.removeEventListener('mousedown', this._onResizeStart);
const floatingResizeCorner = this._paneEl.querySelector('.ssv-floating-resize-corner');
if (floatingResizeCorner) floatingResizeCorner.removeEventListener('mousedown', this._onFloatingResizeStart);
const header = this._paneEl.querySelector('.ssv-pane-header');
if (header) header.removeEventListener('mousedown', this._onFloatingDragStart);
this._cancelFloatingDrag();
this._cancelFloatingResize();
this._paneEl.remove();
this._paneEl = null;
this._paneTitle = null;
this._paneBody = null;
document.body.classList.remove('ssv-active');
this._log('Pane destroyed');
}
_scheduleRemount(reason = 'layout-change', force = false) {
if (!this._splitChannelId) return;
if (this._remountTimer) window.clearTimeout(this._remountTimer);
this._remountTimer = window.setTimeout(() => {
this._remountTimer = null;
const channelId = this._splitChannelId;
if (this._stopped || !channelId) return;
const paneMissing = !this._paneEl || !document.body.contains(this._paneEl);
if (!paneMissing) {
this._ensurePaneDockedRight(reason);
if (!force) return;
}
this._dbg(`Remounting split pane after ${reason}: ${channelId}`);
this.destroyDockedPane();
this.open(channelId, null, { automatic: true });
}, 150);
}
_installLayoutPersistence() {
if (this._layoutObserver || typeof MutationObserver !== 'function') return;
this._layoutObserver = new MutationObserver(() => {
if (!this._splitChannelId) { this._restoreActiveSplit(); return; }
if (!this._paneEl || !document.body.contains(this._paneEl)) {
this._scheduleRemount('discord-navigation');
} else {
this._scheduleDockedRight('discord-navigation');
}
});
this._layoutObserver.observe(document.body, { childList: true, subtree: true });
this._dbg('Layout persistence observer installed');
}
_removeLayoutPersistence() {
this._restoreDockedMembers();
this._restoreCompactToolbar();
if (this._remountTimer) {
window.clearTimeout(this._remountTimer);
this._remountTimer = null;
}
for (const timer of this._redockTimers) window.clearTimeout(timer);
this._redockTimers.clear();
this._layoutObserver?.disconnect?.();
this._layoutObserver = null;
this._removeDockedGeometryObserver();
this._restoreNativeRowPosition();
}
_clearDeferredTimers() {
if (this._restoreTimer) {
window.clearTimeout(this._restoreTimer);
this._restoreTimer = null;
this._restoreAttempt = 0;
}
if (this._memberListTimer) {
window.clearTimeout(this._memberListTimer);
this._memberListTimer = null;
}
for (const timer of this._redockTimers) window.clearTimeout(timer);
this._redockTimers.clear();
for (const timer of this._nativeRenderTimers) window.clearTimeout(timer);
this._nativeRenderTimers.clear();
if (this._floatingSaveTimer) {
window.clearTimeout(this._floatingSaveTimer);
this._floatingSaveTimer = null;
}
for (const timer of this._scrollTimers) window.clearTimeout(timer);
this._scrollTimers.clear();
this._removeDockedGeometryObserver();
}
_getSelectedChannelId() {
const store = this._modules.SelectedChannelStore;
try {
return store?.getChannelId?.() ?? store?.getVoiceChannelId?.() ?? null;
} catch {
return null;
}
}
_cacheChannelSnapshot(channel) {
if (!channel?.id) return null;
this._contextChannelCache.set(channel.id, channel);
return channel;
}
_getChannel(channelOrId) {
if (!channelOrId) return null;
if (typeof channelOrId === 'object') return this._cacheChannelSnapshot(channelOrId);
return this._modules.ChannelStore?.getChannel?.(channelOrId)
?? this._contextChannelCache.get(channelOrId)
?? null;
}
_getChannelGuildId(channelOrId) {
const channel = this._getChannel(channelOrId);
if (!channel) return null;
if (channel.guild_id) return channel.guild_id;
if (channel.parent_id) return this._getChannel(channel.parent_id)?.guild_id ?? null;
return null;
}
_getCurrentAccountId() {
try {
const id = this._modules.UserStore?.getCurrentUser?.()?.id;
return isDiscordId(id) ? id : null;
} catch { return null; }
}
_syncPersistenceAccount() {
const next = this._getCurrentAccountId();
if (next === this._accountId) return;
const initial = this._accountId === undefined;
// Initial UserStore hydration may lag start(); defer the account-bound
// session read, but never apply this exception after a known user logs out.
if (initial && !next) return;
this._teardownSplit();
this._contextChannelCache.clear();
this._accountId = next;
if (this._settings) this._settings.activeChannelId = initial ? this._readSessionActiveSplit() : null;
if (!initial) this._writeSessionActiveSplit(null);
}
_getSelectedGuildId(selectedChannelId = this._getSelectedChannelId()) {
try {
// A null selected guild means DMs/home, never the last visited server.
const store = this._modules.SelectedGuildStore;
if (typeof store?.getGuildId === 'function') return store.getGuildId() || null;
const selected = this._modules.SelectedChannelStore;
if (typeof selected?.getGuildId === 'function') return selected.getGuildId() || null;
return selectedChannelId ? this._getChannelGuildId(selectedChannelId) : null;
} catch { return null; }
}
_closeForGuildChange(previousGuildId, nextGuildId) {
this._dbg('Closing BetterChat after guild/server change:', previousGuildId, '→', nextGuildId);
if (this._settings?.rememberSplitPerServer) {
this._teardownSplit();
this._settings.activeChannelId = null;
this._writeSessionActiveSplit(null);
} else this.close();
}
_isDuplicateMainSplitChannel(channelId = this._splitChannelId) {
return !!channelId && channelId === this._getSelectedChannelId();
}
_applyDuplicateChannelMode(reason = 'update') {
const duplicate = this._isDuplicateMainSplitChannel();
this._paneEl?.classList.toggle('ssv-duplicate-main-channel', duplicate);
if (duplicate) {
this._recordCrashEvent('duplicate-main-split-independent-scroll-mode', {
channelId: this._splitChannelId,
reason,
});
this._dbg('Same channel is open in main view and BetterChat; keeping BetterChat scrollable while suppressing only its composer:', this._splitChannelId);
}
return duplicate;
}
_installSelectedChannelPersistence() {
if (this._selectedChannelListener) return;
this._syncPersistenceAccount();
this._lastMainChannelId = this._getSelectedChannelId();
this._lastMainGuildId = this._getSelectedGuildId();
this._selectedChannelListener = () => {
if (this._stopped) return;
this._syncPersistenceAccount();
const next = this._getSelectedChannelId();
const guildId = this._getSelectedGuildId(next);
const changed = next !== this._lastMainChannelId || guildId !== this._lastMainGuildId;
if (changed) {
this._cancelOpenEffects();
this._cancelSplitRestore();
}
if (this._splitChannelId && (!guildId || guildId !== this._splitGuildId)) {
this._closeForGuildChange(this._splitGuildId, guildId);
}
this._lastMainChannelId = next;
this._lastMainGuildId = guildId;
if (!this._accountId) return;
if (this._splitChannelId) {
if (changed) {
this._applyDuplicateChannelMode('main-channel-change');
this._scheduleRemount('main-channel-change');
}
} else this._restoreActiveSplit();
};
for (const name of ['SelectedChannelStore', 'SelectedGuildStore', 'UserStore', 'ChannelStore', 'GuildStore', 'PermissionStore']) {
const store = this._modules[name];
if (!store?.addChangeListener || this._persistenceStores.includes(store)) continue;
try {
store.addChangeListener(this._selectedChannelListener);
this._persistenceStores.push(store);
} catch (e) { this._dbg(`${name} persistence listener unavailable:`, e.message); }
}
}
_removeSelectedChannelPersistence() {
for (const store of this._persistenceStores) {
try { store.removeChangeListener?.(this._selectedChannelListener); } catch { /* ignore */ }
}
this._persistenceStores = [];
this._selectedChannelListener = null;
this._lastMainChannelId = null;
this._lastMainGuildId = null;
}
_cancelSplitRestore() {
if (this._restoreTimer) window.clearTimeout(this._restoreTimer);
this._restoreTimer = null;
this._restoreAttempt = 0;
this._restoreKey = null;
}
_restoreActiveSplit() {
if (this._stopped || !this._settings) return;
this._syncPersistenceAccount();
if (!this._accountId || this._splitChannelId) return;
const guildId = this._getSelectedGuildId();
const channelId = this._settings.rememberSplitPerServer
? this._settings.rememberedSplits[this._accountId]?.[guildId]
: this._settings.activeChannelId;
if (!guildId || !channelId) return;
const key = `${this._accountId}:${guildId}:${channelId}`;
if (key !== this._restoreKey) {
this._cancelSplitRestore();
this._restoreKey = key;
}
// Restore only from live stores, not old context-menu snapshots. Missing
// records/permissions are readiness gaps, not proof a target was deleted.
let channel, main, permission;
try {
channel = this._modules.ChannelStore?.getChannel?.(channelId);
main = this._modules.ChannelStore?.getChannel?.(this._getSelectedChannelId());
const channelGuild = channel?.guild_id || this._modules.ChannelStore?.getChannel?.(channel?.parent_id)?.guild_id;
const mainGuild = main?.guild_id || this._modules.ChannelStore?.getChannel?.(main?.parent_id)?.guild_id;
if (channel && (!this.isSplitTargetChannel(channel) || (channelGuild && channelGuild !== guildId))) {
this._forgetActiveSplit(guildId);
return;
}
// Routing and guild hydration must agree before interpreting permissions.
if (channel && channelGuild === guildId && mainGuild === guildId && this._modules.GuildStore?.getGuild?.(guildId)) {
const flag = this._modules.Permissions?.VIEW_CHANNEL;
if (typeof flag === 'bigint' || typeof flag === 'number') permission = this._modules.PermissionStore?.can?.(flag, channel);
if (permission === false) {
this._forgetActiveSplit(guildId);
return;
}
if (permission === true && this._findLayoutContainer() &&
(this._settings.paneMode === 'floating' || this._dockedWidth(this._resolveDockedLayout()) !== null)) {
this._cancelSplitRestore();
this.open(channelId, channel, { automatic: true });
return;
}
}
} catch (e) { this._dbg('Split restore waiting for stores:', e.message); }
// One bounded startup/route retry budget, plus opportunities on real store
// or layout changes. Events never replenish an exhausted unchanged budget.
const delays = [1200, 2500, 5000, 9000];
if (!this._restoreTimer && this._restoreAttempt < delays.length) {
const timer = window.setTimeout(() => {
if (this._restoreTimer !== timer) return;
this._restoreTimer = null;
if (this._restoreKey === key) this._restoreActiveSplit();
}, delays[this._restoreAttempt++]);
this._restoreTimer = timer;
}
}
_clearPaneRefs() {
this._paneEl = null;
this._paneTitle = null;
this._paneBody = null;
}
_closeMemberListIfOpen() {
// Keep BetterChat clean by collapsing Discord's native member list only when
// it is actually open. Discord exposes the same toolbar toggle as
// "Hide Member List" when open and "Show Member List" when already closed.
const candidates = [
'button[aria-label*="Hide Member List" i]',
'button[aria-label*="Hide Members" i]',
'button[aria-label*="Hide Member" i]',
'[role="button"][aria-label*="Hide Member List" i]',
'[role="button"][aria-label*="Hide Members" i]',
'[role="button"][aria-label*="Hide Member" i]',
];
for (const selector of candidates) {
const button = document.querySelector(selector);
if (!button || button.closest('[data-ssv="pane"]')) continue;
try {
button.click();
this._dbg('Closed Discord member list for cleaner BetterChat layout');
return true;
} catch (e) {
this._dbg('Member list close failed:', e.message);
return false;
}
}
this._dbg('Member list already closed or toggle not found');
return false;
}
_forgetActiveSplit(guildId = this._splitGuildId || this._getSelectedGuildId()) {
if (!this._settings) return;
this._cancelSplitRestore();
if (this._settings.rememberSplitPerServer && this._accountId && this._accountId === this._getCurrentAccountId()) {
const targets = this._settings.rememberedSplits[this._accountId];
if (targets && guildId) delete targets[guildId];
}
this._settings.activeChannelId = null;
this._writeSessionActiveSplit(null);
this._saveSettings();
}
_rememberActiveSplit(channelId) {
if (!this._settings) return;
const accountId = this._getCurrentAccountId();
if (!accountId || accountId !== this._accountId) return;
if (this._settings.rememberSplitPerServer && isDiscordId(this._splitGuildId) && isDiscordId(channelId)) {
this._settings.rememberedSplits ??= Object.create(null);
const targets = this._settings.rememberedSplits[accountId] ??= Object.create(null);
targets[this._splitGuildId] = channelId;
}
this._settings.activeChannelId = channelId;
this._writeSessionActiveSplit(channelId);
this._saveSettings();
}
_isPaneAttached() {
return !!this._paneEl && document.body.contains(this._paneEl);
}
_isPaneDetached() {
return !!this._paneEl && !document.body.contains(this._paneEl);
}
_renderPlaceholder(heading, detail, diagnostic = null) {
if (!this._paneBody) return;
// The body may currently be owned by React. Unmount before direct DOM
// replacement so fallback diagnostics do not leave stale React listeners.
this._unmountReactRoot();
const icon = document.createElement('div');
icon.className = 'ssv-placeholder-icon';
icon.textContent = '▫';
const h = document.createElement('div');
h.className = 'ssv-placeholder-heading';
h.textContent = heading;
const d = document.createElement('div');
d.className = 'ssv-placeholder-detail';
d.textContent = detail;
const nodes = [icon, h, d];
if (diagnostic) {
const diag = document.createElement('div');
diag.className = 'ssv-placeholder-detail ssv-placeholder-diagnostic';
diag.textContent = diagnostic;
nodes.push(diag);
}
this._paneEl?.classList.remove('ssv-native-composerless');
this._paneBody.classList.remove('ssv-native');
this._paneBody.classList.add('ssv-placeholder');
this._paneBody.replaceChildren(...nodes);
}
_applyWidth(w) {
this._setPaneStyle('width', `${w}px`);
}
_applyPanePlacement() {
if (this._settings?.paneMode === 'floating') {
this._applyFloatingRect(this._clampFloatingRect(this._settings.floatingRect));
} else {
this._applyDockedLayout();
if (this._paneEl) {
this._paneEl.style.left = '';
this._paneEl.style.top = '';
this._paneEl.style.height = '';
}
}
}
_applyFloatingRect(rect) {
this._setDockedSuspended(false);
this._restoreCompactToolbar();
const next = normalizeFloatingRect(rect);
this._settings.floatingRect = next;
this._restoreDockedMembers();
this._removeDockedGeometryObserver();
this._restoreNativeRowPosition();
if (!this._paneEl) return;
this._setPaneStyle('display', '');
this._paneEl.style.left = `${next.left}px`;
this._paneEl.style.top = `${next.top}px`;
this._paneEl.style.width = `${next.width}px`;
this._paneEl.style.height = `${next.height}px`;
document.documentElement.style.setProperty('--ssv-split-width', `${next.width}px`);
}
_persistFloatingRect(rect) {
this._settings.floatingRect = this._clampFloatingRect(normalizeFloatingRect(rect));
this._settings.currentWidth = this._settings.floatingRect.width;
this._hasSavedFloatingRect = true;
this._saveSettings();
return this._settings.floatingRect;
}
_clampFloatingRect(rect) {
const viewportWidth = Math.max(window.innerWidth || DEFAULT_FLOATING_RECT.width, DEFAULT_FLOATING_RECT.width);
const viewportHeight = Math.max(window.innerHeight || DEFAULT_FLOATING_RECT.height, DEFAULT_FLOATING_RECT.height);
const width = Math.max(MIN_WIDTH, Math.min(rect.width, Math.max(MIN_WIDTH, viewportWidth - 24)));
const height = Math.max(MIN_FLOATING_HEIGHT, Math.min(rect.height, Math.max(MIN_FLOATING_HEIGHT, viewportHeight - 24)));
return {
left: Math.max(12, Math.min(rect.left, Math.max(12, viewportWidth - width - 12))),
top: Math.max(12, Math.min(rect.top, Math.max(12, viewportHeight - height - 12))),
width,
height,
};
}
toggleFloatingMode() {
if (!this._settings) return null;
const nextMode = this._settings.paneMode === 'floating' ? 'docked' : 'floating';
const layout = nextMode === 'docked' ? this._resolveDockedLayout() : null;
if (nextMode === 'docked' && this._paneEl && (!layout || this._dockedWidth(layout) === null)) {
this._toast('BetterChat could not find a safe Discord chat row with enough room', 'warning');
return { paneMode: this._settings.paneMode, floatingRect: this._settings.floatingRect };
}
if (nextMode === 'floating') {
// Keep the user's last floating location instead of reusing the docked
// pane's right-side bounds. The whole point of breakout is stable position.
const nextRect = this._hasSavedFloatingRect
? this._settings.floatingRect
: DEFAULT_FLOATING_RECT;
this._settings.floatingRect = this._clampFloatingRect(nextRect);
this._hasSavedFloatingRect = true;
}
this._settings.paneMode = nextMode;
this._saveSettings();
if (this._paneEl) {
this._paneEl.classList.toggle('ssv-floating', nextMode === 'floating');
(nextMode === 'floating' ? document.body : layout.host).appendChild(this._paneEl);
this._applyPanePlacement();
}
this._toast(nextMode === 'floating' ? 'BetterChat broken out; drag the header to move it' : 'BetterChat docked right', 'info');
return { paneMode: this._settings.paneMode, floatingRect: this._settings.floatingRect };
}
// ─── Native render ───────────────────────────────────────────────────────────
// Returns the plugin's own minimal ErrorBoundary class (or Discord's if found).
// Lazily created so React must be discovered first.
_getErrorBoundary() {
if (this._SsvErrorBoundary) return this._SsvErrorBoundary;
const React = this._modules.React;
if (!React?.Component) return null;
const plugin = this;
class SsvErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, errorText: null };
}
static getDerivedStateFromError(err) { return { hasError: true, errorText: err?.message || String(err || 'unknown render error') }; }
componentDidCatch(err, info) {
console.error('[BetterChat] Render boundary caught:', err);
plugin._recordCrashEvent('render-boundary-caught', {
error: plugin._stringifyLogArgs([err])[0],
componentStack: info?.componentStack ?? null,
});
}
render() {
if (this.state.hasError) return React.createElement('div', { className: 'ssv-placeholder-detail ssv-placeholder-diagnostic' }, [
React.createElement('div', { key: 'title' }, 'Native render error caught.'),
React.createElement('code', { key: 'error', style: { display: 'block', marginTop: '8px', whiteSpace: 'pre-wrap' } }, this.state.errorText || 'unknown render error'),
React.createElement('div', { key: 'hint', style: { marginTop: '8px' } }, 'Run BetterChatDebug.printCrashLog().'),
]);
return this.props.children;
}
}
this._SsvErrorBoundary = SsvErrorBoundary;
return SsvErrorBoundary;
}
_unmountReactRoot() {
this._releaseSplitAttachmentScan?.();
this._releaseSplitAttachmentScan = null;
if (!this._reactRoot) return;
try {
if (this._reactRoot._legacy) {
this._modules.ReactDOM?.unmountComponentAtNode?.(this._reactRoot._el);
} else {
this._reactRoot.unmount();
}
} catch (e) {
this._dbg('Unmount error:', e.message);
}
this._reactRoot = null;
}
_clearNativeRenderRetries() {
for (const timer of this._nativeRenderTimers) window.clearTimeout(timer);
this._nativeRenderTimers.clear();
}
_nativeRenderModulesReady() {
return !!(
this._modules.React &&
this._modules.ReactDOM &&
this._modules.SplitViewComponent
);
}
_scheduleNativeRenderRetry(channelId, channelHint = null, firstResult = null, automatic = false) {
const epoch = this._openEffectsEpoch;
const missing = firstResult?.missing ?? [];
if (!missing.length || !this._paneBody) return;
this._clearNativeRenderRetries();
for (const delay of [350, 1000, 2500, 5000, 9000]) {
const timer = window.setTimeout(() => {
if (!this._nativeRenderTimers.has(timer)) return;
this._nativeRenderTimers.delete(timer);
if (this._stopped || this._splitChannelId !== channelId || !this._paneBody) return;
this.discoverModules();
const result = this._tryNativeRender(channelId, channelHint, false, this._nativeRenderVariant);
if (result.ok) {
this._clearNativeRenderRetries();
if (!automatic && epoch === this._openEffectsEpoch) this._scheduleScrollToBottom('split', `native-render-retry-${delay}ms`);
return;
}
if (this._nativeRenderTimers.size === 0) {
this._dbg('Native render still unavailable after retries:', result);
}
}, delay);
this._nativeRenderTimers.add(timer);
}
}
_normalizeNativeVariant(variant = null) {
const value = String(variant || this._nativeRenderVariant || 'sidebar').toLowerCase();
if (['sidebar', 'full', 'composer'].includes(value)) return 'sidebar';
if (['none', 'no-input', 'noinput'].includes(value)) return 'none';
return 'composerless';
}
_buildNativeRenderProps(channel, guild, variant) {
const props = { channel, guild };
if (variant === 'sidebar') {
props.chatInputType = this._modules.ChatInputTypes?.SIDEBAR;
return props;
}
// Diagnostic path for legacy 0.1.212: legacy 0.1.211 proved Discord throws
// "chat input type must be set" if chatInputType is undefined. Keep the
// required SIDEBAR type so the native invariant is satisfied, while passing
// conservative read-only/no-composer hints and visually suppressing the
// duplicate composer with CSS. If this returns to richValue/isEditorEmpty,
// the crash is specifically inside Discord's sidebar composer state.
props.chatInputType = this._modules.ChatInputTypes?.SIDEBAR;
props.renderChatInput = false;
props.showChatInput = false;
props.shouldRenderChatInput = false;
props.disableChatInput = true;
props.hideChatInput = true;
props.readOnly = true;
props.isReadOnly = true;
props.allowSend = false;
return props;
}
_ensureNativeHistory(channelId) {
const warn = () => {
if (this._nativeHistoryWarned) return;
this._nativeHistoryWarned = true;
console.warn('[BetterChat] Native history loading unavailable; chat remains native.');
};
try {
const W = BdApi.Webpack;
const store = this._modules.MessageStore ??= W?.getStore?.('MessageStore');
const messages = store?.getMessages?.(channelId);
if (!messages || messages.loadingMore || (messages.ready && !messages.cached)) return;
const manager = this._modules.MessageManager ??= W?.getModule?.(m =>
typeof m?.fetchMessages === 'function' && typeof m.loadSelectedChannelIfNecessary === 'function',
{ searchExports: true });
if (!manager) return;
// Let Discord own request deduplication, thread startup and cached/jump state.
const guildId = this._getChannelGuildId(this._getChannel(channelId));
manager.fetchMessages({ guildId, channelId })?.catch?.(warn);
} catch { warn(); }
}
// Attempts to mount Discord's internal SplitViewComponent. The default legacy
// variant satisfies Discord's chatInputType invariant while visually suppressing
// the duplicate composer to isolate the richValue/isEditorEmpty crash.
// Returns { ok: true } on success, { ok: false, missing?: string[], error?: string } on failure.
_tryNativeRender(channelId, channelHint = null, rediscoverIfMissing = true, variant = null) {
if (rediscoverIfMissing && !this._nativeRenderModulesReady()) this.discoverModules();
const { React, ReactDOM, SplitViewComponent, ChatInputTypes, GuildStore } = this._modules;
const renderVariant = this._normalizeNativeVariant(variant);
const missing = [];
if (!React) missing.push('React');
if (!ReactDOM) missing.push('ReactDOM');
if (!SplitViewComponent) missing.push('SplitViewComponent');
if (!ChatInputTypes?.SIDEBAR) missing.push('ChatInputTypes.SIDEBAR');
if (missing.length > 0) {
this._renderMode = 'placeholder';
this._dbg('Native render unavailable — missing:', missing.join(', '));
return { ok: false, missing };
}
const channel = this._getChannel(channelHint ?? channelId);
const guildId = this._getChannelGuildId(channel);
const guild = guildId ? (GuildStore?.getGuild?.(guildId) ?? null) : null;
try {
this._unmountReactRoot();
const ErrorBoundary = this._getErrorBoundary();
const renderProps = this._buildNativeRenderProps(channel, guild, renderVariant);
let content = React.createElement(SplitViewComponent, renderProps);
const plugin = this;
let active = true, release = null;
this._releaseSplitAttachmentScan = () => { active = false; release?.(); };
const NativeScanTarget = function NativeScanTarget({ children }) {
React.useEffect(() => {
if (!active || plugin._stopped || plugin._splitChannelId !== channelId) return;
const dispose = plugin._retainAttachmentScanTarget(channelId);
release = dispose;
plugin._ensureNativeHistory(channelId);
return dispose;
}, []);
return children;
};
content = this._withNativeProviders(React.createElement(NativeScanTarget, {}, content), { theme: true });
if (ErrorBoundary) {
content = React.createElement(ErrorBoundary, {}, content);
}
this._paneEl?.classList.toggle('ssv-native-composerless', renderVariant !== 'sidebar');
this._paneBody.classList.remove('ssv-placeholder');
this._paneBody.classList.add('ssv-native');
if (ReactDOM.createRoot) {
this._reactRoot = ReactDOM.createRoot(this._paneBody);
this._reactRoot.render(content);
} else {
// React 17 / legacy render path
ReactDOM.render(content, this._paneBody);
this._reactRoot = { _legacy: true, _el: this._paneBody };
}
this._renderMode = 'native';
this._log(`Native render mounted: ${channelId} [${renderVariant}]`);
return { ok: true, variant: renderVariant };
} catch (e) {
this._unmountReactRoot();
this._err('Native render failed:', e);
this._recordCrashEvent('native-render-failed', { error: this._stringifyLogArgs([e])[0], channelId, variant: renderVariant });
this._renderMode = 'placeholder';
return { ok: false, error: e.message };
}
}
_withNativeProviders(content, { theme = false } = {}) {
// Use native live providers and the native impression hook. Never copy a
// context snapshot, invent defaults, or suppress missing-provider warnings.
try {
const W = BdApi.Webpack, React = this._modules.React;
if (!W?.getModule || !React) return content;
if (!this._nativeProviders) {
const Theme = W.getModule(m => typeof m === 'function' &&
['RootThemeContextProvider', 'themeOverride', 'getWindowFocused'].every(k => String(m).includes(k)), { searchExports: true });
const Analytics = W.getModule(m => m?.Provider && typeof m._currentValue === 'function' &&
String(m._currentValue).includes('AnalyticsTrackImpressionContext'), { searchExports: true });
const track = W.getModule(m => typeof m === 'function' &&
['trackOnInitialLoad', 'impression_', 'sequenceId'].every(k => String(m).includes(k)), { searchExports: true });
if (Theme && Analytics && track) this._nativeProviders = { Theme, Analytics, track };
}
const providers = this._nativeProviders;
if (!providers) return content;
if (theme) content = React.createElement(providers.Theme, {}, content);
return React.createElement(providers.Analytics.Provider, { value: providers.track }, content);
} catch { return content; }
}
// ─── MiniChat-style Breakout popouts ─────────────────────────────────────────
_getBreakoutModuleStatus() {
const m = this._modules || {};
return {
React: !!m.React,
SplitViewComponent: !!m.SplitViewComponent,
ChatInputTypesSidebar: !!m.ChatInputTypes?.SIDEBAR,
PopoutActions: !!m.PopoutActions?.open,
PopoutActionsClose: !!m.PopoutActions?.close,
PopoutWindow: !!m.PopoutWindow,
PopoutWindowStore: !!m.PopoutWindowStore,
NativeAlwaysOnTop: !!m.Native?.setAlwaysOnTop,
Header: !!m.Header,
Bar: !!m.Bar,
IconUtils: !!m.IconUtils,
AckActions: !!m.AckActions?.ack,
};
}
inspectBreakoutModules() {
// Reuse live identities; rescan only when required modules are absent.
const cached = this._getBreakoutModuleStatus();
if (![cached.React, cached.SplitViewComponent, cached.ChatInputTypesSidebar, cached.PopoutActions, cached.PopoutWindow].every(Boolean)) this.discoverModules();
const status = this._getBreakoutModuleStatus();
const missingRequired = Object.entries({
React: status.React,
SplitViewComponent: status.SplitViewComponent,
ChatInputTypesSidebar: status.ChatInputTypesSidebar,
PopoutActions: status.PopoutActions,
PopoutWindow: status.PopoutWindow,
}).filter(([, ok]) => !ok).map(([name]) => name);
const result = {
ok: missingRequired.length === 0,
missingRequired,
status,
openBreakouts: this._listBreakoutStatus(),
ackBridgeInstalled: !!this._dispatcher,
sidebarInputPatched: !!this._origSidebarInput,
};
console.log('[BetterChat] Breakout module inspection:', result);
return result;
}
_installBreakoutInputPatches() {
const sidebar = this._modules.ChatInputTypes?.SIDEBAR;
if (!sidebar || this._origSidebarInput) return;
this._origSidebarInput = {
gifs: sidebar.gifs ? { ...sidebar.gifs } : null,
stickers: sidebar.stickers ? { ...sidebar.stickers } : null,
commands: sidebar.commands ? { ...sidebar.commands } : null,
};
// Matches the MiniChat pattern: make the sidebar composer act like a durable
// chat input instead of a transient secondary input that loses focus after
// the main Discord view changes. Keep slash commands explicitly enabled:
// Discord's native split/sidebar chat uses this same ChatInputTypes.SIDEBAR
// object, so disabling `commands` here removes `/` autocomplete from both
// docked BetterChat and Breakout chats.
if (sidebar.gifs) sidebar.gifs.button = true;
if (sidebar.stickers) {
sidebar.stickers.button = true;
sidebar.stickers.autoSuggest = true;
}
if (sidebar.commands) sidebar.commands.enabled = true;
this._recordCrashEvent('breakout-input-patches-installed', {
commandsEnabled: sidebar.commands?.enabled ?? null,
});
}
_restoreBreakoutInputPatches() {
const sidebar = this._modules.ChatInputTypes?.SIDEBAR;
const orig = this._origSidebarInput;
if (!sidebar || !orig) return;
try {
if (orig.gifs && sidebar.gifs) Object.assign(sidebar.gifs, orig.gifs);
if (orig.stickers && sidebar.stickers) Object.assign(sidebar.stickers, orig.stickers);
if (orig.commands && sidebar.commands) Object.assign(sidebar.commands, orig.commands);
} catch (e) {
this._dbg('Breakout input restore failed:', e.message);
}
this._origSidebarInput = null;
}
_installBreakoutAckBridge() {
if (this._dispatcher || !this._modules.AckActions?.ack) return;
const dispatcher = this._modules.UserStore?._dispatcher ?? BdApi.Webpack?.Stores?.UserStore?._dispatcher ?? null;
if (!dispatcher?.subscribe || !dispatcher?.dispatch) return;
this._dispatcher = dispatcher;
this._onBreakoutMessage = (event) => {
if (!event?.channelId || event.optimistic || event.isPushNotification || !event.message?.id) return;
this._activateBreakout(event.channelId, event.message.id);
};
try { dispatcher.subscribe('MESSAGE_CREATE', this._onBreakoutMessage); }
catch { this._removeBreakoutAckBridge(); return; }
this._recordCrashEvent('breakout-ack-bridge-installed');
}
_removeBreakoutAckBridge() {
try { this._dispatcher?.unsubscribe?.('MESSAGE_CREATE', this._onBreakoutMessage); } catch { /* ignore */ }
this._dispatcher = null;
this._onBreakoutMessage = null;
}
_listBreakoutStatus() {
// Never expose DOM/windows or transfer-pane references through JSON diagnostics.
return Array.from(this._breakouts.values(), ({ breakoutId, channelId, windowKey, title, createdAt, ready }) =>
({ breakoutId, channelId, windowKey, title, createdAt, ready: ready === true }));
}
_breakoutIsCurrent(record) {
return !!record && !this._stopped && this._breakouts.get(record.channelId) === record;
}
_breakoutScroller(record) {
const root = record?.root;
const messages = root?.querySelector?.('[data-list-id="chat-messages"], [role="log"]');
return messages?.closest?.('[class*="scroller_"]') ??
root?.querySelector?.('[class*="messagesWrapper_"] [class*="scroller_"]') ?? null;
}
_breakoutIsViewed(record) {
const doc = record?.root?.ownerDocument;
const scroller = this._breakoutScroller(record);
return this._breakoutIsCurrent(record) && record.ready === true && record.root?.isConnected === true &&
doc?.visibilityState === 'visible' && doc.hasFocus?.() === true && !doc.defaultView?.closed &&
!!scroller && scroller.clientHeight > 0 && scroller.clientHeight <= doc.defaultView.innerHeight &&
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= 24;
}
_activateBreakout(recordOrChannelId, messageId) {
const record = typeof recordOrChannelId === 'object' ? recordOrChannelId :
this._breakouts.get(recordOrChannelId) || Array.from(this._breakouts.values()).find(r => r.windowKey === recordOrChannelId);
if (!this._breakoutIsViewed(record)) return { ok: false, skipped: 'Breakout is not being viewed at the bottom' };
// Never enable unconditional automatic ACK or clear the whole local unread
// state. A minimized, blurred or history-scrolled window is not a read receipt.
try {
if (typeof this._modules.AckActions?.ack !== 'function') return { ok: false, skipped: 'Native acknowledgement unavailable' };
this._modules.AckActions.ack(record.channelId, undefined, true, true, messageId);
record.lastActivatedAt = new Date().toISOString();
return { ok: true, channelId: record.channelId };
} catch (e) { return { ok: false, error: e.message }; }
}
_releaseBreakout(record) {
if (!record) return;
record.disposeMount?.();
if (record.setupTimer != null) window.clearTimeout(record.setupTimer);
record.setupTimer = null;
record.releaseAttachmentScan?.();
delete record.releaseAttachmentScan;
record.menuUnpatch?.();
if (record.searchContext && this._breakouts.get(record.channelId) === record) {
try { this._nativeSearchService?.service.cleanUpSearchState(record.searchContext); } catch { /* host changed */ }
}
record.searchContext = null;
record.refreshHeader = null;
record.root = null;
record.showSearch = null;
record.ready = false;
record.transfer = null;
if (this._breakouts.get(record.channelId) !== record) return;
this._breakouts.delete(record.channelId);
if (!this._breakouts.size) {
this._removeBreakoutAckBridge();
this._breakoutSearchUnpatch?.();
this._breakoutSearchUnpatch = null;
this._restoreBreakoutInputPatches();
}
}
_completeBreakoutTransfer(record) {
const transfer = record.transfer;
if (!record.ready || !transfer || !this._breakoutIsCurrent(record)) return;
record.transfer = null;
// A later split open/close/navigation owns the pane now, even for the same
// channel. Never close a replacement pane or the native main chat.
if (this._splitChannelId === record.channelId && this._paneEl === transfer.pane &&
this._openEffectsEpoch === transfer.epoch) this.close();
}
_waitForBreakout(record, attempt = 0) {
if (!this._breakoutIsCurrent(record)) return;
const root = record.root;
const failed = root?.querySelector?.('.ssv-placeholder-diagnostic, .ssv-breakout-diagnostic');
const messages = root?.querySelector?.('[data-list-id="chat-messages"], [role="log"]');
const history = this._modules.MessageStore?.getMessages?.(record.channelId);
const historyReady = history?.ready === true && !history.cached && !history.loadingMore;
const scroller = this._breakoutScroller(record);
const viewportHeight = root?.ownerDocument?.defaultView?.innerHeight;
// Native styles arrive after mount. An unstyled full-history list is not
// a usable viewport, even though its height is positive and history is ready.
const viewportReady = scroller?.clientHeight > 0 && scroller.clientHeight <= viewportHeight;
if (!failed && historyReady && root?.isConnected && messages && viewportReady) {
record.ready = true;
this._completeBreakoutTransfer(record);
this._activateBreakout(record);
return;
}
if (failed || attempt >= 100) {
record.failed = true;
record.transfer = null;
this._toast('Breakout chat did not become ready; the split was kept open', 'warning');
return;
}
record.setupTimer = window.setTimeout(() => {
record.setupTimer = null;
this._waitForBreakout(record, attempt + 1);
}, 100);
}
_getBreakoutWindowKey(channelId) {
// Electron closes windows asynchronously. Reusing the same host key can
// let the previous close destroy an immediately reopened window.
this._breakoutWindowEpoch ??= Date.now().toString(36);
return `DISCORD_SSV_BREAKOUT_${channelId}_${this._breakoutWindowEpoch}_${this._breakoutCounter + 1}`;
}
_getChannelDisplayName(channel) {
if (!channel) return 'Chat';
const rawName = channel.name || channel.rawRecipients?.[0]?.username || channel.recipients?.[0]?.username || channel.id;
const prefix = channel.type === 1 ? '@' : channel.type === 3 ? '' : '#';
return `${prefix}${rawName}`;
}
_getBreakoutSubtitle(channel, guild) {
if (!channel) return 'Breakout Chat';
const parts = [];
if (guild?.name) parts.push(guild.name);
const parent = channel.parent_id ? this._getChannel(channel.parent_id) : null;
if (parent?.name) parts.push(`#${parent.name}`);
if (this.isThreadChannel(channel)) parts.push('thread');
parts.push('Breakout Chat');
return parts.join(' · ');
}
_syncBreakoutDocumentStyles(doc) {
try {
if (!doc || doc === document) return;
doc.querySelectorAll('[data-ssv-breakout-synced]').forEach(el => el.remove());
document.querySelectorAll('bd-head style, style[data-bd], style[id^="bd"], style#SplitView').forEach(el => {
const clone = el.cloneNode(true);
clone.setAttribute('data-ssv-breakout-synced', 'true');
doc.head.appendChild(clone);
});
doc.documentElement.className = document.documentElement.className;
doc.documentElement.style.cssText = document.documentElement.style.cssText;
const mainMount = document.getElementById('app-mount');
const popMount = doc.getElementById('app-mount');
if (mainMount && popMount) popMount.className = mainMount.className;
} catch (e) {
this._dbg('Breakout style sync failed:', e.message);
}
}
_getBreakoutHeaderAdapter(channel) {
// Cache component types/methods only, never a selected-channel action closure.
if (!this._breakoutHeaderAdapter) {
try {
const pins = document.querySelector('section [aria-label="Pinned Messages"]');
const adapter = this._getNativePinsAdapter({ channelId: this._getSelectedChannelId(), actions: [{ key: 'pins', el: pins }] });
let fiber = pins && BdApi.ReactUtils?.getInternalInstance(pins), owner;
for (let depth = 0; fiber && depth < 40; depth++, fiber = fiber.return) {
if (typeof fiber.stateNode?.openChannelContextMenu === 'function' &&
typeof fiber.stateNode?.openThreadContextMenu === 'function' &&
typeof fiber.stateNode?.renderSidebar === 'function') { owner = fiber.stateNode; break; }
}
if (adapter && owner) {
const sidebar = owner.renderSidebar.call({ props: { channel, section: 'SEARCH' } });
this._breakoutHeaderAdapter = { Popout: adapter.Popout, Content: adapter.Content,
SearchResultsLoader: sidebar?.type, openChannelMenu: owner.openChannelContextMenu,
openThreadMenu: owner.openThreadContextMenu };
}
} catch (error) { this._dbg('Breakout header adapter unavailable:', error.message); }
}
return this._breakoutHeaderAdapter ?? null;
}
_getNativeSearchService() {
if (this._nativeSearchService) return this._nativeSearchService;
try {
const W = BdApi.Webpack;
const service = W?.getModule(m => typeof m?.getSearchInputText === 'function' &&
typeof m?.cleanUpSearchState === 'function' && typeof m?.fetchMessages === 'function', { searchExports: true });
const query = W?.getModule(m => typeof m?.updateSearchQueryText === 'function' &&
typeof m?.updateSearchMode === 'function', { searchExports: true });
if (service && query) this._nativeSearchService = { service, query };
} catch { /* native search changed: leave it unavailable */ }
return this._nativeSearchService ?? null;
}
_installBreakoutSearchScope() {
if (this._breakoutSearchUnpatch) return true;
try {
const factory = BdApi.Webpack?.getModule(m => typeof m?.create === 'function' &&
String(m.create).includes('searchType') && typeof m.cancel === 'function' && typeof m.get === 'function', { searchExports: true });
if (!factory || !this._getNativeSearchService()) return false;
this._breakoutSearchUnpatch = BdApi.Patcher.before(PLUGIN_NAME, factory, 'create', (_self, args) => {
const request = args[0], record = this._breakouts.get(request?.id);
// GUILD_CHANNEL has a channel-keyed result store but still needs an
// explicit filter. Never infer request ownership from OS focus.
if (request?.searchType !== 'GUILD_CHANNEL' || !request.searchQuery ||
!this._breakoutIsCurrent(record) || !record.root?.isConnected) return;
args[0] = { ...request, searchQuery: { ...request.searchQuery, channel_id: [record.channelId], search_everywhere: false } };
});
return typeof this._breakoutSearchUnpatch === 'function';
} catch { return false; }
}
_submitBreakoutSearch(record, queryText) {
if (!this._breakoutIsCurrent(record) || !record.root?.isConnected || !this._breakoutSearchUnpatch) return false;
const native = this._getNativeSearchService(), channel = this._getChannel(record.channelId);
if (!native || !channel?.guild_id || !queryText.trim()) return false;
const context = { type: 'GUILD_CHANNEL', guildId: channel.guild_id, channelId: channel.id };
native.query.updateSearchQueryText(context, queryText);
record.searchContext = context;
record.showSearch?.(true);
native.service.fetchMessages({ searchContext: context, searchQueryString: queryText, offset: 0, searchEverywhere: false });
return true;
}
_openBreakoutMenu(record, adapter, event) {
if (!this._breakoutIsCurrent(record)) return;
const channel = this._getChannel(record.channelId);
if (!channel || typeof BdApi.ContextMenu?.patch !== 'function') return;
record.menuUnpatch?.();
const current = () => this._breakoutIsCurrent(record) && record.root?.isConnected;
const disposers = ['channel-context', 'thread-context'].map(id => BdApi.ContextMenu.patch(id, (tree, props) => {
if (props?.channel?.id !== channel.id || !current()) return;
const guarded = this._guardHeaderMenuTree(tree, current);
if (!guarded?.props) return;
const onClose = guarded.props.onClose;
tree.props = { ...guarded.props, onClose: (...args) => {
record.menuUnpatch?.();
return onClose?.(...args);
} };
}));
record.menuUnpatch = () => { disposers.forEach(fn => fn?.()); record.menuUnpatch = null; };
try {
const method = this.isThreadChannel(channel) ? adapter.openThreadMenu : adapter.openChannelMenu;
method.call({ props: { guild: this._modules.GuildStore?.getGuild?.(channel.guild_id) } }, event, channel);
} catch (error) { record.menuUnpatch?.(); this._dbg('Breakout menu unavailable:', error.message); }
}
_buildBreakoutContent(channelId, breakoutId, windowKey) {
const { React, SplitViewComponent, ChatInputTypes, GuildStore } = this._modules;
const channel = this._getChannel(channelId);
const guildId = this._getChannelGuildId(channel);
const guild = guildId ? (GuildStore?.getGuild?.(guildId) ?? null) : null;
const ErrorBoundary = this._getErrorBoundary();
const title = this._getChannelDisplayName(channel);
const plugin = this;
const adapter = this._getBreakoutHeaderAdapter(channel);
function BreakoutHeader() {
const [pinsOpen, setPinsOpen] = React.useState(false);
const [, refresh] = React.useState(0);
const pinsRef = React.useRef(null);
const current = () => plugin._breakouts.get(channelId)?.breakoutId === breakoutId && !plugin._stopped;
React.useEffect(() => {
const record = plugin._breakouts.get(channelId);
if (!current()) return;
const update = () => refresh(value => value + 1);
record.refreshHeader = update;
return () => { if (record.refreshHeader === update) record.refreshHeader = null; };
}, []);
const parent = plugin.isThreadChannel(channel) ? plugin._getChannel(channel.parent_id) : null;
const fullTitle = parent?.name ? `#${parent.name} › ${channel.name}` : title;
const label = plugin._settings.hideParentChannelInSplit && parent ? channel.name : fullTitle;
const closePins = () => { setPinsOpen(false); pinsRef.current?.focus(); };
const close = React.createElement('button', { type: 'button', className: 'ssv-pane-header-btn',
title: `Close ${title}`, 'aria-label': `Close ${title}`, onClick: () => { if (current()) plugin.closeBreakout(channelId); } }, '×');
const name = React.createElement('div', { className: 'ssv-breakout-title', title: fullTitle }, label);
if (!adapter) return React.createElement('div', { className: 'ssv-breakout-header' }, close, name,
React.createElement('span', { title: 'Open a guild channel in the main window, then reopen this breakout to discover native controls.' }, 'Controls unavailable'));
const pins = React.createElement(adapter.Popout, { key: 'pins', targetElementRef: pinsRef, shouldShow: pinsOpen,
position: 'bottom', align: 'right', autoInvert: true, nudgeAlignIntoViewport: true, spacing: 8,
ignoreModalClicks: true, clickTrap: true, onRequestClose: closePins,
renderPopout: props => current() ? React.createElement(adapter.Content, { ...props, channel,
onJump: event => { if (!event?.shiftKey) closePins(); } }) : null,
children: trigger => React.createElement('button', { ...trigger, ref: pinsRef, type: 'button',
className: 'ssv-pane-header-btn', 'aria-label': 'Pinned Messages', title: 'Pinned Messages',
'aria-haspopup': 'dialog', 'aria-expanded': pinsOpen,
onClick: () => { if (current()) setPinsOpen(value => !value); },
onKeyDown: event => { if (event.key === 'Escape' && pinsOpen) { event.preventDefault(); closePins(); } } },
React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
React.createElement('path', { d: PIN_ICON_PATH }))) });
const openMore = event => { if (current()) plugin._openBreakoutMenu(plugin._breakouts.get(channelId), adapter, event); };
const more = React.createElement('button', { key: 'more', type: 'button', className: 'ssv-pane-header-btn',
title: 'More channel actions', 'aria-label': 'More', 'aria-haspopup': 'menu',
onClick: openMore,
onKeyDown: event => { if (event.key === 'ArrowDown' && current()) { event.preventDefault(); openMore(event); } },
}, '⋯');
// An owned input avoids native global search subscriptions duplicating
// requests across windows. Parsing, filters, results and pagination stay native.
const search = React.createElement('input', { key: 'search', type: 'search', className: 'ssv-breakout-search',
placeholder: 'Search this chat', 'aria-label': `Search ${title}`, maxLength: 512,
disabled: !plugin._breakoutSearchUnpatch, title: 'Search this chat using Discord search syntax',
onKeyDown: event => {
if (event.key === 'Enter' && current()) {
event.preventDefault(); event.stopPropagation();
plugin._submitBreakoutSearch(plugin._breakouts.get(channelId), event.currentTarget.value);
} else if (event.key === 'Escape') plugin._breakouts.get(channelId)?.showSearch?.(false);
} });
return React.createElement(plugin._modules.Header, { className: 'ssv-breakout-header',
guildId, channelId, channelType: channel.type, toolbar: [pins, more, search],
hideSearch: true, 'aria-label': 'Breakout chat header',
}, close, name);
}
function ScopedSearchResults() {
const discover = () => BdApi.Webpack?.getModule(m => typeof m === 'function' &&
String(m).includes('selectedChannelId:') && String(m).includes('guildId:') && String(m).length < 500, { searchExports: true });
const [Implementation, setImplementation] = React.useState(() => discover());
React.useEffect(() => {
if (Implementation) return;
let timer, attempts = 0, cancelled = false;
const check = () => {
if (cancelled) return;
const component = discover();
if (component) setImplementation(() => component);
else if (++attempts < 40) timer = window.setTimeout(check, 250);
};
check();
return () => { cancelled = true; window.clearTimeout(timer); };
}, [Implementation]);
if (!Implementation) return React.createElement(React.Fragment, null,
// Load the native lazy chunk without mounting a guild-keyed result panel.
adapter?.SearchResultsLoader && React.createElement(adapter.SearchResultsLoader, {}),
React.createElement('div', { role: 'status' }, 'Loading native search results…'));
return React.createElement(ScopedLoadedResults, { Implementation });
}
function ScopedLoadedResults({ Implementation }) {
// Invoke the native wrapper as a component body so its hooks keep their
// owner, then override only the context on its native result component.
const tree = Implementation({ guildId, channelId });
return tree && React.cloneElement(tree, { searchContext: { type: 'GUILD_CHANNEL', guildId, channelId } });
}
const BreakoutRoot = function BreakoutRoot() {
const ref = React.useRef?.(null);
const [searchOpen, showSearch] = React.useState?.(false) ?? [false, () => {}];
React.useEffect?.(() => {
const record = plugin._breakouts.get(channelId);
if (plugin._stopped || record?.breakoutId !== breakoutId) return;
const release = plugin._retainAttachmentScanTarget(channelId);
record.releaseAttachmentScan = release;
record.root = ref.current;
record.showSearch = showSearch;
const doc = record.root?.ownerDocument;
plugin._syncBreakoutDocumentStyles(doc);
if (doc) doc.title = title;
plugin._ensureNativeHistory(channelId);
const activate = () => plugin._activateBreakout(record);
const unload = () => plugin._releaseBreakout(record);
doc?.defaultView?.addEventListener?.('focus', activate);
doc?.defaultView?.addEventListener?.('unload', unload);
doc?.addEventListener?.('visibilitychange', activate);
record.root?.addEventListener?.('scroll', activate, true);
const mountedRoot = record.root;
const token = {};
record.mountToken = token;
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
release();
if (record.releaseAttachmentScan === release) delete record.releaseAttachmentScan;
doc?.defaultView?.removeEventListener?.('focus', activate);
doc?.defaultView?.removeEventListener?.('unload', unload);
doc?.removeEventListener?.('visibilitychange', activate);
mountedRoot?.removeEventListener?.('scroll', activate, true);
if (record.mountToken === token) {
record.root = null;
record.ready = false;
}
};
record.disposeMount = dispose;
if (record.root && record.setupTimer == null) plugin._waitForBreakout(record);
return () => {
dispose();
// React may replay effects during a mount. A new mount token retains
// ownership; an old window's cleanup cannot erase a newer breakout.
queueMicrotask(() => {
if (record.mountToken === token && !record.root) plugin._releaseBreakout(record);
});
};
}, []);
let native = null;
try {
native = React.createElement(SplitViewComponent, {
channel,
guild,
chatInputType: ChatInputTypes?.SIDEBAR,
});
if (ErrorBoundary) native = React.createElement(ErrorBoundary, {}, native);
} catch (e) {
plugin._recordCrashEvent('breakout-content-build-failed', { channelId, breakoutId, windowKey, error: plugin._stringifyLogArgs([e])[0] });
native = React.createElement('div', { className: 'ssv-breakout-diagnostic' }, `Breakout render failed: ${e.message}`);
}
return React.createElement('div', {
className: 'ssv-breakout-root',
'data-ssv-breakout-id': String(breakoutId),
'data-ssv-breakout-channel-id': channelId,
'data-ssv-breakout-title': title,
onMouseDownCapture: () => plugin._activateBreakout(channelId),
onFocusCapture: () => plugin._activateBreakout(channelId),
ref,
},
React.createElement(BreakoutHeader),
React.createElement('div', { className: 'ssv-breakout-body' }, native),
searchOpen && adapter?.SearchResultsLoader ? React.createElement('div', { className: 'ssv-breakout-search-results' },
React.createElement('button', { type: 'button', className: 'ssv-pane-header-btn',
'aria-label': 'Close search results', title: 'Close search results', onClick: () => showSearch(false) }, '×'),
React.createElement(ScopedSearchResults)) : null
);
};
return React.createElement(BreakoutRoot, {});
}
openBreakout(channelId, channelHint = null, { source = 'context' } = {}) {
if (!channelId || this._stopped) return { ok: false, error: 'Breakout requires an active plugin and a channelId' };
if (!this._modules.ChannelStore) this.discoverModules();
const channel = this._getChannel(channelHint ?? channelId);
if (!channel || channel.id !== channelId || !this.isSplitTargetChannel(channel)) {
const result = { ok: false, error: 'Breakout Chat opens available guild text channels and real Discord threads' };
this._toast(result.error, 'warning');
return result;
}
const transfer = source !== 'main' && this._splitChannelId === channelId && this._paneEl
? { pane: this._paneEl, epoch: this._openEffectsEpoch } : null;
const existing = this._breakouts.get(channelId);
if (existing) {
const windowKey = existing.windowKey;
if (existing.root?.ownerDocument?.defaultView?.closed ||
(existing.failed && !this._modules.PopoutWindowStore?.getWindowOpen?.(windowKey))) this._releaseBreakout(existing);
else {
if (transfer) existing.transfer = transfer;
if (existing.failed) { existing.failed = false; this._waitForBreakout(existing); }
const win = existing.root?.ownerDocument?.defaultView ?? this._modules.PopoutWindowStore?.getWindow?.(windowKey);
try { win?.focus?.(); } catch { /* focus may be denied by the host */ }
this._completeBreakoutTransfer(existing);
return { ok: true, reused: true, channelId, windowKey, ready: existing.ready === true };
}
}
const windowKey = this._getBreakoutWindowKey(channelId);
// Do not create a second window when Discord still owns one we did not mount.
if (this._modules.PopoutWindowStore?.getWindowOpen?.(windowKey)) {
try { this._modules.PopoutWindowStore.getWindow?.(windowKey)?.focus?.(); } catch { /* host denied focus */ }
return { ok: true, reused: true, channelId, windowKey, ready: false };
}
const inspection = this.inspectBreakoutModules();
if (!inspection.ok) {
const result = { ok: false, error: `Missing breakout modules: ${inspection.missingRequired.join(', ')}`, inspection };
this._recordCrashEvent('breakout-open-missing-modules', result);
this._toast('Breakout modules missing; run BetterChatDebug.inspectBreakoutModules()', 'warning');
return result;
}
this._installBreakoutInputPatches();
this._installBreakoutAckBridge();
this._installBreakoutSearchScope();
const { React, PopoutActions, PopoutWindow } = this._modules;
const breakoutId = ++this._breakoutCounter;
const title = this._getChannelDisplayName(channel);
const info = { breakoutId, channelId, windowKey, title, createdAt: new Date().toISOString() };
const record = { ...info, transfer, ready: false };
this._breakouts.set(channelId, record);
try {
PopoutActions.open(windowKey, () => this._withNativeProviders(React.createElement(PopoutWindow,
{ windowKey, withTitleBar: true, title, channelId },
this._buildBreakoutContent(channelId, breakoutId, windowKey))), { width: 520, height: 560 });
this._recordCrashEvent('breakout-open-requested', info);
// Initial dimensions are defaults only: never resize or force always-on-top
// after the user/native window manager has positioned the actual window.
this._waitForBreakout(record);
return { ok: true, pending: true, ...info };
} catch (e) {
this._releaseBreakout(record);
const result = { ok: false, error: e.message, channelId, windowKey };
this._recordCrashEvent('breakout-open-failed', result);
this._toast('Breakout Chat failed; the split was kept open', 'error');
return result;
}
}
closeBreakout(channelIdOrWindowKey) {
const record = this._breakouts.get(channelIdOrWindowKey) || Array.from(this._breakouts.values()).find(r => r.windowKey === channelIdOrWindowKey);
if (!record) return { ok: false, error: 'Breakout not tracked', value: channelIdOrWindowKey };
try {
if (typeof this._modules.PopoutActions?.close !== 'function') throw new Error('Native window close unavailable');
if (this._modules.PopoutActions.close(record.windowKey) === false) throw new Error('Native window close refused');
} catch (e) {
const result = { ok: false, error: e.message, channelId: record.channelId, windowKey: record.windowKey };
this._recordCrashEvent('breakout-close-failed', result);
this._toast('Breakout could not close; retry or use the window close button', 'warning');
return result;
}
this._releaseBreakout(record);
const result = { ok: true, channelId: record.channelId, windowKey: record.windowKey };
this._recordCrashEvent('breakout-closed', result);
return result;
}
closeAllBreakouts() {
const records = Array.from(this._breakouts.values());
const results = records.map(record => this.closeBreakout(record.channelId));
// Disabling still releases plugin resources even if native close fails.
if (this._stopped) for (const record of records) this._releaseBreakout(record);
return { ok: results.every(result => result.ok), closed: results.filter(result => result.ok).length,
failed: results.filter(result => !result.ok) };
}
// ─── Open / close ────────────────────────────────────────────────────────────
open(channelId, channelHint = null, { automatic = false } = {}) {
if (!channelId) { this._err('open() requires a channelId'); return; }
// Lazy discovery if called before start() (e.g., from BetterChatDebug.open())
if (!this._modules.ChannelStore) this.discoverModules();
this._syncPersistenceAccount();
if (!this._accountId) return;
const channel = this._getChannel(channelHint ?? channelId);
if (channel && !this.isSplitTargetChannel(channel)) {
if (!automatic) this._toast('BetterChat opens guild text channels and real Discord threads', 'warning');
return;
}
const selectedGuildId = this._getSelectedGuildId();
const selectedChannelId = this._getSelectedChannelId();
if (selectedChannelId && selectedChannelId === channelId) {
this._recordCrashEvent('duplicate-open-same-channel-readonly', { channelId, selectedChannelId });
if (!automatic) this._toast('Same channel open in both panes; BetterChat composer suppressed', 'info');
this._dbg('Opening duplicate main/split channel in read-only split mode:', channelId);
}
const targetGuildId = this._getChannelGuildId(channel);
if (selectedGuildId && targetGuildId && selectedGuildId !== targetGuildId) {
if (!automatic) this._toast('Switch to that server before opening BetterChat', 'warning');
this._dbg('Refusing cross-server BetterChat open:', { selectedGuildId, targetGuildId, channelId });
return;
}
if (automatic && (!channel || !selectedGuildId || targetGuildId !== selectedGuildId)) return;
if (automatic && this._splitChannelId === channelId && this._isPaneAttached() && this._reactRoot) return;
// Discover native menu identities before a new split changes the main
// toolbar's React tree; discovering inside its portal render can see WIP fibers.
this._getBreakoutHeaderAdapter(channel);
this._cancelSplitRestore();
this._clearNativeRenderRetries();
this._cancelOpenEffects();
this._splitChannelId = channelId;
this._splitGuildId = targetGuildId;
if (!automatic) this._rememberActiveSplit(channelId);
if (this._isPaneDetached()) {
this._dbg('Clearing detached pane before opening new split target');
this.destroyDockedPane();
}
if (!this._paneEl && !this.createDockedPane(automatic)) {
this._splitChannelId = null;
this._splitGuildId = null;
return;
}
this._scheduleDockedRight('split-open');
if (!automatic) {
const epoch = this._openEffectsEpoch;
this._memberListTimer = window.setTimeout(() => {
this._memberListTimer = null;
if (!this._stopped && epoch === this._openEffectsEpoch) this._closeMemberListIfOpen();
}, 100);
}
this._syncHeaderTitles();
const result = this._tryNativeRender(channelId, channel, true, this._nativeRenderVariant);
this._applyDuplicateChannelMode('split-open');
if (!result.ok) {
const displayName = channel?.name ?? channelId;
const prefix = channel?.type === 1 ? '@' : channel?.type === 3 ? '' : '#';
const diag = result.missing
? `Missing modules: ${result.missing.join(', ')}`
: `Render error: ${result.error ?? 'unknown'}`;
this._renderPlaceholder(
`${prefix}${displayName}`,
'Native Discord chat unavailable.',
diag
);
this._scheduleNativeRenderRetry(channelId, channel, result, automatic);
}
this._log(`Opened ${channelId}${channel ? ` (#${channel.name})` : ''} [${this._renderMode}]`);
if (!automatic) this._scheduleScrollToBottom('both', 'split-open');
}
_cancelOpenEffects() {
this._openEffectsEpoch++;
if (this._memberListTimer) window.clearTimeout(this._memberListTimer);
this._memberListTimer = null;
for (const timer of this._scrollTimers) window.clearTimeout(timer);
this._scrollTimers.clear();
}
_teardownSplit() {
this._cancelSplitRestore();
this._cancelOpenEffects();
if (this._remountTimer) window.clearTimeout(this._remountTimer);
this._remountTimer = null;
for (const timer of this._redockTimers) window.clearTimeout(timer);
this._redockTimers.clear();
this._clearNativeRenderRetries();
this._splitChannelId = null;
this._splitGuildId = null;
this._renderMode = 'none';
this._paneEl?.classList.remove('ssv-duplicate-main-channel');
this.destroyDockedPane();
this._splitSearchTarget = null;
}
close() {
this._forgetActiveSplit();
this._teardownSplit();
this._log('Closed');
}
isSplitTargetChannel(channel) {
return channel != null && SPLIT_TARGET_TYPES.has(channel.type);
}
isThreadChannel(channel) {
return channel != null && THREAD_TYPES.has(channel.type);
}
// ─── Floating breakout positioning ────────────────────────────────────────────
_onFloatingDragStart = (e) => {
if (this._settings?.paneMode !== 'floating' || !this._paneEl) return;
if (e.target?.closest?.('button')) return;
e.preventDefault();
const rect = this._paneEl.getBoundingClientRect();
this._floatingDragStart = {
x: e.clientX,
y: e.clientY,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
this._paneEl.classList.add('ssv-floating-dragging');
document.addEventListener('mousemove', this._onFloatingDragMove);
document.addEventListener('mouseup', this._onFloatingDragEnd);
window.addEventListener('blur', this._cancelFloatingDrag);
};
_onFloatingDragMove = (e) => {
if (!this._floatingDragStart || !this._paneEl) return;
const next = this._clampFloatingRect({
left: this._floatingDragStart.left + (e.clientX - this._floatingDragStart.x),
top: this._floatingDragStart.top + (e.clientY - this._floatingDragStart.y),
width: this._floatingDragStart.width,
height: this._floatingDragStart.height,
});
this._settings.floatingRect = next;
this._applyFloatingRect(next);
this._scheduleFloatingRectSave();
};
_onFloatingDragEnd = () => {
if (!this._floatingDragStart) return;
const rect = this._settings.floatingRect;
this._cancelFloatingDrag();
this._persistFloatingRect(rect);
this._dbg('Floating position persisted:', this._settings.floatingRect);
};
_cancelFloatingDrag = () => {
this._paneEl?.classList.remove('ssv-floating-dragging');
this._floatingDragStart = null;
document.removeEventListener('mousemove', this._onFloatingDragMove);
document.removeEventListener('mouseup', this._onFloatingDragEnd);
window.removeEventListener('blur', this._cancelFloatingDrag);
};
_onFloatingResizeStart = (e) => {
if (this._settings?.paneMode !== 'floating' || !this._paneEl) return;
e.preventDefault();
e.stopPropagation();
const rect = this._paneEl.getBoundingClientRect();
this._floatingResizeStart = {
x: e.clientX,
y: e.clientY,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
document.body.classList.add('ssv-floating-resizing');
this._paneEl.classList.add('ssv-floating-resizing');
document.addEventListener('mousemove', this._onFloatingResizeMove);
document.addEventListener('mouseup', this._onFloatingResizeEnd);
window.addEventListener('blur', this._cancelFloatingResize);
};
_onFloatingResizeMove = (e) => {
if (!this._floatingResizeStart || !this._paneEl) return;
const next = this._clampFloatingRect({
left: this._floatingResizeStart.left,
top: this._floatingResizeStart.top,
width: this._floatingResizeStart.width + (e.clientX - this._floatingResizeStart.x),
height: this._floatingResizeStart.height + (e.clientY - this._floatingResizeStart.y),
});
this._settings.currentWidth = next.width;
this._settings.floatingRect = next;
this._applyFloatingRect(next);
this._scheduleFloatingRectSave();
};
_onFloatingResizeEnd = () => {
if (!this._floatingResizeStart) return;
const rect = this._settings.floatingRect;
this._cancelFloatingResize();
this._persistFloatingRect(rect);
this._dbg('Floating size persisted:', this._settings.floatingRect);
};
_cancelFloatingResize = () => {
this._paneEl?.classList.remove('ssv-floating-resizing');
document.body.classList.remove('ssv-floating-resizing');
this._floatingResizeStart = null;
document.removeEventListener('mousemove', this._onFloatingResizeMove);
document.removeEventListener('mouseup', this._onFloatingResizeEnd);
window.removeEventListener('blur', this._cancelFloatingResize);
};
_onWindowResize = () => {
if (this._settings?.paneMode !== 'floating') {
this._ensurePaneDockedRight('window-resize');
return;
}
const clamped = this._clampFloatingRect(this._settings.floatingRect);
this._settings.floatingRect = clamped;
this._applyFloatingRect(clamped);
};
// ─── Resize ──────────────────────────────────────────────────────────────────
_onResizeStart = (e) => {
e.preventDefault();
this._resizing = true;
this._resizeMoved = false;
this._resizeStartX = e.clientX;
this._resizeStartWidth = this._settings?.paneMode === 'floating'
? this._settings.floatingRect.width
: (this._paneEl?.getBoundingClientRect().width || this._settings.currentWidth);
document.body.classList.add('ssv-resizing');
this._paneEl?.querySelector('.ssv-resize-handle')?.classList.add('ssv-resizing');
document.addEventListener('mousemove', this._onResizeMove);
document.addEventListener('mouseup', this._onResizeEnd);
window.addEventListener('blur', this._cancelResize);
};
_cancelResize = () => {
if (!this._resizing) return;
this._resizing = false;
document.body.classList.remove('ssv-resizing');
this._paneEl?.querySelector('.ssv-resize-handle')?.classList.remove('ssv-resizing');
document.removeEventListener('mousemove', this._onResizeMove);
document.removeEventListener('mouseup', this._onResizeEnd);
window.removeEventListener('blur', this._cancelResize);
if (this._settings?.paneMode === 'floating') {
this._persistFloatingRect(this._settings.floatingRect);
} else {
this._saveSettings();
}
this._dbg('Resize cancelled after window blur');
};
_onResizeMove = (e) => {
if (!this._resizing || !this._paneEl) return;
// Handle is on the left edge: dragging left widens, dragging right narrows
const delta = this._resizeStartX - e.clientX;
if (!delta && !this._resizeMoved) return;
this._resizeMoved = true;
const maxW = this._settings?.paneMode === 'floating' ? Math.floor(window.innerWidth - 24) : Math.floor(window.innerWidth * 0.8);
const newW = Math.max(MIN_WIDTH, Math.min(maxW, this._resizeStartWidth + delta));
this._settings.currentWidth = newW;
if (this._settings?.paneMode === 'floating') {
this._settings.floatingRect = this._clampFloatingRect({ ...this._settings.floatingRect, width: newW });
this._applyFloatingRect(this._settings.floatingRect);
} else {
this._applyDockedLayout();
}
};
_onResizeEnd = () => {
if (!this._resizing) return;
this._resizing = false;
document.body.classList.remove('ssv-resizing');
this._paneEl?.querySelector('.ssv-resize-handle')?.classList.remove('ssv-resizing');
document.removeEventListener('mousemove', this._onResizeMove);
document.removeEventListener('mouseup', this._onResizeEnd);
window.removeEventListener('blur', this._cancelResize);
if (this._settings?.paneMode === 'floating') {
this._persistFloatingRect(this._settings.floatingRect);
} else {
this._saveSettings();
}
this._dbg(`Width persisted: ${this._settings.currentWidth}px`);
};
// ─── Context menus ───────────────────────────────────────────────────────────
patchContextMenus() {
if (typeof BdApi.ContextMenu?.patch !== 'function') {
this._log('BdApi.ContextMenu.patch not available — context menu items disabled');
return;
}
const buildSplitItem = (channel) =>
BdApi.ContextMenu.buildItem({
type: 'button',
id: 'ssv-open-in-split',
label: 'Split this chat',
action: () => {
this._cacheChannelSnapshot(channel);
this.open(channel.id, channel);
},
});
const buildBreakoutItem = (channel) =>
BdApi.ContextMenu.buildItem({
type: 'button',
id: 'ssv-breakout-chat',
label: 'Breakout Chat',
action: () => {
this._cacheChannelSnapshot(channel);
this.openBreakout(channel.id, channel);
},
});
const inject = (ret, channel) => {
try {
if (!channel?.id) return;
this._cacheChannelSnapshot(channel);
const children = ret?.props?.children;
if (!Array.isArray(children)) return;
children.unshift(
buildSplitItem(channel),
buildBreakoutItem(channel),
BdApi.ContextMenu.buildItem({ type: 'separator' })
);
} catch (e) {
this._dbg('inject failed:', e.message);
}
};
const safe = (fn) => (...args) => { try { fn(...args); } catch (e) { this._dbg('ctx patch error:', e.message); } };
// Real Discord channels/threads only. The native thread route is still the
// product direction, but channel right-clicks are a required entry point and
// should appear at the top of the menu.
for (const navId of ['channel-context', 'thread-context']) {
const unpatch = BdApi.ContextMenu.patch(navId, safe((ret, props) => {
if (props?.betterChatCompactToolbar) return; // More already contains native thread options.
const ch = props?.channel;
if (ch && this.isSplitTargetChannel(ch)) inject(ret, ch);
}));
this._unpatchers.push(unpatch);
}
this._log(`Context menus patched (${this._unpatchers.length} entries)`);
}
unpatchContextMenus() {
this._unpatchers.forEach(fn => { try { fn?.(); } catch { /* already removed */ } });
this._unpatchers = [];
}
_inspectElement(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
tag: el.tagName,
cls: String(el.className || '').slice(0, 240),
id: el.id || null,
role: el.getAttribute?.('role'),
aria: el.getAttribute?.('aria-label'),
text: String(el.textContent || '').trim().slice(0, 180),
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
display: cs.display,
position: cs.position,
zIndex: cs.zIndex,
overflow: cs.overflow,
pointerEvents: cs.pointerEvents,
appRegion: cs.webkitAppRegion,
};
}
_inspectPane() {
const pane = this._paneEl || document.querySelector('[data-ssv="pane"]');
const body = this._paneBody || pane?.querySelector('.ssv-pane-body');
const selectors = [
'[class*="chatContent"]',
'[class*="messagesWrapper"]',
'[class*="scroller"]',
'[class*="channelTextArea"]',
'[class*="typing"]',
'[role="textbox"]',
'[role="log"]',
'ol',
'ul',
'form',
].join(', ');
const nativeEvidence = pane ? Array.from(pane.querySelectorAll(selectors)).slice(0, 40).map(el => this._inspectElement(el)) : [];
const result = {
status: this._collectStatusSnapshot(),
nativeRenderVariant: this._nativeRenderVariant,
pane: this._inspectElement(pane),
paneParent: this._inspectElement(pane?.parentElement),
paneBody: this._inspectElement(body),
paneChildren: pane ? Array.from(pane.children).map(el => this._inspectElement(el)) : [],
bodyChildren: body ? Array.from(body.children).map(el => this._inspectElement(el)) : [],
nativeEvidence,
};
console.log('[BetterChat] Pane inspection:', result);
return result;
}
// ─── Debug API ───────────────────────────────────────────────────────────────
_installDebugAPI() {
const api = {
dumpStatus: () => {
const moduleStatus = Object.fromEntries(
Object.entries(this._modules).map(([k, v]) => [k, v != null ? '✓' : '✗'])
);
console.group('[BetterChat] Status');
console.log('Pane active: ', !!this._paneEl);
console.log('Pane attached: ', this._isPaneAttached());
console.log('Split channel: ', this._splitChannelId ?? '—');
console.log('Saved channel: ', this._settings?.activeChannelId ?? '—');
console.log('Main channel: ', this._getSelectedChannelId() ?? '—');
console.log('Main guild: ', this._getSelectedGuildId() ?? '—');
console.log('Split guild: ', this._getChannelGuildId(this._splitChannelId) ?? '—');
console.log('Render mode: ', this._renderMode);
console.log('Pane mode: ', this._settings?.paneMode ?? 'docked');
console.log('Floating rect: ', this._settings?.floatingRect ?? null);
console.log('Raw saved: ', this._readSavedSettings());
console.log('Settings: ', { ...this._settings });
console.table(moduleStatus);
console.groupEnd();
return {
pane: !!this._paneEl,
paneAttached: this._isPaneAttached(),
channelId: this._splitChannelId,
activeChannelId: this._settings?.activeChannelId ?? null,
selectedMainChannelId: this._getSelectedChannelId(),
selectedMainGuildId: this._getSelectedGuildId(),
splitGuildId: this._getChannelGuildId(this._splitChannelId),
renderMode: this._renderMode,
nativeRenderVariant: this._nativeRenderVariant,
paneMode: this._settings?.paneMode ?? 'docked',
floatingRect: this._settings?.floatingRect ?? null,
savedSettings: this._readSavedSettings(),
settings: { ...this._settings },
modules: moduleStatus,
};
},
open: (channelId) => this.open(channelId),
// Native sidebar rendering is the product path. Real Discord threads are
// preferred, but guild text/announcement channels are accepted so the
// right-click channel workflow remains usable.
// Re-runs module discovery first so fresh data is used.
openNative: (channelId) => {
if (!channelId) {
const result = { ok: false, error: 'openNative requires a channelId or an active BetterChat target.' };
this._log('openNative(undefined):', result);
return result;
}
this.discoverModules();
const channel = this._getChannel(channelId);
if (channel && !this.isSplitTargetChannel(channel)) {
const result = { ok: false, error: 'BetterChat accepts guild text/announcement channels and real Discord thread channel ids.' };
this._renderMode = 'placeholder';
this._toast('BetterChat opens guild text channels and real Discord threads', 'warning');
this._log(`openNative(${channelId}):`, result);
return result;
}
this._splitChannelId = channelId;
this._rememberActiveSplit(channelId);
if (!this._paneEl && !this.createDockedPane()) return { ok: false, error: 'safe layout container not found' };
this._syncHeaderTitles();
const result = this._tryNativeRender(channelId, channel, true, this._nativeRenderVariant);
this._applyDuplicateChannelMode('debug-openNative');
if (!result.ok) {
const diag = result.missing
? `Missing modules: ${result.missing.join(', ')}`
: `Render error: ${result.error ?? 'unknown'}`;
const name = channel?.name ?? channelId;
const prefix = channel?.type === 1 ? '@' : channel?.type === 3 ? '' : '#';
this._renderPlaceholder(`${prefix}${name}`, 'Native render failed.', diag);
this._scheduleNativeRenderRetry(channelId, channel, result);
}
this._log(`openNative(${channelId}):`, result);
this._scheduleScrollToBottom('both', 'debug-openNative');
return result;
},
openNativeVariant: (channelId, variant = 'sidebar') => {
this._nativeRenderVariant = this._normalizeNativeVariant(variant);
return api.openNative(channelId || this._splitChannelId || this._settings?.activeChannelId);
},
setNativeRenderVariant: (variant = 'sidebar') => {
this._nativeRenderVariant = this._normalizeNativeVariant(variant);
if (this._splitChannelId) return api.openNative(this._splitChannelId);
return { nativeRenderVariant: this._nativeRenderVariant };
},
inspectPane: () => this._inspectPane(),
close: () => this.close(),
breakout: (channelId) => this.openBreakout(channelId || this._splitChannelId || this._settings?.activeChannelId),
floatInDiscord: () => this.toggleFloatingMode(),
openBreakout: (channelId) => this.openBreakout(channelId || this._splitChannelId || this._settings?.activeChannelId),
closeBreakout: (channelIdOrWindowKey) => this.closeBreakout(channelIdOrWindowKey || this._splitChannelId || this._settings?.activeChannelId),
closeAllBreakouts: () => this.closeAllBreakouts(),
inspectBreakoutModules: () => this.inspectBreakoutModules(),
activateBreakout: (channelIdOrWindowKey) => this._activateBreakout(channelIdOrWindowKey || this._splitChannelId || this._settings?.activeChannelId),
inspectBreakoutFocus: () => ({
ackBridgeInstalled: !!this._dispatcher,
sidebarInputPatched: !!this._origSidebarInput,
breakouts: this._listBreakoutStatus(),
}),
listBreakouts: () => this._listBreakoutStatus(),
dock: () => {
if (this._settings?.paneMode !== 'floating') return { paneMode: this._settings?.paneMode ?? 'docked' };
return this.toggleFloatingMode();
},
setFloatingRect: (rect) => {
this._persistFloatingRect(rect);
this._settings.paneMode = 'floating';
this._saveSettings();
if (this._paneEl) this.destroyDockedPane();
if (this._splitChannelId || this._settings.activeChannelId) this.open(this._splitChannelId || this._settings.activeChannelId);
return { paneMode: this._settings.paneMode, floatingRect: this._settings.floatingRect, savedSettings: this._readSavedSettings() };
},
getSavedSettings: () => this._readSavedSettings(),
forgetActiveSplit: () => {
this.close();
this._forgetActiveSplit();
return { activeChannelId: this._settings?.activeChannelId ?? null, savedSettings: this._readSavedSettings() };
},
resetSettings: () => {
this.close();
this._settings = {
currentWidth: DEFAULT_WIDTH,
debug: false,
hideParentChannelInSplit: false,
rememberSplitPerServer: false,
rememberedSplits: Object.create(null),
activeChannelId: null,
paneMode: 'docked',
floatingRect: normalizeFloatingRect(null),
};
this._hasSavedFloatingRect = false;
try { BdApi.Data.save(PLUGIN_NAME, SETTINGS_KEY, this._serializeSettings()); } catch (e) { this._dbg('BdApi reset save failed:', e.message); }
try { this._getLocalStorage()?.removeItem?.(LOCAL_STORAGE_KEY); } catch (e) { this._dbg('localStorage reset failed:', e.message); }
this._writeSessionActiveSplit(null);
this._applyWidth(DEFAULT_WIDTH);
return { reset: true, savedSettings: this._readSavedSettings() };
},
closeMemberList: () => this._closeMemberListIfOpen(),
scrollBottom: (target = 'both') => {
this._scheduleScrollToBottom(target, 'debug-scrollBottom');
return { scheduled: true, target };
},
inspectLayout: () => this._inspectLayout(),
inspectTitlebarDragStrip: () => this._inspectTitlebarDragStrip(),
syncTitlebarDragStrip: () => this._syncTitlebarDragStrip('debug-sync'),
paintTitlebarDragStripProbe: () => this._setTitlebarDragStripProbe(true),
clearTitlebarDragStripProbe: () => this._setTitlebarDragStripProbe(false),
forceRemount: () => {
this._scheduleRemount('debug-force-remount', true);
return {
scheduled: true,
channelId: this._splitChannelId,
activeChannelId: this._settings?.activeChannelId ?? null,
};
},
setDebug: (enabled) => {
this._settings.debug = !!enabled;
this._saveSettings();
this._log(`Debug mode: ${this._settings.debug}`);
},
getCrashLog: () => this._getCrashLog(),
printCrashLog: () => {
const log = this._getCrashLog();
console.log('[BetterChat] Crash log:', log);
return log;
},
copyCrashLog: async () => {
const text = this._formatCrashLog();
await navigator.clipboard?.writeText?.(text);
console.log('[BetterChat] Crash log copied to clipboard');
return { copied: true, bytes: text.length };
},
downloadCrashLog: () => {
const text = this._formatCrashLog();
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `betterchat-crash-log-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return { downloaded: true, bytes: text.length };
},
clearCrashLog: () => {
this._crashLog = [];
try { this._getLocalStorage()?.removeItem?.(CRASH_LOG_KEY); } catch { /* ignore */ }
return { cleared: true };
},
setAllowDuplicateChannelForDiagnostics: (enabled) => {
this._allowDuplicateChannelForDiagnostics = !!enabled;
this._recordCrashEvent('set-allow-duplicate-channel-for-diagnostics', { enabled: this._allowDuplicateChannelForDiagnostics });
this._toast('Deprecated: same-channel BetterChat now suppresses its duplicate composer by default', 'info');
this._applyDuplicateChannelMode('debug-deprecated-diagnostic-toggle');
return {
allowDuplicateChannelForDiagnostics: this._allowDuplicateChannelForDiagnostics,
note: 'Same-channel mode now keeps BetterChat open and suppresses the split-pane composer by default.',
};
},
discoverModules: () => {
const result = this.discoverModules();
console.log('[BetterChat] Module discovery result:', result);
return result;
},
};
this._debugAPI = api;
window.BetterChatDebug = api;
window.SplitViewDebug = api; // Compatibility for existing diagnostic commands.
this._dbg('BetterChatDebug installed on window');
}
_removeDebugAPI() {
if (window.BetterChatDebug === this._debugAPI) delete window.BetterChatDebug;
if (window.SplitViewDebug === this._debugAPI) delete window.SplitViewDebug;
this._debugAPI = null;
}
// ─── Lifecycle ───────────────────────────────────────────────────────────────
start() {
try {
this._stopped = false;
this._attachmentScanWarned = false;
this._settings = this._loadSettings();
this.installStyles();
this.discoverModules();
this.patchContextMenus();
this._installCrashDiagnostics();
this._installDebugAPI();
this._installLayoutPersistence();
this._installSelectedChannelPersistence();
window.addEventListener('resize', this._onWindowResize);
this._restoreActiveSplit();
this._log(`Started v${PLUGIN_VERSION} canonical slash-command baseline`);
} catch (e) {
this._err('start() error:', e);
this._toast('Failed to start; see console', 'error');
}
}
stop() {
try {
this._stopped = true;
this._removeAttachmentScanBridge();
this._removeLayoutPersistence();
this._clearDeferredTimers();
this._removeSelectedChannelPersistence();
window.removeEventListener('resize', this._onWindowResize);
this._cancelResize();
this._cancelFloatingResize();
this._renderMode = 'none';
this.closeAllBreakouts();
this._removeBreakoutAckBridge();
this._breakoutSearchUnpatch?.();
this._breakoutSearchUnpatch = null;
this._restoreBreakoutInputPatches();
this._teardownSplit();
this.unpatchContextMenus();
try { BdApi.Patcher?.unpatchAll?.(PLUGIN_NAME); } catch { /* ignore if patcher not used yet */ }
this.removeStyles();
this._removeDebugAPI();
this._removeCrashDiagnostics();
this._modules = {};
this._accountId = undefined;
this._contextChannelCache.clear();
this._SsvErrorBoundary = null;
this._log('Stopped');
} catch (e) {
this._err('stop() error:', e);
}
}
};