Feat/channeltabs (#435)

* add serialization for settings and a myraid of other fixes rewrites.

* test dummy settings

* fix issue with commits not being logged, rewrite some elements

* remove dummy setting

* windows titlebar padding fix (maybe works maybe not)

* scaling, padding with linux, global rendering

* inverse padding logic

* disable compact expand on hover setting

* myriad of fixes, enhanced bookmark system, and shit

* change to intl

* fix crashing

* fix errorboundry

* fixes

* whitespace moment

* whitespace moment

* icon fixes

* Polish bookmark navigation

* resize handle settings

* lint horror

* proper message requests support

* Bookmark special pages support (Quests, Shop, Message Requests, Discovery)

* nitro page support (with a lil easter egg)

* active quest gradient (toggleable)

* different icon method

---------

Co-authored-by: Jake <jake@Georges-MacBook-Air.local>
Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Jake 2025-10-04 00:51:59 +01:00 • committed by GitHub
parent 2c811c791d
commit 079f462e2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1157 additions and 47 deletions

View file

@ -9,9 +9,10 @@ import { getGuildAcronym, getIntlMessage } from "@utils/discord";
import { classes } from "@utils/misc";
import { closeModal, openModal } from "@utils/modal";
import { findComponentByCodeLazy } from "@webpack";
import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, ReadStateStore, ReadStateUtils, Text, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore } from "@webpack/common";
import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, React, ReadStateStore, ReadStateUtils, SelectedChannelStore, SelectedGuildStore, Text, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore, useState } from "@webpack/common";
import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, CircleQuestionIcon, isBookmarkFolder, settings, switchChannel, useBookmarks } from "../util";
import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, isBookmarkFolder, isTabSelected, navigateToBookmark, openedTabs, settings, switchChannel, useBookmarks } from "../util";
import { CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, NitroIcon, QuestIcon, ShopIcon } from "../util/icons";
import { NotificationDot } from "./ChannelTab";
import { BookmarkContextMenu, EditModal } from "./ContextMenus";
@ -19,6 +20,19 @@ const cl = classNameFactory("vc-channeltabs-");
const StarIcon = findComponentByCodeLazy(".73-2.25h6.12l1.9-5.83Z");
function LibraryIcon({ height = 16, width = 16 }: { height?: number; width?: number; }) {
return (
<svg
viewBox="0 0 24 24"
height={height}
width={width}
fill="none"
>
<path fill="currentColor" d="M3 3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3zm2 1v16h10V4H5zm13-1h2a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-2V3zm0 2v12h1V5h-1z" />
</svg>
);
}
function FolderIcon({ fill }: { fill: string; }) {
return (
<path
@ -39,6 +53,29 @@ function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
</svg>
);
// Handle special synthetic pages
const { channelId } = bookmark;
if (channelId?.startsWith("__")) {
const specialIconsMap: Record<string, React.ComponentType<any>> = {
"__quests__": QuestIcon,
"__message-requests__": EnvelopeIcon,
"__friends__": FriendsIcon,
"__shop__": ShopIcon,
"__library__": () => <LibraryIcon height={16} width={16} />,
"__discovery__": DiscoveryIcon,
"__nitro__": NitroIcon
};
const IconComponent = specialIconsMap[channelId];
if (IconComponent) {
// LibraryIcon is a function that returns JSX, others are webpack components
if (channelId === "__library__") {
return <LibraryIcon height={16} width={16} />;
}
return <IconComponent height={16} width={16} />;
}
}
const channel = ChannelStore.getChannel(bookmark.channelId);
const guild = GuildStore.getGuild(bookmark.guildId);
if (guild) return guild.icon
@ -167,8 +204,110 @@ function BookmarkFolderOpenMenu(props: BookmarkProps) {
);
}
function Bookmark(props: BookmarkProps) {
const { bookmarks, index, methods } = props;
function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isCurrentChannel }: {
bookmark: Bookmark;
folderIndex: number;
bookmarkIndex: number;
methods: any;
isCurrentChannel?: boolean;
}) {
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
const ref = useRef<HTMLDivElement>(null);
// drag implementation for folder items
const [, drag] = useDrag(() => ({
type: "vc_Bookmark",
item: () => ({
index: bookmarkIndex, // index within folder
folderIndex: folderIndex, // the folder its in
bookmark: bookmark,
isFromFolder: true
}),
collect: monitor => ({
isDragging: !!monitor.isDragging()
}),
}), [bookmark, folderIndex, bookmarkIndex]);
// combine drag ref
drag(ref);
return (
<div
ref={ref}
className={classes(
cl("bookmark", "folder-item", "hoverable"),
isCurrentChannel && cl("bookmark-active")
)}
onClick={() => navigateToBookmark(bookmark)}
onContextMenu={e => {
e.stopPropagation();
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="folder-item-context"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
>
<Menu.MenuItem
key="edit-bookmark"
id="edit-bookmark"
label="Edit Bookmark"
action={() => {
const key = openModal(modalProps =>
<EditModal
modalProps={modalProps}
modalKey={key}
bookmark={bookmark}
onSave={name => {
const folder = methods.bookmarks[folderIndex] as BookmarkFolder;
const newBookmarks = [...folder.bookmarks];
newBookmarks[bookmarkIndex].name = name;
methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
closeModal(key);
}}
/>
);
}}
/>
<Menu.MenuItem
key="delete-bookmark"
id="delete-bookmark"
label="Delete Bookmark"
action={() => methods.deleteBookmark(bookmarkIndex, folderIndex)}
/>
<Menu.MenuItem
key="remove-from-folder"
id="remove-from-folder"
label="Remove from Folder"
action={() => {
methods.addBookmark(bookmark);
methods.deleteBookmark(bookmarkIndex, folderIndex);
}}
/>
{bookmarkNotificationDot && ReadStateStore.hasUnread(bookmark.channelId) && (
<Menu.MenuItem
key="mark-as-read"
id="mark-as-read"
label={getIntlMessage("MARK_AS_READ")}
action={() => ReadStateUtils.ackChannel(ChannelStore.getChannel(bookmark.channelId))}
/>
)}
</Menu.Menu>
));
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" style={{ marginRight: "0.25rem" }}>
<path fill="currentColor" d="M10 17l5-5-5-5z" />
</svg>
<BookmarkIcon bookmark={bookmark} />
<Text variant="text-sm/normal" className={cl("name-text")}>
{bookmark.name}
</Text>
{bookmarkNotificationDot && <NotificationDot channelIds={[bookmark.channelId]} />}
</div>
);
}
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 (
<div
className={cl("bookmark", "hoverable", { wider: settings.store.widerTabsAndBookmarks })}
className={classes(
cl("bookmark", "hoverable", { wider: settings.store.widerTabsAndBookmarks }),
isFolderDropTarget && cl("folder-drop-target"),
isExpanded && cl("folder-expanded"),
isCurrentChannel && cl("bookmark-active")
)}
ref={ref}
onClick={e => isBookmarkFolder(bookmark)
? ContextMenuApi.openContextMenu(e, () => <BookmarkFolderOpenMenu {...props} />)
: switchChannel(bookmark)
}
onClick={e => {
if (isBookmarkFolder(bookmark)) {
onToggleFolder?.();
} else {
navigateToBookmark(bookmark);
}
}}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () =>
<BookmarkContextMenu {...props} />
)}
>
<BookmarkIcon bookmark={bookmark} />
{isBookmarkFolder(bookmark) && (
<svg width="12" height="12" viewBox="0 0 24 24" style={{ marginLeft: "0.25rem" }}>
<path
fill="currentColor"
d={isExpanded ? "M7 10l5 5 5-5z" : "M10 17l5-5-5-5z"}
/>
</svg>
)}
<Text variant="text-sm/normal" className={cl("name-text")}>
{bookmark.name}
</Text>
@ -233,17 +451,30 @@ function Bookmark(props: BookmarkProps) {
);
}
function HorizontalScroller({ children, className }: React.PropsWithChildren<{ className?: string; }>) {
const ref = useRef<HTMLDivElement>(null);
function HorizontalScroller({ children, className, customRef }: React.PropsWithChildren<{ className?: string; customRef?: (node: HTMLDivElement) => void; }>) {
const internalRef = useRef<HTMLDivElement>(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 (
<div className={classes(cl("scroller"), className)} ref={ref}>
<div className={classes(cl("scroller"), className)} ref={combinedRef}>
{children}
</div>
);
@ -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<Set<number>>(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 (
<div className={cl("bookmark-container")}>
<HorizontalScroller className={cl("bookmarks")}>
<HorizontalScroller className={classes(cl("bookmarks"), isOver && cl("bookmarks-drop-target"))} customRef={dropRef}>
{!bookmarks && <Text className={cl("bookmark-placeholder-text")} variant="text-xs/normal">
Loading bookmarks...
</Text>}
@ -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
</Text>}
{Array.isArray(bookmarks) && bookmarks.length > 0 &&
bookmarks.map((_, i) => (
<Bookmark key={i} index={i} bookmarks={bookmarks} methods={methods} />
))
bookmarks.flatMap((bookmark, i) => {
const items = [
<Bookmark
key={i}
index={i}
bookmarks={bookmarks}
methods={methods}
isExpanded={expandedFolders.has(i)}
onToggleFolder={() => 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(
<FolderBookmarkItem
key={`${i}-${childIndex}`}
bookmark={childBookmark}
folderIndex={i}
bookmarkIndex={childIndex}
methods={methods}
isCurrentChannel={
bookmarksIndependentFromTabs &&
!channelMatchesActiveTab && (
childBookmark.channelId === currentChannelId &&
(childBookmark.guildId || "@me") === currentGuildId
)
}
/>
);
});
}
return items;
})
}
</HorizontalScroller>

View file

@ -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 (
<svg
viewBox="0 0 24 24"
height={height}
width={width}
fill="none"
className={className}
>
<path fill="currentColor" d="M3 3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3zm2 1v16h10V4H5zm13-1h2a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-2V3zm0 2v12h1V5h-1z" />
</svg>
);
}
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<string, { label: string, Icon: React.ComponentType<any>; }> = {
"__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 (
<>
<Icon />
<Text className={cl("name-text")}>{label}</Text>
</>
);
}
}
if (guildId === "@me" || guildId === undefined) {
return (
<>
<FriendsIcon />
<Text className={cl("name-text")}>{getIntlMessage("FRIENDS")}</Text>
</>
);
}
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 <div
className={cl("tab", {
"tab-compact": compact,
@ -303,6 +389,8 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
"tab-closing": isClosing,
"tab-dragging": isDragging,
"tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks
})}
key={index}
@ -334,6 +422,11 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
>
<XIcon size={16} fill="var(--interactive-normal)" />
</button>}
{!compact && settings.store.showResizeHandle && <div
className={cl("tab-resize-handle")}
onMouseDown={handleResizeStart}
/>}
</div>;
}

View file

@ -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, () => <BasicContextMenu />)}
>
{showBookmarkBar && <>

View file

@ -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(() => {

View file

@ -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);
}

View file

@ -17,7 +17,25 @@ export function isBookmarkFolder(bookmark: Bookmark | BookmarkFolder): bookmark
export function bookmarkPlaceholderName(bookmark: Omit<Bookmark | BookmarkFolder, "name">) {
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<string, string> = {
"__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}`;

View file

@ -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");

View file

@ -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");

View file

@ -6,5 +6,6 @@
export * from "./bookmarks";
export * from "./constants";
export * from "./icons";
export * from "./tabs";
export * from "./types";

View file

@ -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<T>(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<PersistedTabs | undefined>;
// cache for the tab state (so like scroll pos etc)
interface TabStateCache {
scrollPosition: number;
timestamp: number;
}
const tabStateCache = new Map<number, TabStateCache>();
// 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<string, string> = {
"__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<string, string> = {
"__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);