Equicord/src/equicordplugins/channelTabs/util/tabs.tsx
2026-10-03 11:16:17 -04:00

592 lines
20 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { isPluginEnabled } from "@api/PluginManager";
import { classNameFactory } from "@utils/css";
import { NavigationRouter, SelectedChannelStore, SelectedGuildStore, showToast, useEffect, useRef, useState } from "@webpack/common";
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-");
// Navigation context tracking system
interface NavigationContext {
guildId: string;
channelId: string;
timestamp: number;
source: "bookmark" | "tab" | "unknown";
}
let lastNavigationContext: NavigationContext | null = null;
let isViewingViaBookmark = false;
let isNavigatingViaTabFlag = false;
let navigationTimeoutId: NodeJS.Timeout | undefined;
const NAVIGATION_TIMEOUT_MS = 1000;
export function isViewingViaBookmarkMode() {
return settings.store.bookmarksIndependentFromTabs && isViewingViaBookmark;
}
export function isNavigatingViaTab() {
return isNavigatingViaTabFlag;
}
export function clearNavigationFlag() {
clearTimeout(navigationTimeoutId);
isNavigatingViaTabFlag = false;
}
export function clearBookmarkViewingMode() {
isViewingViaBookmark = false;
}
export function setNavigationSource(guildId: string, channelId: string, source: "bookmark" | "tab") {
lastNavigationContext = {
guildId: guildId || "@me",
channelId,
timestamp: Date.now(),
source
};
}
export function isNavigationFromSource(guildId: string | null | undefined, channelId: string, source: "bookmark"): boolean {
if (!lastNavigationContext) return false;
// Normalize both sides for comparison (handles null/undefined/"" for DMs)
const normalizedGuildId = guildId || "@me";
const normalizedContextGuildId = lastNavigationContext.guildId || "@me";
// Check if this navigation matches the tracked source
const matches =
normalizedContextGuildId === normalizedGuildId &&
lastNavigationContext.channelId === channelId &&
lastNavigationContext.source === source;
// Don't clear immediately - allow multiple React updates to check
// Context will be cleared by clearStaleNavigationContext() after 2 seconds
return matches;
}
// Clean up old contexts (safety mechanism)
export function clearStaleNavigationContext() {
if (lastNavigationContext && Date.now() - lastNavigationContext.timestamp > 2000) {
lastNavigationContext = null;
}
}
function replaceArray<T>(array: T[], ...values: T[]) {
const len = array.length;
for (let i = 0; i < len; i++) array.pop();
array.push(...values);
}
let highestIdIndex = 0;
const genId = () => highestIdIndex++;
const openTabs: ChannelTabsProps[] = [];
const closedTabs: ChannelTabsProps[] = [];
let currentlyOpenTab: number;
const openTabHistory: number[] = [];
let hydratedUserId: string | undefined;
let hydrationGeneration = 0;
let saveQueue = Promise.resolve();
// horror
const _ = {
get openedTabs() {
return openTabs;
}
};
export const { openedTabs } = _;
type UpdateFunction = (save?: boolean) => void;
const unsetUpdate: UpdateFunction = () => {
logger.warn("Update function not set");
};
let update = unsetUpdate;
let bumpGhostTabCount = () => {
logger.warn("Set ghost tab function not set");
};
let clearGhostTabs = () => {
logger.warn("Clear ghost tab function not set");
};
export function createTab(props: BasicChannelTabsProps | ChannelTabsProps, switchToTab?: boolean, messageId?: string, save = true, bypassOneTabPerServer = false) {
// Important for the "One tab per server" feature, has to be before the maxOpenTabs check!
if (!bypassOneTabPerServer && settings.store.oneTabPerServer && props.guildId && props.guildId !== "@me") {
const existingTab = openTabs.find(tab => tab.guildId === props.guildId);
if (existingTab) {
existingTab.channelId = props.channelId;
existingTab.messageId = messageId;
existingTab.compact = "compact" in props ? props.compact : settings.store.openNewTabsInCompactMode;
if (switchToTab) {
moveToTab(existingTab.id);
} else {
update(save);
}
return;
}
}
const maxTabs = settings.store.maxOpenTabs;
const isLimitEnabled = maxTabs > 0;
const wouldExceedLimit = isLimitEnabled && openTabs.length >= maxTabs;
if (wouldExceedLimit) {
const currentTab = openTabs.find(t => t.id === currentlyOpenTab);
if (currentTab) {
currentTab.channelId = props.channelId;
currentTab.guildId = props.guildId;
currentTab.messageId = messageId;
currentTab.compact = "compact" in props ? props.compact : settings.store.openNewTabsInCompactMode;
if (switchToTab) {
update(save);
} else {
moveToTab(currentTab.id);
}
}
return;
}
const id = genId();
openTabs.push({ ...props, id, messageId, compact: "compact" in props ? props.compact : settings.store.openNewTabsInCompactMode });
if (switchToTab) moveToTab(id);
clearGhostTabs();
update(save);
}
export function closeTab(id: number) {
if (openTabs.length <= 1) return;
const i = openTabs.findIndex(v => v.id === id);
if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
const closed = openTabs.splice(i, 1);
closedTabs.push(...closed);
// the memory leak preventer
tabStateCache.delete(id);
if (id === currentlyOpenTab) {
if (openTabHistory.length) {
// use tab history to find most recently used tab
openTabHistory.pop();
let newTab: ChannelTabsProps | undefined = undefined;
while (!newTab) {
const maybeNewTabId = openTabHistory.at(-1);
openTabHistory.pop();
if (!maybeNewTabId) {
// fallback: go to tab on the right, or leftmost if closing last tab
const fallbackIndex = i < openTabs.length ? i : 0;
moveToTab(openTabs[fallbackIndex].id);
break;
}
const maybeNewTab = openTabs.find(t => t.id === maybeNewTabId);
if (maybeNewTab) newTab = maybeNewTab;
}
if (newTab) {
moveToTab(newTab.id);
openTabHistory.pop();
}
} else {
// no history: go to tab on the right, or leftmost if closing last tab
const fallbackIndex = i < openTabs.length ? i : 0;
moveToTab(openTabs[fallbackIndex].id);
}
}
if (i !== openTabs.length) bumpGhostTabCount();
else clearGhostTabs();
update();
}
export function closeOtherTabs(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);
const removedTabs = openTabs.filter(v => v.id !== id);
closedTabs.push(...removedTabs.reverse());
const lastTab = openTabs.find(v => v.id === currentlyOpenTab)!;
replaceArray(openTabs, tab);
setOpenTab(id);
replaceArray(openTabHistory, id);
if (tab.channelId !== lastTab.channelId) moveToTab(id);
else update();
}
export function closeTabsToTheRight(id: number) {
const i = openTabs.findIndex(v => v.id === id);
if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
const tabsToTheRight = openTabs.filter((_, ind) => ind > i);
closedTabs.push(...tabsToTheRight.reverse());
const tabsToTheLeft = openTabs.filter((_, ind) => ind <= i);
replaceArray(openTabs, ...tabsToTheLeft);
if (!tabsToTheLeft.some(v => v.id === currentlyOpenTab)) moveToTab(openTabs.at(-1)!.id);
else update();
}
export function closeTabsToTheLeft(id: number) {
const i = openTabs.findIndex(v => v.id === id);
if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
const tabsToTheLeft = openTabs.filter((_, ind) => ind < i);
closedTabs.push(...tabsToTheLeft.reverse());
const tabsToTheRight = openTabs.filter((_, ind) => ind >= i);
replaceArray(openTabs, ...tabsToTheRight);
if (!tabsToTheRight.some(v => v.id === currentlyOpenTab)) moveToTab(openTabs[0].id);
else update();
}
let lastNavigationTime = 0;
export function handleChannelSwitch(ch: BasicChannelTabsProps) {
// only require channelId to allow synthetic IDs for special pages
if (!ch.channelId) return;
// don't modify tabs when viewing via bookmarks in independent mode
if (isViewingViaBookmarkMode()) {
return;
}
const tab = openTabs.find(c => c.id === currentlyOpenTab);
// Normalize guildId for DMs/Group Chats to ensure consistent comparison
const normalizedGuildId = ch.guildId || "@me";
const existingTab = openTabs.find(tab => {
const tabGuildId = tab.guildId || "@me";
return tab.channelId === ch.channelId && tabGuildId === normalizedGuildId;
});
// First check: switch to existing tab if setting enabled and tab exists
if (settings.store.switchToExistingTab && existingTab) {
moveToTab(existingTab.id);
return;
}
// Second check: create new tab if setting enabled
if (settings.store.createNewTabIfNotExists) {
// Apply rapid navigation logic when creating new tabs
const now = Date.now();
const isRapidNavigation = now - lastNavigationTime < settings.store.rapidNavigationThreshold * 1000;
lastNavigationTime = now;
if (isRapidNavigation && settings.store.enableRapidNavigation) {
// Replace current tab content instead of creating new one
const currentTab = openTabs.find(t => t.id === currentlyOpenTab);
if (currentTab && currentTab.channelId !== ch.channelId) {
currentTab.channelId = ch.channelId;
currentTab.guildId = ch.guildId;
update();
return;
}
}
// Create new tab (normal behavior)
if (!existingTab) {
createTab(ch, true);
}
return;
}
// Default behavior: replace current tab content
if (tab && tab.channelId !== ch.channelId) {
openTabs[openTabs.indexOf(tab)] = { id: tab.id, compact: tab.compact, ...ch };
update();
}
}
export function hasClosedTabs() {
return !!closedTabs.length;
}
export function isTabSelected(id: number) {
// don't show any tab as selected when viewing via bookmark in independent mode
if (isViewingViaBookmark && settings.store.bookmarksIndependentFromTabs) {
return false;
}
return id === currentlyOpenTab;
}
export function moveDraggedTabs(index1: number, index2: number) {
if (index1 < 0 || index1 >= openTabs.length || index2 < 0 || index2 >= openTabs.length)
return logger.error(`Out of bounds drag (swap between indexes ${index1} and ${index2})`, openTabs);
const firstItem = openTabs.splice(index1, 1)[0];
if (!firstItem) return logger.error(`Tab at index ${index1} is undefined`, openTabs);
openTabs.splice(index2, 0, firstItem);
update();
}
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);
// clear bookmark viewing mode when switching to a tab
isViewingViaBookmark = false;
// cache current tab state before switching to it
const changingTabs = id !== currentlyOpenTab;
if (changingTabs) cacheCurrentTabState(openTabs.find(t => t.id === currentlyOpenTab), openTabs);
setOpenTab(id);
// SET FLAG: We're initiating navigation
clearTimeout(navigationTimeoutId);
isNavigatingViaTabFlag = true;
// handle special pages with synthetic channelIds
if (tab.channelId && tab.channelId.startsWith("__")) {
const routeMap: Record<string, string> = {
"__quests__": "/quest-home",
"__message-requests__": "/message-requests",
"__friends__": "/channels/@me",
"__shop__": "/shop",
"__library__": "/library",
"__discovery__": "/discovery",
"__nitro__": "/store",
"__icymi__": "/icymi",
"__activity__": "/channels/@me/activity",
};
const route = routeMap[tab.channelId];
if (route) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionTo(route);
// Clear flag after navigation with safety timeout
navigationTimeoutId = setTimeout(() => {
isNavigatingViaTabFlag = false;
}, NAVIGATION_TIMEOUT_MS);
return;
}
}
// regular channel nav
const { messageId } = tab;
if (messageId) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionTo(`/channels/${tab.guildId}/${tab.channelId}/${messageId}`);
delete openTabs[openTabs.indexOf(tab)].messageId;
}
else if (tab.channelId !== SelectedChannelStore.getChannelId() || (tab.guildId || "@me") !== (SelectedGuildStore.getGuildId() || "@me")) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionToGuild(tab.guildId, tab.channelId);
}
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;
}, NAVIGATION_TIMEOUT_MS);
}
export async function openStartupTabs(props: BasicChannelTabsProps & { userId: string; }, setUserId: (id: string) => void): Promise<void> {
const { userId } = props;
if (hydratedUserId === userId && openTabs.length) {
setUserId(userId);
update(false);
return;
}
setUserId("");
const generation = ++hydrationGeneration;
await saveQueue;
if (generation !== hydrationGeneration) return;
const keepCurrentChannel = settings.store.onStartup !== "nothing" && isPluginEnabled("KeepCurrentChannel");
let savedTabs: PersistedTabs[string] | undefined;
if (settings.store.onStartup === "remember" && !keepCurrentChannel) {
try {
const persistedTabs = await DataStore.get<PersistedTabs>("ChannelTabs_openChannels_v2");
if (generation !== hydrationGeneration) return;
savedTabs = persistedTabs?.[userId];
} catch (error) {
logger.error("Failed to load persisted tabs from DataStore", error);
showToast("Failed to load saved tabs", "failure");
}
}
replaceArray(openTabs);
replaceArray(closedTabs);
replaceArray(openTabHistory);
clearTabState();
highestIdIndex = 0;
if (keepCurrentChannel) {
hydratedUserId = undefined;
showToast("Not restoring tabs as KeepCurrentChannel is enabled", "failure");
return;
}
switch (settings.store.onStartup) {
case "remember": {
if (!savedTabs?.openTabs.length) {
showToast("Failed to restore tabs", "failure");
break;
}
savedTabs.openTabs.forEach(tab => createTab(tab, false, tab.messageId, false));
currentlyOpenTab = openTabs[savedTabs.openTabIndex]?.id ?? openTabs[0]?.id;
break;
}
case "preset": {
const tabs = settings.store.tabSet?.[userId];
if (!tabs) break;
tabs.forEach(tab => createTab(tab, false, tab.messageId, false));
currentlyOpenTab = openTabs[0]?.id;
break;
}
}
if (!openTabs.length)
createTab({ channelId: props.channelId, guildId: props.guildId }, false, undefined, false);
currentlyOpenTab = openTabs.find(tab => tab.id === currentlyOpenTab)?.id ?? openTabs[0].id;
hydratedUserId = userId;
setUserId(userId);
moveToTab(currentlyOpenTab);
}
export function reopenClosedTab() {
if (!closedTabs.length) return;
const tab = closedTabs.pop()!;
createTab(tab, true);
}
export function saveTabs(userId: string): Promise<void> {
if (!userId) return Promise.resolve();
const snapshot = {
openTabs: openTabs.map(tab => ({ ...tab })),
openTabIndex: openTabs.findIndex(tab => tab.id === currentlyOpenTab)
};
saveQueue = saveQueue
.then(() => DataStore.update<PersistedTabs>("ChannelTabs_openChannels_v2", old => ({
...(old ?? {}),
[userId]: snapshot
})))
.catch(error => {
logger.error("Failed to save tabs to DataStore", error);
});
return saveQueue;
}
export function setOpenTab(id: number) {
const i = openTabs.findIndex(v => v.id === id);
if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
currentlyOpenTab = id;
openTabHistory.push(id);
}
export function setUpdaterFunction(fn: UpdateFunction): () => void {
update = fn;
return () => {
if (update === fn) update = unsetUpdate;
};
}
export function switchChannel(ch: BasicChannelTabsProps) {
handleChannelSwitch(ch);
moveToTab(openTabs.find(t => t.id === currentlyOpenTab)!.id);
}
export function navigateToBookmark(ch: BasicChannelTabsProps) {
// check if independent mode is enabled
if (settings.store.bookmarksIndependentFromTabs) {
// set flag that we're viewing via bookmark
isViewingViaBookmark = true;
// set navigation context BEFORE navigating
setNavigationSource(ch.guildId, ch.channelId, "bookmark");
// Handle special pages with synthetic channelIds
if (ch.channelId && ch.channelId.startsWith("__")) {
const routeMap: Record<string, string> = {
"__quests__": "/quest-home",
"__message-requests__": "/message-requests",
"__friends__": "/channels/@me",
"__shop__": "/shop",
"__library__": "/library",
"__discovery__": "/discovery",
"__nitro__": "/store",
"__icymi__": "/icymi",
"__activity__": "/channels/@me/activity",
};
const route = routeMap[ch.channelId];
if (route) {
NavigationRouter.transitionTo(route);
// trigger update to reflect tab deselection
update();
return;
}
}
// navigate directly without affecting tab state
NavigationRouter.transitionToGuild(ch.guildId, ch.channelId);
// trigger update to reflect tab deselection
update();
} else {
// use old behavior - affects tabs
// sorry for all the comments im losing it here
switchChannel(ch);
}
}
export function toggleCompactTab(id: number) {
const i = openTabs.findIndex(v => v.id === id);
if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
openTabs[i] = {
...openTabs[i],
compact: !openTabs[i].compact
};
update();
}
export function useGhostTabs() {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [count, setCount] = useState(0);
bumpGhostTabCount = () => {
setCount(prev => prev + 1);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setCount(0);
}, 3000);
};
clearGhostTabs = () => {
clearTimeout(timeoutRef.current);
setCount(0);
};
// cleanup timeout on unmount
useEffect(() => {
return () => {
clearTimeout(timeoutRef.current);
};
}, []);
return new Array<JSX.Element>(count).fill(<div className={cl("tab", "ghost-tab")} />);
}