navigateToBookmark(bookmark)}
+ onContextMenu={e => {
+ e.stopPropagation();
+ ContextMenuApi.openContextMenu(e, () => (
+
FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ >
+ {
+ const key = openModal(modalProps =>
+ {
+ const folder = methods.bookmarks[folderIndex] as BookmarkFolder;
+ const newBookmarks = [...folder.bookmarks];
+ newBookmarks[bookmarkIndex].name = name;
+ methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
+ closeModal(key);
+ }}
+ />
+ );
+ }}
+ />
+ methods.deleteBookmark(bookmarkIndex, folderIndex)}
+ />
+ {
+ methods.addBookmark(bookmark);
+ methods.deleteBookmark(bookmarkIndex, folderIndex);
+ }}
+ />
+ {bookmarkNotificationDot && ReadStateStore.hasUnread(bookmark.channelId) && (
+ ReadStateUtils.ackChannel(ChannelStore.getChannel(bookmark.channelId))}
+ />
+ )}
+
+ ));
+ }}
+ >
+
+
+
+ {bookmark.name}
+
+ {bookmarkNotificationDot &&
}
+
+ );
+}
+
+function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?: () => void; isCurrentChannel?: boolean; }) {
+ const { bookmarks, index, methods, isExpanded, onToggleFolder, isCurrentChannel } = props;
const bookmark = bookmarks[index];
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
@@ -176,13 +315,25 @@ function Bookmark(props: BookmarkProps) {
const [, drag] = useDrag(() => ({
type: "vc_Bookmark",
+ canDrag: () => !isBookmarkFolder(bookmark), // Don't allow dragging folders
item: () => {
- return { index };
+ // find current index dynamically instead of using stale closure
+ const currentIndex = bookmarks.findIndex(b =>
+ isBookmarkFolder(b) ?
+ (isBookmarkFolder(bookmark) && b.bookmarks === bookmark.bookmarks) :
+ (!isBookmarkFolder(bookmark) && b.channelId === bookmark.channelId && b.guildId === bookmark.guildId)
+ );
+ return {
+ index: currentIndex !== -1 ? currentIndex : index,
+ folderIndex: undefined, // not in a folder (bar)
+ bookmark: bookmark, // include full bookmark data
+ isFromFolder: false
+ };
},
collect: monitor => ({
isDragging: !!monitor.isDragging()
}),
- }));
+ }), [bookmarks, bookmark, index]);
const [, drop] = useDrop(() => ({
accept: "vc_Bookmark",
@@ -207,21 +358,88 @@ function Bookmark(props: BookmarkProps) {
item.index = hoverIndex;
},
}), []);
- drag(drop(ref));
+
+ // separate drop zone for folders to accept tabs and bookmarks (prevents duplicates)
+ const [{ isOver: isFolderDropTarget }, folderDrop] = useDrop(() => ({
+ accept: ["vc_ChannelTab", "vc_Bookmark"],
+ canDrop: () => isBookmarkFolder(bookmark),
+ drop: (item: any, monitor) => {
+ if (!isBookmarkFolder(bookmark)) return;
+
+ const itemType = monitor.getItemType();
+
+ // dropping a tab onto a folder
+ if (itemType === "vc_ChannelTab") {
+ methods.addBookmark({
+ guildId: item.guildId,
+ channelId: item.channelId
+ }, index);
+ }
+
+ // dropping a bookmark onto a folder (reorganize)
+ if (itemType === "vc_Bookmark") {
+ const sourceBookmark = item.bookmark || bookmarks[item.index];
+
+ // skip if source is a folder
+ if (isBookmarkFolder(sourceBookmark)) return;
+
+ // skip if already in this folder
+ if (item.isFromFolder && item.folderIndex === index) return;
+
+ // remove from original location
+ if (item.isFromFolder) {
+ // coming from a folder
+ methods.deleteBookmark(item.index, item.folderIndex);
+ } else {
+ // coming from bar level
+ methods.deleteBookmark(item.index);
+ }
+
+ // add to this folder
+ methods.addBookmark(sourceBookmark, index);
+ }
+ },
+ collect: monitor => ({
+ isOver: monitor.isOver({ shallow: true }) && monitor.canDrop()
+ })
+ }), [bookmarks, index, methods]);
+
+ // combine refs differently based on bookmark type
+ if (isBookmarkFolder(bookmark)) {
+ drag(folderDrop(drop(ref)));
+ } else {
+ drag(drop(ref));
+ }
return (
isBookmarkFolder(bookmark)
- ? ContextMenuApi.openContextMenu(e, () =>
)
- : switchChannel(bookmark)
- }
+ onClick={e => {
+ if (isBookmarkFolder(bookmark)) {
+ onToggleFolder?.();
+ } else {
+ navigateToBookmark(bookmark);
+ }
+ }}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () =>
)}
>
+ {isBookmarkFolder(bookmark) && (
+
+ )}
{bookmark.name}
@@ -233,17 +451,30 @@ function Bookmark(props: BookmarkProps) {
);
}
-function HorizontalScroller({ children, className }: React.PropsWithChildren<{ className?: string; }>) {
- const ref = useRef
(null);
+function HorizontalScroller({ children, className, customRef }: React.PropsWithChildren<{ className?: string; customRef?: (node: HTMLDivElement) => void; }>) {
+ const internalRef = useRef(null);
+
useEffect(() => {
- ref.current!.addEventListener("wheel", e => {
+ const element = internalRef.current;
+ if (!element) return;
+
+ const handleWheel = (e: WheelEvent) => {
e.preventDefault();
- ref.current!.scrollLeft += e.deltaY;
- });
+ element.scrollLeft += e.deltaY;
+ };
+
+ element.addEventListener("wheel", handleWheel);
+ return () => element.removeEventListener("wheel", handleWheel);
}, []);
+ // Combine refs
+ const combinedRef = (node: HTMLDivElement) => {
+ internalRef.current = node;
+ if (customRef) customRef(node);
+ };
+
return (
-
+
{children}
);
@@ -252,6 +483,31 @@ function HorizontalScroller({ children, className }: React.PropsWithChildren<{ c
export default function BookmarkContainer(props: BasicChannelTabsProps & { userId: string; }) {
const { guildId, channelId, userId } = props;
const [bookmarks, methods] = useBookmarks(userId);
+ const [expandedFolders, setExpandedFolders] = useState
>(new Set());
+
+ // make setting reactive to changes
+ const { bookmarksIndependentFromTabs } = settings.use(["bookmarksIndependentFromTabs"]);
+
+ const currentChannelId = SelectedChannelStore.getChannelId();
+ const currentGuildId = SelectedGuildStore.getGuildId() || "@me";
+
+ // check if current channel matches the active tab
+ const activeTab = openedTabs.find(tab => isTabSelected(tab.id));
+ const channelMatchesActiveTab = activeTab &&
+ activeTab.channelId === currentChannelId &&
+ (activeTab.guildId || "@me") === currentGuildId;
+
+ const toggleFolder = (folderIndex: number) => {
+ setExpandedFolders(prev => {
+ const next = new Set(prev);
+ if (next.has(folderIndex)) {
+ next.delete(folderIndex);
+ } else {
+ next.add(folderIndex);
+ }
+ return next;
+ });
+ };
let isCurrentChannelBookmarked = false, currentChannelFolderIndex = -1;
bookmarks?.forEach((bookmark, i) => {
@@ -264,9 +520,47 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
else if (bookmark.channelId === channelId) isCurrentChannelBookmarked = true;
});
+ const [{ isOver }, dropRef] = useDrop(() => ({
+ accept: ["vc_ChannelTab", "vc_Bookmark"],
+ drop: (item: any, monitor) => {
+ if (!bookmarks) return;
+
+ // check if a nested drop zone (like a folder) already handled this
+ if (monitor.didDrop()) {
+ return;
+ }
+
+ const itemType = monitor.getItemType();
+
+ // if dropping a tab, create bookmark
+ if (itemType === "vc_ChannelTab") {
+ methods.addBookmark({
+ guildId: item.guildId,
+ channelId: item.channelId
+ });
+ }
+ // if dropping a bookmark, handle based on source
+ else if (itemType === "vc_Bookmark") {
+ if (item.isFromFolder && item.folderIndex !== undefined) {
+ // moving from folder to bar (aka root level)
+ const folder = bookmarks[item.folderIndex];
+ if (isBookmarkFolder(folder)) {
+ const sourceBookmark = item.bookmark || folder.bookmarks[item.index];
+ methods.deleteBookmark(item.index, item.folderIndex);
+ methods.addBookmark(sourceBookmark);
+ }
+ }
+ // if moving from bar to bar, that's just reordering (handled by hover logic)
+ }
+ },
+ collect: monitor => ({
+ isOver: monitor.isOver({ shallow: true })
+ })
+ }), [bookmarks, methods]);
+
return (
-
+
{!bookmarks &&
Loading bookmarks...
}
@@ -274,9 +568,50 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
You have no bookmarks. You can add an open tab or hide this by right clicking it
}
{Array.isArray(bookmarks) && bookmarks.length > 0 &&
- bookmarks.map((_, i) => (
-
- ))
+ bookmarks.flatMap((bookmark, i) => {
+ const items = [
+ toggleFolder(i)}
+ isCurrentChannel={
+ bookmarksIndependentFromTabs &&
+ !channelMatchesActiveTab && (
+ !isBookmarkFolder(bookmark) &&
+ bookmark.channelId === currentChannelId &&
+ (bookmark.guildId || "@me") === currentGuildId
+ )
+ }
+ />
+ ];
+
+ // if folder is expanded, render its bookmarks
+ if (isBookmarkFolder(bookmark) && expandedFolders.has(i)) {
+ bookmark.bookmarks.forEach((childBookmark, childIndex) => {
+ items.push(
+
+ );
+ });
+ }
+
+ return items;
+ })
}
diff --git a/src/equicordplugins/channelTabs/components/ChannelTab.tsx b/src/equicordplugins/channelTabs/components/ChannelTab.tsx
index 307da8ce6..e31b37d4b 100644
--- a/src/equicordplugins/channelTabs/components/ChannelTab.tsx
+++ b/src/equicordplugins/channelTabs/components/ChannelTab.tsx
@@ -5,21 +5,39 @@
*/
import { classNameFactory } from "@api/Styles";
+import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
import { classes } from "@utils/misc";
import { Channel, Guild, User } from "@vencord/discord-types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, Text, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
+import { JSX } from "react";
-import { ChannelTabsProps, CircleQuestionIcon, closeTab, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings } from "../util";
+import { ChannelTabsProps, closeTab, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings } from "../util";
+import { CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, NitroIcon, QuestIcon, ShopIcon } from "../util/icons";
import { TabContextMenu } from "./ContextMenus";
const ThreeDots = findComponentByCodeLazy(".dots,", "dotRadius:");
const dotStyles = findByPropsLazy("numberBadge", "textBadge");
-const FriendsIcon = findComponentByCodeLazy("12h1a8");
const ChannelTypeIcon = findComponentByCodeLazy(".iconContainerWithGuildIcon,");
+// Custom SVG icons for pages that don't have findable components
+
+function LibraryIcon(height: number = 20, width: number = 20, className?: string): JSX.Element {
+ return (
+
+ );
+}
+
const cl = classNameFactory("vc-channeltabs-");
function XIcon({ size, fill }: { size: number, fill: string; }) {
@@ -176,13 +194,38 @@ function ChannelTabContent(props: ChannelTabsProps & {
}
}
- if (guildId === "@me" || guildId === undefined)
+ // handle special synthetic pages
+ if (channelId && channelId.startsWith("__")) {
+ const specialPagesConfig: Record; }> = {
+ "__quests__": { label: "Quests", Icon: QuestIcon },
+ "__message-requests__": { label: "Message Requests", Icon: EnvelopeIcon },
+ "__friends__": { label: getIntlMessage("FRIENDS"), Icon: FriendsIcon },
+ "__shop__": { label: "Shop", Icon: ShopIcon },
+ "__library__": { label: "Library", Icon: () => LibraryIcon(20, 20) },
+ "__discovery__": { label: "Discovery", Icon: DiscoveryIcon },
+ "__nitro__": { label: "Nitro", Icon: NitroIcon }
+ };
+
+ const pageConfig = specialPagesConfig[channelId];
+ if (pageConfig) {
+ const { label, Icon } = pageConfig;
+ return (
+ <>
+
+ {label}
+ >
+ );
+ }
+ }
+
+ if (guildId === "@me" || guildId === undefined) {
return (
<>
{getIntlMessage("FRIENDS")}
>
);
+ }
return (
<>
@@ -220,12 +263,52 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
const lastSwapTimeRef = useRef(0);
const SWAP_THROTTLE_MS = 100;
+ const handleResizeStart = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const startX = e.clientX;
+ const startWidth = ref.current?.getBoundingClientRect().width || 0;
+ const baseWidth = 192; // 12rem in pixels (assuming 16px base font)
+
+ document.body.style.cursor = "ew-resize";
+ document.body.style.userSelect = "none";
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ const deltaX = moveEvent.clientX - startX;
+ const newWidth = startWidth + deltaX;
+ const newScale = newWidth / baseWidth;
+
+ // 50% and 200% scale
+ const clampedScale = Math.max(0.5, Math.min(2, newScale));
+ settings.store.tabWidthScale = Math.round(clampedScale * 100);
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ };
+
const [, drag] = useDrag(() => ({
type: "vc_ChannelTab",
item: () => {
setIsDragging(true);
lastSwapTimeRef.current = Date.now() - SWAP_THROTTLE_MS;
- return { id };
+
+ // get fresh tab data dynamically to avoid stale closures
+ const tab = openedTabs.find(t => t.id === id);
+
+ return {
+ id,
+ channelId: tab?.channelId || channelId,
+ guildId: tab?.guildId || guildId
+ };
},
collect: monitor => ({
isDragging: !!monitor.isDragging()
@@ -235,7 +318,7 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
setIsDropTarget(false);
lastSwapTimeRef.current = 0;
}
- }));
+ }), [id, channelId, guildId]);
const [, drop] = useDrop(() => ({
accept: "vc_ChannelTab",
hover: (item, monitor) => {
@@ -267,7 +350,7 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
const hoverWidth = hoverBoundingRect.right - hoverBoundingRect.left;
const hoverMiddleX = hoverWidth / 2;
- // Get tab width
+ // get tab width
const draggedElement = document.querySelector(".vc-channeltabs-tab-dragging") as HTMLElement;
const draggedWidth = draggedElement?.getBoundingClientRect().width || hoverWidth;
const halfDraggedWidth = draggedWidth / 2;
@@ -295,6 +378,9 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
}), []);
drag(drop(ref));
+ // check if quests running (questify momentLet)
+ const hasActiveQuests = activeQuestIntervals.size > 0;
+
return
}
+
+ {!compact && settings.store.showResizeHandle &&
}
;
}
diff --git a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx
index 3c021cd73..69c849367 100644
--- a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx
+++ b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx
@@ -5,12 +5,13 @@
*/
import { classNameFactory } from "@api/Styles";
+import { IS_MAC } from "@utils/constants";
import { classes } from "@utils/misc";
import { useForceUpdater } from "@utils/react";
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, ContextMenuApi, Flex, FluxDispatcher, Forms, useCallback, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
-import { BasicChannelTabsProps, ChannelTabsProps, createTab, handleChannelSwitch, moveToTab, openedTabs, openStartupTabs, saveTabs, settings, setUpdaterFunction, useGhostTabs } from "../util";
+import { BasicChannelTabsProps, ChannelTabsProps, clearStaleNavigationContext, createTab, handleChannelSwitch, isNavigationFromSource, moveToTab, openedTabs, openStartupTabs, saveTabs, settings, setUpdaterFunction, useGhostTabs } from "../util";
import BookmarkContainer from "./BookmarkContainer";
import ChannelTab, { PreviewTab } from "./ChannelTab";
import { BasicContextMenu } from "./ContextMenus";
@@ -27,6 +28,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
const {
showBookmarkBar,
widerTabsAndBookmarks,
+ tabWidthScale,
enableHotkeys,
hotkeyCount,
tabBarPosition,
@@ -42,10 +44,15 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
animationSelectedBorder,
animationSelectedBackground,
animationTabShadows,
- compactAutoExpandSelected
+ animationTabPositioning,
+ animationResizeHandle,
+ animationQuestsActive,
+ compactAutoExpandSelected,
+ compactAutoExpandOnHover
} = settings.use([
"showBookmarkBar",
"widerTabsAndBookmarks",
+ "tabWidthScale",
"enableHotkeys",
"hotkeyCount",
"tabBarPosition",
@@ -61,7 +68,11 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
"animationSelectedBorder",
"animationSelectedBackground",
"animationTabShadows",
- "compactAutoExpandSelected"
+ "animationTabPositioning",
+ "animationResizeHandle",
+ "animationQuestsActive",
+ "compactAutoExpandSelected",
+ "compactAutoExpandOnHover"
]);
const GhostTabs = useGhostTabs();
const isFullscreen = useStateFromStores([ChannelRTCStore], () => ChannelRTCStore.isFullscreenInContext() ?? false);
@@ -135,8 +146,18 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
useEffect(() => {
if (userId) {
- handleChannelSwitch(props);
+ // Normalize guildId for comparison
+ const normalizedGuildId = props.guildId || "@me";
+
+ // Skip if this navigation came from a bookmark
+ if (!isNavigationFromSource(normalizedGuildId, props.channelId, "bookmark")) {
+ handleChannelSwitch(props);
+ }
+
saveTabs(userId);
+
+ // Clean up any stale navigation contexts
+ clearStaleNavigationContext();
}
}, [userId, props.channelId, props.guildId]);
@@ -149,6 +170,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
className={classes(
cl("container"),
tabBarPosition === "top" && cl("container-top"),
+ IS_MAC && tabBarPosition === "top" && cl("container-top-macos"),
!animationHover && cl("no-hover-animation"),
!animationSelection && cl("no-selection-animation"),
!animationDragDrop && cl("no-drag-animation"),
@@ -161,9 +183,14 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
!animationSelectedBorder && cl("no-selected-border"),
!animationSelectedBackground && cl("no-selected-background"),
!animationTabShadows && cl("no-tab-shadows"),
- !compactAutoExpandSelected && cl("no-compact-auto-expand")
+ !animationTabPositioning && cl("no-tab-positioning"),
+ !animationResizeHandle && cl("no-resize-handle-animation"),
+ !animationQuestsActive && cl("no-quests-active-animation"),
+ !compactAutoExpandSelected && cl("no-compact-auto-expand"),
+ !compactAutoExpandOnHover && cl("no-compact-hover-expand")
)}
ref={ref}
+ style={{ "--tab-width-scale": tabWidthScale / 100 } as React.CSSProperties}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => )}
>
{showBookmarkBar && <>
diff --git a/src/equicordplugins/channelTabs/index.tsx b/src/equicordplugins/channelTabs/index.tsx
index 6c9053fd4..c1b225827 100644
--- a/src/equicordplugins/channelTabs/index.tsx
+++ b/src/equicordplugins/channelTabs/index.tsx
@@ -97,6 +97,63 @@ export default definePlugin({
containerHeight: 0,
+ flux: {
+ CHANNEL_SELECT(data: { channelId: string | null, guildId: string | null; }) {
+ // Skip if this navigation was triggered by us (clicking a tab)
+ if (ChannelTabsUtils.isNavigatingViaTab()) {
+ return;
+ }
+
+ // Check if we're in bookmark viewing mode
+ const wasViewingViaBookmark = ChannelTabsUtils.isViewingViaBookmarkMode();
+
+ // Clear bookmark viewing mode when ANY navigation occurs
+ // This allows subsequent channel switches to work normally
+ if (wasViewingViaBookmark) {
+ ChannelTabsUtils.clearBookmarkViewingMode();
+ }
+
+ let { channelId } = data;
+ let { guildId } = data;
+
+ // Detect special pages by pathname when no channelId
+ if (!channelId) {
+ const path = window.location.pathname;
+
+ if (path === "/quest-home" || path.includes("quest-home")) {
+ channelId = "__quests__";
+ guildId = "@me";
+ } else if (path.includes("/message-requests")) {
+ channelId = "__message-requests__";
+ guildId = "@me";
+ } else if (path === "/channels/@me") {
+ channelId = "__friends__";
+ guildId = "@me";
+ } else if (path.includes("/shop")) {
+ channelId = "__shop__";
+ guildId = "@me";
+ } else if (path.includes("/library")) {
+ channelId = "__library__";
+ guildId = "@me";
+ } else if (path.includes("/discovery")) {
+ channelId = "__discovery__";
+ guildId = "@me";
+ } else if (path.includes("/store")) {
+ channelId = "__nitro__";
+ guildId = "@me";
+ } else {
+ // Unknown page without channelId - ignore
+ return;
+ }
+ }
+
+ // At this point, channelId is guaranteed to be non-null
+ if (channelId && guildId) {
+ handleChannelSwitch({ guildId, channelId });
+ }
+ }
+ },
+
render({ currentChannel, children }: {
currentChannel: BasicChannelTabsProps,
children: JSX.Element;
@@ -134,7 +191,18 @@ export default definePlugin({
},
handleNavigation(guildId: string, channelId: string) {
- if (!guildId || !channelId) return;
+ // Skip if we initiated this navigation
+ if (ChannelTabsUtils.isNavigatingViaTab()) {
+ return;
+ }
+
+ // Skip if we're viewing via bookmarks in independent mode
+ if (ChannelTabsUtils.isViewingViaBookmarkMode()) {
+ return;
+ }
+
+ // Clear bookmark viewing mode when navigating to a regular channel
+ ChannelTabsUtils.clearBookmarkViewingMode();
// wait for discord to update channel data
requestAnimationFrame(() => {
diff --git a/src/equicordplugins/channelTabs/style.css b/src/equicordplugins/channelTabs/style.css
index e069d86d5..e0d4a7493 100644
--- a/src/equicordplugins/channelTabs/style.css
+++ b/src/equicordplugins/channelTabs/style.css
@@ -68,10 +68,14 @@
}
.vc-channeltabs-container-top {
- padding-left: 80px;
+ padding-left: 4px;
box-shadow: inset 0 -1px 0 var(--background-tertiary);
}
+.vc-channeltabs-container-top-macos {
+ padding-left: 80px !important;
+}
+
.vc-channeltabs-separator {
width: 98%;
height: 1px;
@@ -94,7 +98,12 @@
.vc-channeltabs-bookmarks {
flex-grow: 1;
overflow: hidden;
- padding: 2px 0;
+ padding: 2px 0 2px 8px;
+}
+
+/* Add spacing between bookmark items */
+.vc-channeltabs-bookmarks > [class*="scroller"] {
+ gap: 4px;
}
.vc-channeltabs-bookmark-placeholder-text {
@@ -121,14 +130,14 @@
.vc-channeltabs-bookmark {
display: flex;
flex-direction: row;
- max-width: 8rem;
+ max-width: calc(8rem * var(--tab-width-scale, 1));
padding: 4px 8px;
border-radius: 4px;
align-items: center;
}
.vc-channeltabs-bookmark.vc-channeltabs-wider {
- max-width: 12rem;
+ max-width: calc(12rem * var(--tab-width-scale, 1));
}
.vc-channeltabs-bookmark .vc-channeltabs-name-text {
@@ -160,7 +169,7 @@
justify-content: space-between;
align-items: center;
border-radius: 12px;
- width: 12rem;
+ width: calc(12rem * var(--tab-width-scale, 1));
min-width: 0;
margin: 0 4px;
background-color: transparent;
@@ -180,8 +189,44 @@
cursor: grabbing;
}
+.vc-channeltabs-tab-resize-handle {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ cursor: ew-resize;
+ z-index: 10;
+ opacity: 0;
+ background: var(--brand-500);
+ border-radius: 0 11px 11px 0;
+ transition: opacity 0.2s ease;
+}
+
+.vc-channeltabs-tab-resize-handle:hover {
+ opacity: 0.7 !important;
+}
+
+.vc-channeltabs-no-resize-handle-animation .vc-channeltabs-tab-resize-handle {
+ transition: none !important;
+}
+
+.vc-channeltabs-tab:hover .vc-channeltabs-tab-resize-handle {
+ opacity: 0.3;
+}
+
+.vc-channeltabs-tab-resizing {
+ pointer-events: none;
+ user-select: none;
+}
+
+.vc-channeltabs-tab-resizing * {
+ pointer-events: none;
+ user-select: none;
+}
+
.vc-channeltabs-tab.vc-channeltabs-wider:not(.vc-channeltabs-tab-compact) {
- width: 16rem;
+ width: calc(16rem * var(--tab-width-scale, 1));
}
.vc-channeltabs-tab-compact {
@@ -429,7 +474,7 @@
display: flex;
flex-direction: row;
align-items: center;
- width: 12rem;
+ width: calc(12rem * var(--tab-width-scale, 1));
padding: 0.125rem 0;
min-width: 0;
border-radius: 0.25rem;
@@ -492,6 +537,160 @@
0 0 24px rgb(242 63 66 / 50%);
}
+/* Pink Nitro glow effect (easter egg) */
+.vc-channeltabs-tab-nitro {
+ animation: vc-channeltabs-nitro-pulse 3s ease-in-out infinite;
+ box-shadow: 0 0 10px rgb(255 115 250 / 70%), 0 0 20px rgb(255 115 250 / 40%);
+}
+
+@keyframes vc-channeltabs-nitro-pulse {
+
+ /* stylelint-disable-next-line rule-empty-line-before */
+ 0%,
+ 100% {
+ box-shadow: 0 0 10px rgb(255 115 250 / 70%), 0 0 20px rgb(255 115 250 / 40%);
+ }
+
+ 50% {
+ box-shadow: 0 0 16px rgb(255 115 250 / 90%), 0 0 32px rgb(255 115 250 / 60%);
+ }
+}
+
+/* Enhanced Nitro glow for selected tab */
+.vc-channeltabs-tab-selected.vc-channeltabs-tab-nitro {
+ box-shadow:
+ 0 1px 3px rgb(0 0 0 / 20%),
+ 0 0 16px rgb(255 115 250 / 90%),
+ 0 0 32px rgb(255 115 250 / 60%);
+}
+
+/* Nitro icon gradient animation */
+.vc-channeltabs-tab-nitro .vc-channeltabs-tab-inner svg {
+ filter: drop-shadow(0 0 2px rgb(255 115 250 / 80%));
+}
+
+.vc-channeltabs-tab-nitro .vc-channeltabs-tab-inner svg path {
+ fill: rgb(255 115 250) !important;
+ animation: vc-channeltabs-nitro-icon-gradient 3s ease-in-out infinite;
+}
+
+@keyframes vc-channeltabs-nitro-icon-gradient {
+ 0%,
+ 100% {
+ fill: rgb(255 115 250);
+ }
+
+ 50% {
+ fill: rgb(255 180 250);
+ }
+}
+
+/* Quests active gradient (blue+pink) when quests are running */
+.vc-channeltabs-tab-quests-active {
+ position: relative;
+ overflow: visible;
+}
+
+.vc-channeltabs-tab-quests-active::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 11px;
+ background: linear-gradient(135deg,
+ transparent,
+ transparent 25%,
+ rgb(0 200 255) 40%,
+ rgb(88 101 242) 50%,
+ rgb(220 104 255) 60%,
+ transparent 75%,
+ transparent);
+ background-size: 300% 300%;
+ animation: vc-channeltabs-quests-diagonal 5s linear infinite;
+ opacity: 0.8;
+ filter: blur(2px);
+ pointer-events: none;
+}
+
+.vc-channeltabs-tab-quests-active::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 11px;
+ animation: vc-channeltabs-quests-glow 3s ease-in-out infinite;
+ pointer-events: none;
+}
+
+@keyframes vc-channeltabs-quests-diagonal {
+ 0%,
+ 40% {
+ background-position: 100% 0;
+ opacity: 0;
+ }
+
+ 50% {
+ opacity: 0.8;
+ }
+
+ 60%,
+ 100% {
+ background-position: 0 100%;
+ opacity: 0;
+ }
+}
+
+@keyframes vc-channeltabs-quests-glow {
+ 0% {
+ box-shadow: 0 0 8px 1px rgb(0 200 255 / 60%);
+ }
+
+ 33% {
+ box-shadow: 0 0 8px 1px rgb(88 101 242 / 60%);
+ }
+
+ 66% {
+ box-shadow: 0 0 8px 1px rgb(220 104 255 / 60%);
+ }
+
+ 100% {
+ box-shadow: 0 0 8px 1px rgb(0 200 255 / 60%);
+ }
+}
+
+/* Enhanced gradient for selected Quests tab with active quests */
+.vc-channeltabs-tab-selected.vc-channeltabs-tab-quests-active::before {
+ opacity: 1;
+ filter: blur(3px);
+}
+
+.vc-channeltabs-tab-selected.vc-channeltabs-tab-quests-active::after {
+ animation: vc-channeltabs-quests-glow-selected 3s ease-in-out infinite;
+}
+
+@keyframes vc-channeltabs-quests-glow-selected {
+ 0% {
+ box-shadow: 0 0 12px 2px rgb(0 200 255 / 80%);
+ }
+
+ 33% {
+ box-shadow: 0 0 12px 2px rgb(88 101 242 / 80%);
+ }
+
+ 66% {
+ box-shadow: 0 0 12px 2px rgb(220 104 255 / 80%);
+ }
+
+ 100% {
+ box-shadow: 0 0 12px 2px rgb(0 200 255 / 80%);
+ }
+}
+
+/* Disable Quests active animations when setting is off */
+.vc-channeltabs-container.vc-channeltabs-no-quests-active-animation .vc-channeltabs-tab-quests-active::before,
+.vc-channeltabs-container.vc-channeltabs-no-quests-active-animation .vc-channeltabs-tab-quests-active::after {
+ animation: none !important;
+ opacity: 0 !important;
+}
+
/* Tab closing animation */
@keyframes vc-channeltabs-tab-close {
0% {
@@ -647,6 +846,13 @@
animation: none !important;
}
+/* Also disable closing animation for instant disappear */
+.vc-channeltabs-container.vc-channeltabs-no-enter-exit-animation .vc-channeltabs-tab-closing {
+ animation: none !important;
+ opacity: 0 !important;
+ display: none !important;
+}
+
.vc-channeltabs-container.vc-channeltabs-no-enter-exit-animation .vc-channeltabs-tab {
transition: opacity 0.2s ease-out,
transform 0.2s ease-in-out,
@@ -730,6 +936,31 @@
transition: none !important;
}
+/* Also disable tab-inner padding transition */
+.vc-channeltabs-container.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-compact .vc-channeltabs-tab-inner {
+ transition: none !important;
+}
+
+/* Make close button appear instantly with more specificity */
+.vc-channeltabs-container.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-compact:hover .vc-channeltabs-close-button-compact,
+.vc-channeltabs-container.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-compact.vc-channeltabs-tab-selected .vc-channeltabs-close-button-compact {
+ transition: none !important;
+ transition-delay: 0s !important;
+}
+
+/* Tab positioning - disable if setting is off for INSTANT position changes */
+.vc-channeltabs-container.vc-channeltabs-no-tab-positioning .vc-channeltabs-tab-container {
+ transition: none !important;
+}
+
+.vc-channeltabs-container.vc-channeltabs-no-tab-positioning .vc-channeltabs-tab {
+ transition: none !important;
+}
+
+.vc-channeltabs-container.vc-channeltabs-no-tab-positioning .vc-channeltabs-tab-container:has(.vc-channeltabs-tab-dragging) .vc-channeltabs-tab:not(.vc-channeltabs-tab-dragging) {
+ transition: none !important;
+}
+
/* Compact auto-expand selected - disable if setting is off */
.vc-channeltabs-container.vc-channeltabs-no-compact-auto-expand .vc-channeltabs-tab-compact.vc-channeltabs-tab-selected .vc-channeltabs-tab-inner {
padding-right: 0 !important;
@@ -746,3 +977,74 @@
opacity: 0 !important;
pointer-events: none;
}
+
+/* Compact hover expansion - disable if setting is off */
+.vc-channeltabs-container.vc-channeltabs-no-compact-hover-expand .vc-channeltabs-tab-compact:hover .vc-channeltabs-tab-inner {
+ padding-right: 0 !important;
+}
+
+.vc-channeltabs-container.vc-channeltabs-no-compact-hover-expand .vc-channeltabs-tab-compact:hover .vc-channeltabs-name-text {
+ max-width: 0 !important;
+ opacity: 0 !important;
+ transform: translateX(-20px) !important;
+}
+
+/* Hide close button on hover for compact tabs when hover-expand is disabled */
+.vc-channeltabs-container.vc-channeltabs-no-compact-hover-expand .vc-channeltabs-tab-compact:hover .vc-channeltabs-close-button-compact {
+ opacity: 0 !important;
+ pointer-events: none;
+}
+
+/* Enhanced caching mode - smoother transitions */
+[class*="chat_"] {
+ transition: opacity 0.1s ease;
+}
+
+/* Prevent flash when switching tabs with cache enabled */
+[class*="scrollerInner_"] {
+ scroll-behavior: auto;
+}
+
+/* Drag and drop visual feedback */
+.vc-channeltabs-bookmarks-drop-target {
+ background-color: color-mix(in srgb, var(--brand-500) 20%, transparent);
+ border: 2px dashed var(--brand-500);
+ border-radius: 4px;
+}
+
+.vc-channeltabs-bookmark.vc-channeltabs-folder-drop-target {
+ background-color: color-mix(in srgb, var(--brand-500) 30%, transparent);
+ border: 2px solid var(--brand-500);
+ transform: scale(1.05);
+ box-shadow: 0 0 8px var(--brand-500);
+}
+
+/* Folder expansion */
+.vc-channeltabs-bookmark.vc-channeltabs-folder-expanded {
+ background-color: var(--background-modifier-selected);
+}
+
+/* Folder item styling - indented and visually connected to folder */
+.vc-channeltabs-bookmark.vc-channeltabs-folder-item {
+ margin-left: 0.5rem;
+ border-left: 2px solid var(--background-modifier-accent);
+ padding-left: 0.5rem;
+ opacity: 0.9;
+ background-color: var(--background-secondary-alt);
+}
+
+.vc-channeltabs-bookmark.vc-channeltabs-folder-item:hover {
+ opacity: 1;
+ background-color: var(--background-modifier-hover);
+}
+
+/* Active bookmark visual indicator */
+.vc-channeltabs-bookmark.vc-channeltabs-bookmark-active {
+ border: 1px solid var(--brand-500);
+ box-shadow: 0 0 4px var(--brand-500);
+ background-color: color-mix(in srgb, var(--brand-500) 15%, transparent);
+}
+
+.vc-channeltabs-bookmark.vc-channeltabs-bookmark-active:hover {
+ box-shadow: 0 0 6px var(--brand-500);
+}
diff --git a/src/equicordplugins/channelTabs/util/bookmarks.ts b/src/equicordplugins/channelTabs/util/bookmarks.ts
index 98854c888..e43106f02 100644
--- a/src/equicordplugins/channelTabs/util/bookmarks.ts
+++ b/src/equicordplugins/channelTabs/util/bookmarks.ts
@@ -17,7 +17,25 @@ export function isBookmarkFolder(bookmark: Bookmark | BookmarkFolder): bookmark
export function bookmarkPlaceholderName(bookmark: Omit) {
if (isBookmarkFolder(bookmark as Bookmark | BookmarkFolder)) return "Folder";
- const channel = ChannelStore.getChannel((bookmark as Bookmark).channelId);
+
+ const { channelId } = (bookmark as Bookmark);
+
+ // handle special synthetic pages
+ if (channelId?.startsWith("__")) {
+ const specialPagesMap: Record = {
+ "__quests__": "Quests",
+ "__message-requests__": "Message Requests",
+ "__friends__": "Friends",
+ "__shop__": "Shop",
+ "__library__": "Library",
+ "__discovery__": "Discovery",
+ "__nitro__": "Nitro"
+ };
+
+ return specialPagesMap[channelId] || "Special Page";
+ }
+
+ const channel = ChannelStore.getChannel(channelId);
if (!channel) return "Bookmark";
if (channel.name) return `#${channel.name}`;
diff --git a/src/equicordplugins/channelTabs/util/constants.tsx b/src/equicordplugins/channelTabs/util/constants.tsx
index 759fade7c..8fbbbb55a 100644
--- a/src/equicordplugins/channelTabs/util/constants.tsx
+++ b/src/equicordplugins/channelTabs/util/constants.tsx
@@ -7,7 +7,6 @@
import { definePluginSettings } from "@api/Settings";
import { Logger } from "@utils/Logger";
import { OptionType } from "@utils/types";
-import { findComponentByCodeLazy } from "@webpack";
import { Forms, SearchableSelect, useState } from "@webpack/common";
import { JSX } from "react";
@@ -32,7 +31,10 @@ function AnimationSettings(): JSX.Element {
{ label: "Compact Mode Expansion", value: "compact-expand", selected: settings.store.animationCompactExpand },
{ label: "Selected Tab Blue Border", value: "selected-border", selected: settings.store.animationSelectedBorder },
{ label: "Selected Tab Background Color", value: "selected-background", selected: settings.store.animationSelectedBackground },
- { label: "Tab Shadow Effects", value: "tab-shadows", selected: settings.store.animationTabShadows }
+ { label: "Tab Shadow Effects", value: "tab-shadows", selected: settings.store.animationTabShadows },
+ { label: "Tab Repositioning (smooth position changes)", value: "tab-positioning", selected: settings.store.animationTabPositioning },
+ { label: "Resize Handle Fade", value: "resize-handle", selected: settings.store.animationResizeHandle },
+ { label: "Active Quests Gradient", value: "quests-active", selected: settings.store.animationQuestsActive }
];
const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected));
@@ -56,6 +58,9 @@ function AnimationSettings(): JSX.Element {
settings.store.animationSelectedBorder = enabledValues.includes("selected-border");
settings.store.animationSelectedBackground = enabledValues.includes("selected-background");
settings.store.animationTabShadows = enabledValues.includes("tab-shadows");
+ settings.store.animationTabPositioning = enabledValues.includes("tab-positioning");
+ settings.store.animationResizeHandle = enabledValues.includes("resize-handle");
+ settings.store.animationQuestsActive = enabledValues.includes("quests-active");
setCurrentValue(enabled);
}
@@ -160,6 +165,19 @@ export const settings = definePluginSettings({
default: false,
restartNeeded: false
},
+ tabWidthScale: {
+ type: OptionType.NUMBER,
+ description: "Tab width scale (percentage) - adjustable by dragging tab edges",
+ default: 100,
+ hidden: true,
+ restartNeeded: false
+ },
+ renderAllTabs: {
+ type: OptionType.BOOLEAN,
+ description: "Keep all tabs cached in memory for faster switching (caches scroll position and state)",
+ default: false,
+ restartNeeded: false
+ },
switchToExistingTab: {
type: OptionType.BOOLEAN,
description: "Switch to tab if it already exists for the channel you're navigating to",
@@ -284,18 +302,52 @@ export const settings = definePluginSettings({
default: true,
hidden: true
},
+ animationTabPositioning: {
+ type: OptionType.BOOLEAN,
+ description: "Enable smooth transitions when tabs move positions",
+ default: true,
+ hidden: true
+ },
+ animationResizeHandle: {
+ type: OptionType.BOOLEAN,
+ description: "Enable fade animation for resize handle",
+ default: true,
+ hidden: true
+ },
+ animationQuestsActive: {
+ type: OptionType.BOOLEAN,
+ description: "Enable gradient animations on Quests tab when quests are actively running",
+ default: true,
+ hidden: true
+ },
compactAutoExpandSelected: {
type: OptionType.BOOLEAN,
description: "Automatically expand compact tabs when selected to show the full channel name",
default: true,
restartNeeded: false
},
+ compactAutoExpandOnHover: {
+ type: OptionType.BOOLEAN,
+ description: "Automatically expand compact tabs on hover to show the full channel name",
+ default: true,
+ restartNeeded: false
+ },
openInNewTabAutoSwitch: {
type: OptionType.BOOLEAN,
description: "Automatically switch to new tabs opened from 'Open in New Tab' context menu",
default: true,
restartNeeded: false
+ },
+ bookmarksIndependentFromTabs: {
+ type: OptionType.BOOLEAN,
+ description: "Bookmarks navigate independently without affecting the active tabs bar",
+ default: true,
+ restartNeeded: false
+ },
+ showResizeHandle: {
+ type: OptionType.BOOLEAN,
+ description: "Show resize handle when hovering over tabs to adjust tab width",
+ default: true,
+ restartNeeded: false
}
});
-
-export const CircleQuestionIcon = findComponentByCodeLazy("10.58l-3.3-3.3a1");
diff --git a/src/equicordplugins/channelTabs/util/icons.ts b/src/equicordplugins/channelTabs/util/icons.ts
new file mode 100644
index 000000000..87c3c756f
--- /dev/null
+++ b/src/equicordplugins/channelTabs/util/icons.ts
@@ -0,0 +1,16 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { findComponentByCodeLazy } from "@webpack";
+
+// icons n shi
+export const QuestIcon = findComponentByCodeLazy("10.47a.76.76");
+export const ShopIcon = findComponentByCodeLazy("M2.63 4.19A3");
+export const EnvelopeIcon = findComponentByCodeLazy("M1.16 5.02c-.1.28");
+export const DiscoveryIcon = findComponentByCodeLazy("M7.74 9.3A2 2 0 0 1 9.3 7.75l7.22");
+export const NitroIcon = findComponentByCodeLazy("M16.23 12c0 1.29-.95 2.25");
+export const FriendsIcon = findComponentByCodeLazy("12h1a8");
+export const CircleQuestionIcon = findComponentByCodeLazy("10.58l-3.3-3.3a1");
diff --git a/src/equicordplugins/channelTabs/util/index.ts b/src/equicordplugins/channelTabs/util/index.ts
index 3cfe7f44c..14c3f1bf9 100644
--- a/src/equicordplugins/channelTabs/util/index.ts
+++ b/src/equicordplugins/channelTabs/util/index.ts
@@ -6,5 +6,6 @@
export * from "./bookmarks";
export * from "./constants";
+export * from "./icons";
export * from "./tabs";
export * from "./types";
diff --git a/src/equicordplugins/channelTabs/util/tabs.tsx b/src/equicordplugins/channelTabs/util/tabs.tsx
index 7dff23977..ee6e9117a 100644
--- a/src/equicordplugins/channelTabs/util/tabs.tsx
+++ b/src/equicordplugins/channelTabs/util/tabs.tsx
@@ -14,6 +14,65 @@ 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;
+
+export function isViewingViaBookmarkMode() {
+ return settings.store.bookmarksIndependentFromTabs && isViewingViaBookmark;
+}
+
+export function isNavigatingViaTab() {
+ return isNavigatingViaTabFlag;
+}
+
+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(array: T[], ...values: T[]) {
const len = array.length;
for (let i = 0; i < len; i++) array.pop();
@@ -29,6 +88,13 @@ let currentlyOpenTab: number;
const openTabHistory: number[] = [];
let persistedTabs: Promise;
+// cache for the tab state (so like scroll pos etc)
+interface TabStateCache {
+ scrollPosition: number;
+ timestamp: number;
+}
+const tabStateCache = new Map();
+
// horror
const _ = {
get openedTabs() {
@@ -63,6 +129,9 @@ export function closeTab(id: number) {
const closed = openTabs.splice(i, 1);
closedTabs.push(...closed);
+ // the memory leak preventer
+ tabStateCache.delete(id);
+
if (id === currentlyOpenTab) {
if (openTabHistory.length) {
openTabHistory.pop();
@@ -131,6 +200,14 @@ export function closeTabsToTheLeft(id: number) {
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
@@ -183,6 +260,10 @@ export function hasClosedTabs() {
}
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;
}
@@ -197,18 +278,93 @@ 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 cacheCurrentTabState() {
+ if (!settings.store.renderAllTabs) return;
+
+ const scrollContainer = getScrollContainer();
+ if (scrollContainer && currentlyOpenTab !== undefined) {
+ 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);
+ // clear bookmark viewing mode when switching to a tab
+ isViewingViaBookmark = false;
+
+ // cache current tab state before switching to it
+ cacheCurrentTabState();
+
setOpenTab(id);
+
+ // SET FLAG: We're initiating navigation
+ isNavigatingViaTabFlag = true;
+
+ // handle special pages with synthetic channelIds
+ if (tab.channelId && tab.channelId.startsWith("__")) {
+ const routeMap: Record = {
+ "__quests__": "/quest-home",
+ "__message-requests__": "/message-requests",
+ "__friends__": "/channels/@me",
+ "__shop__": "/shop",
+ "__library__": "/library",
+ "__discovery__": "/discovery",
+ "__nitro__": "/store"
+ };
+
+ const route = routeMap[tab.channelId];
+ if (route) {
+ setNavigationSource(tab.guildId, tab.channelId, "tab");
+ NavigationRouter.transitionTo(route);
+ // Clear flag after navigation completes
+ setTimeout(() => { isNavigatingViaTabFlag = false; }, 100);
+ return;
+ }
+ }
+
+ // regular channel nav
if (tab.messageId) {
+ setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionTo(`/channels/${tab.guildId}/${tab.channelId}/${tab.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 !== SelectedGuildStore.getGuildId()) {
+ setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionToGuild(tab.guildId, tab.channelId);
+ // restore cached state for the new tab
+ restoreTabState(id);
+ }
else update();
+
+ // Clear flag after navigation
+ setTimeout(() => { isNavigatingViaTabFlag = false; }, 100);
}
export function openStartupTabs(props: BasicChannelTabsProps & { userId: string; }, setUserId: (id: string) => void) {
@@ -290,6 +446,48 @@ export function switchChannel(ch: BasicChannelTabsProps) {
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 = {
+ "__quests__": "/quest-home",
+ "__message-requests__": "/message-requests",
+ "__friends__": "/channels/@me",
+ "__shop__": "/shop",
+ "__library__": "/library",
+ "__discovery__": "/discovery",
+ "__nitro__": "/store"
+ };
+
+ 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);