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:
parent
e99ef13c81
commit
49902c5f40
5 changed files with 216 additions and 72 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
179
src/equicordplugins/channelTabs/util/scroll.ts
Normal file
179
src/equicordplugins/channelTabs/util/scroll.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue