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