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 { classes } from "@utils/misc";
import { closeModal, openModal } from "@utils/modal"; import { closeModal, openModal } from "@utils/modal";
import { findComponentByCodeLazy } from "@webpack"; 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 { NotificationDot } from "./ChannelTab";
import { BookmarkContextMenu, EditModal } from "./ContextMenus"; import { BookmarkContextMenu, EditModal } from "./ContextMenus";
@ -19,6 +20,19 @@ const cl = classNameFactory("vc-channeltabs-");
const StarIcon = findComponentByCodeLazy(".73-2.25h6.12l1.9-5.83Z"); 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; }) { function FolderIcon({ fill }: { fill: string; }) {
return ( return (
<path <path
@ -39,6 +53,29 @@ function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
</svg> </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 channel = ChannelStore.getChannel(bookmark.channelId);
const guild = GuildStore.getGuild(bookmark.guildId); const guild = GuildStore.getGuild(bookmark.guildId);
if (guild) return guild.icon if (guild) return guild.icon
@ -167,8 +204,110 @@ function BookmarkFolderOpenMenu(props: BookmarkProps) {
); );
} }
function Bookmark(props: BookmarkProps) { function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isCurrentChannel }: {
const { bookmarks, index, methods } = props; 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 bookmark = bookmarks[index];
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]); const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
@ -176,13 +315,25 @@ function Bookmark(props: BookmarkProps) {
const [, drag] = useDrag(() => ({ const [, drag] = useDrag(() => ({
type: "vc_Bookmark", type: "vc_Bookmark",
canDrag: () => !isBookmarkFolder(bookmark), // Don't allow dragging folders
item: () => { 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 => ({ collect: monitor => ({
isDragging: !!monitor.isDragging() isDragging: !!monitor.isDragging()
}), }),
})); }), [bookmarks, bookmark, index]);
const [, drop] = useDrop(() => ({ const [, drop] = useDrop(() => ({
accept: "vc_Bookmark", accept: "vc_Bookmark",
@ -207,21 +358,88 @@ function Bookmark(props: BookmarkProps) {
item.index = hoverIndex; 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 ( return (
<div <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} ref={ref}
onClick={e => isBookmarkFolder(bookmark) onClick={e => {
? ContextMenuApi.openContextMenu(e, () => <BookmarkFolderOpenMenu {...props} />) if (isBookmarkFolder(bookmark)) {
: switchChannel(bookmark) onToggleFolder?.();
} } else {
navigateToBookmark(bookmark);
}
}}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => onContextMenu={e => ContextMenuApi.openContextMenu(e, () =>
<BookmarkContextMenu {...props} /> <BookmarkContextMenu {...props} />
)} )}
> >
<BookmarkIcon bookmark={bookmark} /> <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")}> <Text variant="text-sm/normal" className={cl("name-text")}>
{bookmark.name} {bookmark.name}
</Text> </Text>
@ -233,17 +451,30 @@ function Bookmark(props: BookmarkProps) {
); );
} }
function HorizontalScroller({ children, className }: React.PropsWithChildren<{ className?: string; }>) { function HorizontalScroller({ children, className, customRef }: React.PropsWithChildren<{ className?: string; customRef?: (node: HTMLDivElement) => void; }>) {
const ref = useRef<HTMLDivElement>(null); const internalRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
ref.current!.addEventListener("wheel", e => { const element = internalRef.current;
if (!element) return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault(); 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 ( return (
<div className={classes(cl("scroller"), className)} ref={ref}> <div className={classes(cl("scroller"), className)} ref={combinedRef}>
{children} {children}
</div> </div>
); );
@ -252,6 +483,31 @@ function HorizontalScroller({ children, className }: React.PropsWithChildren<{ c
export default function BookmarkContainer(props: BasicChannelTabsProps & { userId: string; }) { export default function BookmarkContainer(props: BasicChannelTabsProps & { userId: string; }) {
const { guildId, channelId, userId } = props; const { guildId, channelId, userId } = props;
const [bookmarks, methods] = useBookmarks(userId); 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; let isCurrentChannelBookmarked = false, currentChannelFolderIndex = -1;
bookmarks?.forEach((bookmark, i) => { bookmarks?.forEach((bookmark, i) => {
@ -264,9 +520,47 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
else if (bookmark.channelId === channelId) isCurrentChannelBookmarked = true; 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 ( return (
<div className={cl("bookmark-container")}> <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"> {!bookmarks && <Text className={cl("bookmark-placeholder-text")} variant="text-xs/normal">
Loading bookmarks... Loading bookmarks...
</Text>} </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 You have no bookmarks. You can add an open tab or hide this by right clicking it
</Text>} </Text>}
{Array.isArray(bookmarks) && bookmarks.length > 0 && {Array.isArray(bookmarks) && bookmarks.length > 0 &&
bookmarks.map((_, i) => ( bookmarks.flatMap((bookmark, i) => {
<Bookmark key={i} index={i} bookmarks={bookmarks} methods={methods} /> 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> </HorizontalScroller>

View file

@ -5,21 +5,39 @@
*/ */
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord"; import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { Channel, Guild, User } from "@vencord/discord-types"; import { Channel, Guild, User } from "@vencord/discord-types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; 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 { 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"; import { TabContextMenu } from "./ContextMenus";
const ThreeDots = findComponentByCodeLazy(".dots,", "dotRadius:"); const ThreeDots = findComponentByCodeLazy(".dots,", "dotRadius:");
const dotStyles = findByPropsLazy("numberBadge", "textBadge"); const dotStyles = findByPropsLazy("numberBadge", "textBadge");
const FriendsIcon = findComponentByCodeLazy("12h1a8");
const ChannelTypeIcon = findComponentByCodeLazy(".iconContainerWithGuildIcon,"); 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-"); const cl = classNameFactory("vc-channeltabs-");
function XIcon({ size, fill }: { size: number, fill: string; }) { 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 ( return (
<> <>
<FriendsIcon /> <FriendsIcon />
<Text className={cl("name-text")}>{getIntlMessage("FRIENDS")}</Text> <Text className={cl("name-text")}>{getIntlMessage("FRIENDS")}</Text>
</> </>
); );
}
return ( return (
<> <>
@ -220,12 +263,52 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
const lastSwapTimeRef = useRef(0); const lastSwapTimeRef = useRef(0);
const SWAP_THROTTLE_MS = 100; 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(() => ({ const [, drag] = useDrag(() => ({
type: "vc_ChannelTab", type: "vc_ChannelTab",
item: () => { item: () => {
setIsDragging(true); setIsDragging(true);
lastSwapTimeRef.current = Date.now() - SWAP_THROTTLE_MS; 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 => ({ collect: monitor => ({
isDragging: !!monitor.isDragging() isDragging: !!monitor.isDragging()
@ -235,7 +318,7 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
setIsDropTarget(false); setIsDropTarget(false);
lastSwapTimeRef.current = 0; lastSwapTimeRef.current = 0;
} }
})); }), [id, channelId, guildId]);
const [, drop] = useDrop(() => ({ const [, drop] = useDrop(() => ({
accept: "vc_ChannelTab", accept: "vc_ChannelTab",
hover: (item, monitor) => { hover: (item, monitor) => {
@ -267,7 +350,7 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
const hoverWidth = hoverBoundingRect.right - hoverBoundingRect.left; const hoverWidth = hoverBoundingRect.right - hoverBoundingRect.left;
const hoverMiddleX = hoverWidth / 2; const hoverMiddleX = hoverWidth / 2;
// Get tab width // get tab width
const draggedElement = document.querySelector(".vc-channeltabs-tab-dragging") as HTMLElement; const draggedElement = document.querySelector(".vc-channeltabs-tab-dragging") as HTMLElement;
const draggedWidth = draggedElement?.getBoundingClientRect().width || hoverWidth; const draggedWidth = draggedElement?.getBoundingClientRect().width || hoverWidth;
const halfDraggedWidth = draggedWidth / 2; const halfDraggedWidth = draggedWidth / 2;
@ -295,6 +378,9 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
}), []); }), []);
drag(drop(ref)); drag(drop(ref));
// check if quests running (questify momentLet)
const hasActiveQuests = activeQuestIntervals.size > 0;
return <div return <div
className={cl("tab", { className={cl("tab", {
"tab-compact": compact, "tab-compact": compact,
@ -303,6 +389,8 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
"tab-closing": isClosing, "tab-closing": isClosing,
"tab-dragging": isDragging, "tab-dragging": isDragging,
"tab-drop-target": isDropTarget, "tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks wider: settings.store.widerTabsAndBookmarks
})} })}
key={index} key={index}
@ -334,6 +422,11 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
> >
<XIcon size={16} fill="var(--interactive-normal)" /> <XIcon size={16} fill="var(--interactive-normal)" />
</button>} </button>}
{!compact && settings.store.showResizeHandle && <div
className={cl("tab-resize-handle")}
onMouseDown={handleResizeStart}
/>}
</div>; </div>;
} }

View file

@ -5,12 +5,13 @@
*/ */
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { IS_MAC } from "@utils/constants";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { useForceUpdater } from "@utils/react"; import { useForceUpdater } from "@utils/react";
import { findComponentByCodeLazy, findStoreLazy } from "@webpack"; import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, ContextMenuApi, Flex, FluxDispatcher, Forms, useCallback, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common"; 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 BookmarkContainer from "./BookmarkContainer";
import ChannelTab, { PreviewTab } from "./ChannelTab"; import ChannelTab, { PreviewTab } from "./ChannelTab";
import { BasicContextMenu } from "./ContextMenus"; import { BasicContextMenu } from "./ContextMenus";
@ -27,6 +28,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
const { const {
showBookmarkBar, showBookmarkBar,
widerTabsAndBookmarks, widerTabsAndBookmarks,
tabWidthScale,
enableHotkeys, enableHotkeys,
hotkeyCount, hotkeyCount,
tabBarPosition, tabBarPosition,
@ -42,10 +44,15 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
animationSelectedBorder, animationSelectedBorder,
animationSelectedBackground, animationSelectedBackground,
animationTabShadows, animationTabShadows,
compactAutoExpandSelected animationTabPositioning,
animationResizeHandle,
animationQuestsActive,
compactAutoExpandSelected,
compactAutoExpandOnHover
} = settings.use([ } = settings.use([
"showBookmarkBar", "showBookmarkBar",
"widerTabsAndBookmarks", "widerTabsAndBookmarks",
"tabWidthScale",
"enableHotkeys", "enableHotkeys",
"hotkeyCount", "hotkeyCount",
"tabBarPosition", "tabBarPosition",
@ -61,7 +68,11 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
"animationSelectedBorder", "animationSelectedBorder",
"animationSelectedBackground", "animationSelectedBackground",
"animationTabShadows", "animationTabShadows",
"compactAutoExpandSelected" "animationTabPositioning",
"animationResizeHandle",
"animationQuestsActive",
"compactAutoExpandSelected",
"compactAutoExpandOnHover"
]); ]);
const GhostTabs = useGhostTabs(); const GhostTabs = useGhostTabs();
const isFullscreen = useStateFromStores([ChannelRTCStore], () => ChannelRTCStore.isFullscreenInContext() ?? false); const isFullscreen = useStateFromStores([ChannelRTCStore], () => ChannelRTCStore.isFullscreenInContext() ?? false);
@ -135,8 +146,18 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
useEffect(() => { useEffect(() => {
if (userId) { 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); saveTabs(userId);
// Clean up any stale navigation contexts
clearStaleNavigationContext();
} }
}, [userId, props.channelId, props.guildId]); }, [userId, props.channelId, props.guildId]);
@ -149,6 +170,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
className={classes( className={classes(
cl("container"), cl("container"),
tabBarPosition === "top" && cl("container-top"), tabBarPosition === "top" && cl("container-top"),
IS_MAC && tabBarPosition === "top" && cl("container-top-macos"),
!animationHover && cl("no-hover-animation"), !animationHover && cl("no-hover-animation"),
!animationSelection && cl("no-selection-animation"), !animationSelection && cl("no-selection-animation"),
!animationDragDrop && cl("no-drag-animation"), !animationDragDrop && cl("no-drag-animation"),
@ -161,9 +183,14 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
!animationSelectedBorder && cl("no-selected-border"), !animationSelectedBorder && cl("no-selected-border"),
!animationSelectedBackground && cl("no-selected-background"), !animationSelectedBackground && cl("no-selected-background"),
!animationTabShadows && cl("no-tab-shadows"), !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} ref={ref}
style={{ "--tab-width-scale": tabWidthScale / 100 } as React.CSSProperties}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <BasicContextMenu />)} onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <BasicContextMenu />)}
> >
{showBookmarkBar && <> {showBookmarkBar && <>

View file

@ -97,6 +97,63 @@ export default definePlugin({
containerHeight: 0, 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 }: { render({ currentChannel, children }: {
currentChannel: BasicChannelTabsProps, currentChannel: BasicChannelTabsProps,
children: JSX.Element; children: JSX.Element;
@ -134,7 +191,18 @@ export default definePlugin({
}, },
handleNavigation(guildId: string, channelId: string) { 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 // wait for discord to update channel data
requestAnimationFrame(() => { requestAnimationFrame(() => {

View file

@ -68,10 +68,14 @@
} }
.vc-channeltabs-container-top { .vc-channeltabs-container-top {
padding-left: 80px; padding-left: 4px;
box-shadow: inset 0 -1px 0 var(--background-tertiary); box-shadow: inset 0 -1px 0 var(--background-tertiary);
} }
.vc-channeltabs-container-top-macos {
padding-left: 80px !important;
}
.vc-channeltabs-separator { .vc-channeltabs-separator {
width: 98%; width: 98%;
height: 1px; height: 1px;
@ -94,7 +98,12 @@
.vc-channeltabs-bookmarks { .vc-channeltabs-bookmarks {
flex-grow: 1; flex-grow: 1;
overflow: hidden; 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 { .vc-channeltabs-bookmark-placeholder-text {
@ -121,14 +130,14 @@
.vc-channeltabs-bookmark { .vc-channeltabs-bookmark {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
max-width: 8rem; max-width: calc(8rem * var(--tab-width-scale, 1));
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
align-items: center; align-items: center;
} }
.vc-channeltabs-bookmark.vc-channeltabs-wider { .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 { .vc-channeltabs-bookmark .vc-channeltabs-name-text {
@ -160,7 +169,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-radius: 12px; border-radius: 12px;
width: 12rem; width: calc(12rem * var(--tab-width-scale, 1));
min-width: 0; min-width: 0;
margin: 0 4px; margin: 0 4px;
background-color: transparent; background-color: transparent;
@ -180,8 +189,44 @@
cursor: grabbing; 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) { .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 { .vc-channeltabs-tab-compact {
@ -429,7 +474,7 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
width: 12rem; width: calc(12rem * var(--tab-width-scale, 1));
padding: 0.125rem 0; padding: 0.125rem 0;
min-width: 0; min-width: 0;
border-radius: 0.25rem; border-radius: 0.25rem;
@ -492,6 +537,160 @@
0 0 24px rgb(242 63 66 / 50%); 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 */ /* Tab closing animation */
@keyframes vc-channeltabs-tab-close { @keyframes vc-channeltabs-tab-close {
0% { 0% {
@ -647,6 +846,13 @@
animation: none !important; 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 { .vc-channeltabs-container.vc-channeltabs-no-enter-exit-animation .vc-channeltabs-tab {
transition: opacity 0.2s ease-out, transition: opacity 0.2s ease-out,
transform 0.2s ease-in-out, transform 0.2s ease-in-out,
@ -730,6 +936,31 @@
transition: none !important; 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 */ /* 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 { .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; padding-right: 0 !important;
@ -746,3 +977,74 @@
opacity: 0 !important; opacity: 0 !important;
pointer-events: none; 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">) { export function bookmarkPlaceholderName(bookmark: Omit<Bookmark | BookmarkFolder, "name">) {
if (isBookmarkFolder(bookmark as Bookmark | BookmarkFolder)) return "Folder"; 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) return "Bookmark";
if (channel.name) return `#${channel.name}`; if (channel.name) return `#${channel.name}`;

View file

@ -7,7 +7,6 @@
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { OptionType } from "@utils/types"; import { OptionType } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { Forms, SearchableSelect, useState } from "@webpack/common"; import { Forms, SearchableSelect, useState } from "@webpack/common";
import { JSX } from "react"; import { JSX } from "react";
@ -32,7 +31,10 @@ function AnimationSettings(): JSX.Element {
{ label: "Compact Mode Expansion", value: "compact-expand", selected: settings.store.animationCompactExpand }, { 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 Blue Border", value: "selected-border", selected: settings.store.animationSelectedBorder },
{ label: "Selected Tab Background Color", value: "selected-background", selected: settings.store.animationSelectedBackground }, { 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)); 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.animationSelectedBorder = enabledValues.includes("selected-border");
settings.store.animationSelectedBackground = enabledValues.includes("selected-background"); settings.store.animationSelectedBackground = enabledValues.includes("selected-background");
settings.store.animationTabShadows = enabledValues.includes("tab-shadows"); 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); setCurrentValue(enabled);
} }
@ -160,6 +165,19 @@ export const settings = definePluginSettings({
default: false, default: false,
restartNeeded: 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: { switchToExistingTab: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Switch to tab if it already exists for the channel you're navigating to", 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, default: true,
hidden: 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: { compactAutoExpandSelected: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Automatically expand compact tabs when selected to show the full channel name", description: "Automatically expand compact tabs when selected to show the full channel name",
default: true, default: true,
restartNeeded: false restartNeeded: false
}, },
compactAutoExpandOnHover: {
type: OptionType.BOOLEAN,
description: "Automatically expand compact tabs on hover to show the full channel name",
default: true,
restartNeeded: false
},
openInNewTabAutoSwitch: { openInNewTabAutoSwitch: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Automatically switch to new tabs opened from 'Open in New Tab' context menu", description: "Automatically switch to new tabs opened from 'Open in New Tab' context menu",
default: true, default: true,
restartNeeded: false 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 "./bookmarks";
export * from "./constants"; export * from "./constants";
export * from "./icons";
export * from "./tabs"; export * from "./tabs";
export * from "./types"; export * from "./types";

View file

@ -14,6 +14,65 @@ import { BasicChannelTabsProps, ChannelTabsProps, PersistedTabs } from "./types"
const cl = classNameFactory("vc-channeltabs-"); 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[]) { function replaceArray<T>(array: T[], ...values: T[]) {
const len = array.length; const len = array.length;
for (let i = 0; i < len; i++) array.pop(); for (let i = 0; i < len; i++) array.pop();
@ -29,6 +88,13 @@ let currentlyOpenTab: number;
const openTabHistory: number[] = []; const openTabHistory: number[] = [];
let persistedTabs: Promise<PersistedTabs | undefined>; 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 // horror
const _ = { const _ = {
get openedTabs() { get openedTabs() {
@ -63,6 +129,9 @@ export function closeTab(id: number) {
const closed = openTabs.splice(i, 1); const closed = openTabs.splice(i, 1);
closedTabs.push(...closed); closedTabs.push(...closed);
// the memory leak preventer
tabStateCache.delete(id);
if (id === currentlyOpenTab) { if (id === currentlyOpenTab) {
if (openTabHistory.length) { if (openTabHistory.length) {
openTabHistory.pop(); openTabHistory.pop();
@ -131,6 +200,14 @@ export function closeTabsToTheLeft(id: number) {
let lastNavigationTime = 0; let lastNavigationTime = 0;
export function handleChannelSwitch(ch: BasicChannelTabsProps) { 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); const tab = openTabs.find(c => c.id === currentlyOpenTab);
// Normalize guildId for DMs/Group Chats to ensure consistent comparison // Normalize guildId for DMs/Group Chats to ensure consistent comparison
@ -183,6 +260,10 @@ export function hasClosedTabs() {
} }
export function isTabSelected(id: number) { 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; return id === currentlyOpenTab;
} }
@ -197,18 +278,93 @@ export function moveDraggedTabs(index1: number, index2: number) {
update(); 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) { export function moveToTab(id: number) {
const tab = openTabs.find(v => v.id === id); const tab = openTabs.find(v => v.id === id);
if (tab === undefined) return logger.error("Couldn't find channel tab with ID " + id, openTabs); 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); 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) { if (tab.messageId) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionTo(`/channels/${tab.guildId}/${tab.channelId}/${tab.messageId}`); NavigationRouter.transitionTo(`/channels/${tab.guildId}/${tab.channelId}/${tab.messageId}`);
delete openTabs[openTabs.indexOf(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); NavigationRouter.transitionToGuild(tab.guildId, tab.channelId);
// restore cached state for the new tab
restoreTabState(id);
}
else update(); else update();
// Clear flag after navigation
setTimeout(() => { isNavigatingViaTabFlag = false; }, 100);
} }
export function openStartupTabs(props: BasicChannelTabsProps & { userId: string; }, setUserId: (id: string) => void) { 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); 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) { export function toggleCompactTab(id: number) {
const i = openTabs.findIndex(v => v.id === id); const i = openTabs.findIndex(v => v.id === id);
if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs); if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);