diff --git a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx index bb7e1f429..be300fc28 100644 --- a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx +++ b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx @@ -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"), diff --git a/src/equicordplugins/channelTabs/index.tsx b/src/equicordplugins/channelTabs/index.tsx index 24a9d8218..b0e46c457 100644 --- a/src/equicordplugins/channelTabs/index.tsx +++ b/src/equicordplugins/channelTabs/index.tsx @@ -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 diff --git a/src/equicordplugins/channelTabs/style.css b/src/equicordplugins/channelTabs/style.css index d62af688f..a718f6066 100644 --- a/src/equicordplugins/channelTabs/style.css +++ b/src/equicordplugins/channelTabs/style.css @@ -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 { diff --git a/src/equicordplugins/channelTabs/util/scroll.ts b/src/equicordplugins/channelTabs/util/scroll.ts new file mode 100644 index 000000000..cc52d61aa --- /dev/null +++ b/src/equicordplugins/channelTabs/util/scroll.ts @@ -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(); +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; +} diff --git a/src/equicordplugins/channelTabs/util/tabs.tsx b/src/equicordplugins/channelTabs/util/tabs.tsx index d0f6351bb..209e371c4 100644 --- a/src/equicordplugins/channelTabs/util/tabs.tsx +++ b/src/equicordplugins/channelTabs/util/tabs.tsx @@ -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(); -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) {