ChannelTabs(fix): duplicate tab behaviour + MacOS traffic light overlap (#1357)

* ChannelTabs: fix duplicate tab scroll behaviour + MacOS traffic lights
overlapping tabs

* move large helper to its own utils file
This commit is contained in:
justjxke 2026-09-13 20:00:25 +01:00 • committed by GitHub
parent e99ef13c81
commit 49902c5f40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 72 deletions

View file

@ -8,6 +8,7 @@ import { Flex } from "@components/Flex";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { BasicChannelTabsProps, ChannelTabsProps, clearStaleNavigationContext, closeTab, createTab, handleChannelSwitch, isNavigationFromSource, isTabSelected, moveToTab, openedTabs, openStartupTabs, saveTabs, settings, setUpdaterFunction, useGhostTabs } from "@equicordplugins/channelTabs/util";
import { IS_MAC } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import { useForceUpdater } from "@utils/react";
@ -310,6 +311,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
className={classes(
cl("container"),
tabBarPosition === "top" && cl("container-top"),
IS_MAC && !IS_WEB && tabBarPosition === "top" && cl("container-top-macos"),
!animationHover && cl("no-hover-animation"),
!animationSelection && cl("no-selection-animation"),
!animationDragDrop && cl("no-drag-animation"),

View file

@ -18,6 +18,7 @@ import { JSX } from "react";
import ChannelsTabsContainer from "./components/ChannelTabsContainer";
import * as ChannelTabsUtils from "./util";
import { BasicChannelTabsProps, createTab, handleChannelSwitch, settings } from "./util";
import { clearTabState, useScrollManager } from "./util/scroll";
const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channel: Channel, messageId?: string; }) => {
const { channel, messageId } = props;
@ -94,9 +95,28 @@ export default definePlugin({
replace: "$&if ($1.ctrlKey) return $self.open($2);"
}
},
{
find: "#{intl::CHANNEL_CHAT_HEADING}",
replacement: {
match: /hideSummaries:(\i)===(\i\.\i)\.OVERLAY/,
replace: "$&,vcChannelTabsMain:$1===$2.NORMAL"
}
},
{
find: "#{intl::CHANNEL_MESSAGES_A11Y_LABEL}",
replacement: {
match: /onScroll:(\i)\.handleScroll(?=,onMouseDown:\i\.handleMouseDown)/,
replace: "onScroll:$self.useScrollManager($1,arguments[0].vcChannelTabsMain).handleScroll"
}
}
],
settings,
useScrollManager,
stop() {
clearTabState();
},
start() {
// migrate old settings to new granular keybind settings

View file

@ -76,6 +76,10 @@
z-index: 102;
}
.vc-channeltabs-container-top-macos {
padding-left: 80px;
}
/* as its in the same region as notices, it needs to hide itself when a notice is shown
there's no permanent notices, right? */
[class*="notice"] + .vc-channeltabs-container-top {

View file

@ -0,0 +1,179 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Channel } from "@vencord/discord-types";
import { findByCodeLazy } from "@webpack";
import { MessageActions, useLayoutEffect } from "@webpack/common";
import { settings } from "./constants";
import { ChannelTabsProps } from "./types";
const getWindowId: (target: Window) => string = findByCodeLazy(/return \i\.__DISCORD_WINDOW_ID\}/);
interface ScrollAnchor {
id: string;
offsetFromTop: number;
offsetTop: number;
offsetHeight: number;
clamped: boolean;
}
interface ScrollManager {
props: { channel: Channel; windowId: string; };
getScrollerState(): { scrollTop: number; };
findAnchor(): ScrollAnchor | null;
getAnchorData(messageId: string, scrollTop: number): ScrollAnchor | null;
setAutomaticAnchor(anchor: ScrollAnchor | null): void;
isReady(): boolean;
isJumping(): boolean;
isScrolledToBottom(): boolean;
scrollTo(position: number, animate?: boolean, callback?: () => void): void;
setScrollToBottom(): void;
handleScroll(): void;
}
// cache for the tab state (so like scroll pos etc)
interface TabStateCache {
channelId: string;
scrollPosition: number;
messageId?: string;
offsetFromTop: number;
atBottom: boolean;
timestamp: number;
}
export const tabStateCache = new Map<number, TabStateCache>();
const MAX_CACHE_SIZE = 50;
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
// discord's main chat scroller
let scrollManager: ScrollManager | undefined;
let restoreGeneration = 0;
let restoreFrame: number | undefined;
let pendingRestore: { state: TabStateCache; generation: number; } | undefined;
function cancelRestore() {
restoreGeneration++;
pendingRestore = undefined;
if (restoreFrame !== undefined) {
cancelAnimationFrame(restoreFrame);
restoreFrame = undefined;
}
}
export function clearTabState() {
cancelRestore();
tabStateCache.clear();
}
function evictStaleCache() {
const now = Date.now();
for (const [tabId, cache] of tabStateCache.entries()) {
if (now - cache.timestamp > CACHE_TTL_MS) {
tabStateCache.delete(tabId);
}
}
if (tabStateCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(tabStateCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const entriesToRemove = entries.slice(0, tabStateCache.size - MAX_CACHE_SIZE);
for (const [tabId] of entriesToRemove) {
tabStateCache.delete(tabId);
}
}
}
export function cacheCurrentTabState(tab: ChannelTabsProps | undefined, tabs: ChannelTabsProps[]) {
const wasRestoring = pendingRestore !== undefined || restoreFrame !== undefined;
cancelRestore();
if (wasRestoring || !tab || !scrollManager || scrollManager.props.channel.id !== tab.channelId || !scrollManager.isReady() || scrollManager.isJumping()) return;
if (!settings.store.renderAllTabs && !tabs.some(other => other.id !== tab.id && other.channelId === tab.channelId)) {
tabStateCache.delete(tab.id);
return;
}
const anchor = scrollManager.findAnchor();
tabStateCache.set(tab.id, {
channelId: tab.channelId,
scrollPosition: scrollManager.getScrollerState().scrollTop,
messageId: anchor?.id,
offsetFromTop: anchor?.offsetFromTop ?? 0,
atBottom: scrollManager.isScrolledToBottom(),
timestamp: Date.now()
});
evictStaleCache();
}
function restorePendingState() {
if (!pendingRestore || restoreFrame !== undefined) return;
// restore scroll pos after delay to make sure content loaded
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined;
const pending = pendingRestore;
const manager = scrollManager;
if (!pending || !manager || manager.props.channel.id !== pending.state.channelId || !manager.isReady()) return;
pendingRestore = undefined;
const { state, generation } = pending;
const restoreAnchor = () => {
if (generation !== restoreGeneration || manager !== scrollManager || manager.props.channel.id !== state.channelId) return;
const anchor = state.messageId ? manager.getAnchorData(state.messageId, manager.getScrollerState().scrollTop) : null;
if (anchor || !state.messageId) {
manager.setAutomaticAnchor(anchor ? { ...anchor, offsetFromTop: state.offsetFromTop } : null);
manager.scrollTo(anchor ? anchor.offsetTop - state.offsetFromTop : state.scrollPosition, false, manager.handleScroll);
}
};
if (state.atBottom) {
manager.setAutomaticAnchor(null);
manager.setScrollToBottom();
} else if (state.messageId && !manager.getAnchorData(state.messageId, manager.getScrollerState().scrollTop)) {
let scheduled = false;
MessageActions.jumpToMessage({
channelId: state.channelId,
messageId: state.messageId,
flash: false,
jumpType: "INSTANT",
context: "ChannelTabs",
onJumpComplete: () => {
if (scheduled || generation !== restoreGeneration) return;
scheduled = true;
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined;
restoreAnchor();
});
}
});
} else {
restoreAnchor();
}
});
}
export function restoreTabState(tabId: number, channelId: string) {
cancelRestore();
evictStaleCache();
const state = tabStateCache.get(tabId);
if (!state || state.channelId !== channelId) return;
pendingRestore = { state, generation: restoreGeneration };
restorePendingState();
}
export function useScrollManager(manager: ScrollManager, isMainChat: boolean) {
useLayoutEffect(() => {
if (!isMainChat || manager.props.windowId !== getWindowId(window)) return;
scrollManager = manager;
restorePendingState();
});
useLayoutEffect(() => () => {
if (scrollManager === manager) scrollManager = undefined;
}, [manager]);
return manager;
}

View file

@ -11,6 +11,7 @@ import { NavigationRouter, SelectedChannelStore, SelectedGuildStore, showToast,
import { JSX } from "react";
import { logger, settings } from "./constants";
import { cacheCurrentTabState, clearTabState, restoreTabState, tabStateCache } from "./scroll";
import { BasicChannelTabsProps, ChannelTabsProps, PersistedTabs } from "./types";
const cl = classNameFactory("vc-channeltabs-");
@ -98,15 +99,6 @@ let hydratedUserId: string | undefined;
let hydrationGeneration = 0;
let saveQueue = Promise.resolve();
// cache for the tab state (so like scroll pos etc)
interface TabStateCache {
scrollPosition: number;
timestamp: number;
}
const tabStateCache = new Map<number, TabStateCache>();
const MAX_CACHE_SIZE = 50;
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
// horror
const _ = {
get openedTabs() {
@ -338,62 +330,6 @@ export function moveDraggedTabs(index1: number, index2: number) {
update();
}
function getScrollContainer(): HTMLElement | null {
// discord's main chat scroller
return document.querySelector('[class*="scrollerInner"]') as HTMLElement;
}
function evictStaleCache() {
const now = Date.now();
for (const [tabId, cache] of tabStateCache.entries()) {
if (now - cache.timestamp > CACHE_TTL_MS) {
tabStateCache.delete(tabId);
}
}
if (tabStateCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(tabStateCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const entriesToRemove = entries.slice(0, tabStateCache.size - MAX_CACHE_SIZE);
for (const [tabId] of entriesToRemove) {
tabStateCache.delete(tabId);
}
}
}
function cacheCurrentTabState() {
if (!settings.store.renderAllTabs) return;
const scrollContainer = getScrollContainer();
if (scrollContainer && currentlyOpenTab !== undefined) {
evictStaleCache();
tabStateCache.set(currentlyOpenTab, {
scrollPosition: scrollContainer.scrollTop,
timestamp: Date.now()
});
}
}
function restoreTabState(tabId: number) {
if (!settings.store.renderAllTabs) return;
const cached = tabStateCache.get(tabId);
if (!cached) return;
// restore scroll pos after delay to make sure content loaded
requestAnimationFrame(() => {
setTimeout(() => {
const scrollContainer = getScrollContainer();
if (scrollContainer) {
scrollContainer.scrollTop = cached.scrollPosition;
}
}, 50);
});
}
export function moveToTab(id: number) {
const tab = openTabs.find(v => v.id === id);
if (tab === undefined) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
@ -402,7 +338,8 @@ export function moveToTab(id: number) {
isViewingViaBookmark = false;
// cache current tab state before switching to it
cacheCurrentTabState();
const changingTabs = id !== currentlyOpenTab;
if (changingTabs) cacheCurrentTabState(openTabs.find(t => t.id === currentlyOpenTab), openTabs);
setOpenTab(id);
@ -437,19 +374,21 @@ export function moveToTab(id: number) {
}
// regular channel nav
if (tab.messageId) {
const { messageId } = tab;
if (messageId) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionTo(`/channels/${tab.guildId}/${tab.channelId}/${tab.messageId}`);
NavigationRouter.transitionTo(`/channels/${tab.guildId}/${tab.channelId}/${messageId}`);
delete openTabs[openTabs.indexOf(tab)].messageId;
}
else if (tab.channelId !== SelectedChannelStore.getChannelId() || tab.guildId !== SelectedGuildStore.getGuildId()) {
else if (tab.channelId !== SelectedChannelStore.getChannelId() || (tab.guildId || "@me") !== (SelectedGuildStore.getGuildId() || "@me")) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionToGuild(tab.guildId, tab.channelId);
// restore cached state for the new tab
restoreTabState(id);
}
else update();
// restore cached state for the new tab
if (changingTabs && !messageId) restoreTabState(id, tab.channelId);
// Clear flag after navigation with safety timeout
navigationTimeoutId = setTimeout(() => {
isNavigatingViaTabFlag = false;
@ -486,7 +425,7 @@ export async function openStartupTabs(props: BasicChannelTabsProps & { userId: s
replaceArray(openTabs);
replaceArray(closedTabs);
replaceArray(openTabHistory);
tabStateCache.clear();
clearTabState();
highestIdIndex = 0;
if (keepCurrentChannel) {