From 0e0377f0930a6bf6314d70c03dc5b714311ca5e4 Mon Sep 17 00:00:00 2001
From: thororen1234 <78185467+thororen1234@users.noreply.github.com>
Date: Sat, 27 Jul 2024 23:51:47 -0400
Subject: [PATCH] ChannelTabs
---
.../components/BookmarkContainer.tsx | 306 ++++++++++++++++
.../channelTabs/components/ChannelTab.tsx | 274 ++++++++++++++
.../components/ChannelTabsContainer.tsx | 130 +++++++
.../channelTabs/components/ContextMenus.tsx | 341 ++++++++++++++++++
src/equicordplugins/channelTabs/index.tsx | 135 +++++++
src/equicordplugins/channelTabs/style.css | 262 ++++++++++++++
.../channelTabs/util/bookmarks.ts | 117 ++++++
.../channelTabs/util/constants.ts | 77 ++++
src/equicordplugins/channelTabs/util/index.ts | 10 +
src/equicordplugins/channelTabs/util/tabs.tsx | 260 +++++++++++++
src/equicordplugins/channelTabs/util/types.ts | 46 +++
src/equicordplugins/loginWithQR/index.tsx | 2 +-
src/utils/constants.ts | 4 +
src/webpack/common/components.ts | 4 +-
src/webpack/common/stores.ts | 2 +
src/webpack/common/types/components.d.ts | 7 +
src/webpack/common/utils.ts | 8 +
17 files changed, 1983 insertions(+), 2 deletions(-)
create mode 100644 src/equicordplugins/channelTabs/components/BookmarkContainer.tsx
create mode 100644 src/equicordplugins/channelTabs/components/ChannelTab.tsx
create mode 100644 src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx
create mode 100644 src/equicordplugins/channelTabs/components/ContextMenus.tsx
create mode 100644 src/equicordplugins/channelTabs/index.tsx
create mode 100644 src/equicordplugins/channelTabs/style.css
create mode 100644 src/equicordplugins/channelTabs/util/bookmarks.ts
create mode 100644 src/equicordplugins/channelTabs/util/constants.ts
create mode 100644 src/equicordplugins/channelTabs/util/index.ts
create mode 100644 src/equicordplugins/channelTabs/util/tabs.tsx
create mode 100644 src/equicordplugins/channelTabs/util/types.ts
diff --git a/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx b/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx
new file mode 100644
index 00000000..4cc2ae7c
--- /dev/null
+++ b/src/equicordplugins/channelTabs/components/BookmarkContainer.tsx
@@ -0,0 +1,306 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { classNameFactory } from "@api/Styles";
+import { classes } from "@utils/misc";
+import { closeModal, openModal } from "@utils/modal";
+import { findByPropsLazy } from "@webpack";
+import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, i18n, Menu, ReadStateStore, ReadStateUtils, Text, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore } from "@webpack/common";
+
+import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, CircleQuestionIcon, isBookmarkFolder, settings, switchChannel, useBookmarks } from "../util";
+import { NotificationDot } from "./ChannelTab";
+import { BookmarkContextMenu, EditModal } from "./ContextMenus";
+
+const cl = classNameFactory("vc-channeltabs-");
+
+const { StarIcon } = findByPropsLazy("StarIcon");
+
+function FolderIcon({ fill }: { fill: string; }) {
+ return (
+
+ );
+}
+
+function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
+ if (isBookmarkFolder(bookmark)) return (
+
+
+
+ );
+
+ const channel = ChannelStore.getChannel(bookmark.channelId);
+ const guild = GuildStore.getGuild(bookmark.guildId);
+ if (guild) return guild.icon
+ ?
+ :
+ {guild.acronym}
+
;
+
+ if (channel?.recipients?.length) {
+ if (channel.recipients.length === 1) return (
+
+ );
+ else return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function BookmarkFolderOpenMenu(props: BookmarkProps) {
+ const { bookmarks, index, methods } = props;
+ const bookmark = bookmarks[index] as BookmarkFolder;
+ const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
+
+ return (
+ FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label="Bookmark Folder Menu"
+ >
+ {bookmark.bookmarks.map((b, i) =>
+
+ {b.name}
+
+ {bookmarkNotificationDot && }
+
+ }
+ icon={() => }
+ showIconFirst={true}
+ action={() => switchChannel(b)}
+
+ children={[
+ (
+ bookmarkNotificationDot &&
+ ReadStateUtils.ackChannel(ChannelStore.getChannel(b.channelId))}
+ />
+
+ ),
+
+ {
+ const key = openModal(modalProps =>
+ {
+ const newBookmarks = [...bookmark.bookmarks];
+ newBookmarks[i].name = name;
+ methods.editBookmark(index, { bookmarks: newBookmarks });
+ closeModal(key);
+ }}
+ />
+ );
+ }}
+ />
+ {
+ methods.deleteBookmark(i, index);
+ }}
+ />
+ {
+ const newBookmarks = [...bookmark.bookmarks];
+ newBookmarks.splice(i, 1);
+
+ methods.addBookmark(b);
+ methods.editBookmark(index, { bookmarks: newBookmarks });
+ }}
+ />
+ ]}
+ />)}
+
+ );
+}
+
+function Bookmark(props: BookmarkProps) {
+ const { bookmarks, index, methods } = props;
+ const bookmark = bookmarks[index];
+ const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
+
+ const ref = useRef(null);
+
+ const [, drag] = useDrag(() => ({
+ type: "vc_Bookmark",
+ item: () => {
+ return { index };
+ },
+ collect: monitor => ({
+ isDragging: !!monitor.isDragging()
+ }),
+ }));
+
+ const [, drop] = useDrop(() => ({
+ accept: "vc_Bookmark",
+ hover: (item, monitor) => {
+ if (!ref.current) return;
+
+ const dragIndex = item.index;
+ const hoverIndex = index;
+ if (dragIndex === hoverIndex) return;
+
+ const hoverBoundingRect = ref.current?.getBoundingClientRect();
+ const hoverMiddleX =
+ (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientX = clientOffset.x - hoverBoundingRect.left;
+ if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
+ || dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
+ return;
+ }
+
+ methods.moveDraggedBookmarks(dragIndex, hoverIndex);
+ item.index = hoverIndex;
+ },
+ }), []);
+ drag(drop(ref));
+
+ return (
+ isBookmarkFolder(bookmark)
+ ? ContextMenuApi.openContextMenu(e, () => )
+ : switchChannel(bookmark)
+ }
+ onContextMenu={e => ContextMenuApi.openContextMenu(e, () =>
+
+ )}
+ >
+
+
+ {bookmark.name}
+
+ {bookmarkNotificationDot && b.channelId)
+ : [bookmark.channelId]
+ } />}
+
+ );
+}
+
+function HorizontalScroller({ children, className }: React.PropsWithChildren<{ className?: string; }>) {
+ const ref = useRef(null);
+ useEffect(() => {
+ ref.current!.addEventListener("wheel", e => {
+ e.preventDefault();
+ ref.current!.scrollLeft += e.deltaY;
+ });
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default function BookmarkContainer(props: BasicChannelTabsProps & { userId: string; }) {
+ const { guildId, channelId, userId } = props;
+ const [bookmarks, methods] = useBookmarks(userId);
+
+ let isCurrentChannelBookmarked = false, currentChannelFolderIndex = -1;
+ bookmarks?.forEach((bookmark, i) => {
+ if (isBookmarkFolder(bookmark)) {
+ if (bookmark.bookmarks.some(b => b.channelId === channelId)) {
+ isCurrentChannelBookmarked = true;
+ currentChannelFolderIndex = i;
+ }
+ }
+ else if (bookmark.channelId === channelId) isCurrentChannelBookmarked = true;
+ });
+
+ return (
+
+
+ {!bookmarks &&
+ Loading bookmarks...
+ }
+ {bookmarks && !bookmarks.length &&
+ You have no bookmarks. You can add an open tab or hide this by right clicking it
+ }
+ {bookmarks?.length &&
+ bookmarks.map((_, i) => (
+
+ ))
+ }
+
+
+
+ {p => {
+ if (isCurrentChannelBookmarked) {
+ if (currentChannelFolderIndex === -1)
+ methods.deleteBookmark(
+ bookmarks!.findIndex(b => !(isBookmarkFolder(b)) && b.channelId === channelId)
+ );
+
+ else methods.deleteBookmark(
+ (bookmarks![currentChannelFolderIndex] as BookmarkFolder).bookmarks
+ .findIndex(b => b.channelId === channelId),
+ currentChannelFolderIndex
+ );
+ }
+ else methods.addBookmark({
+ guildId,
+ channelId
+ });
+ }}
+ >
+
+ }
+
+
+ );
+}
diff --git a/src/equicordplugins/channelTabs/components/ChannelTab.tsx b/src/equicordplugins/channelTabs/components/ChannelTab.tsx
new file mode 100644
index 00000000..a72ccfc9
--- /dev/null
+++ b/src/equicordplugins/channelTabs/components/ChannelTab.tsx
@@ -0,0 +1,274 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { classNameFactory } from "@api/Styles";
+import { getUniqueUsername } from "@utils/discord";
+import { classes } from "@utils/misc";
+import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
+import { Avatar, ChannelStore, ContextMenuApi, Dots, GuildStore, i18n, PresenceStore, ReadStateStore, Text, TypingStore, useDrag, useDrop, useRef, UserStore, useStateFromStores } from "@webpack/common";
+import { Channel, Guild, User } from "discord-types/general";
+
+import { ChannelTabsProps, CircleQuestionIcon, closeTab, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings } from "../util";
+import { TabContextMenu } from "./ContextMenus";
+
+const { getBadgeWidthForValue } = findByPropsLazy("getBadgeWidthForValue");
+const dotStyles = findByPropsLazy("numberBadge", "textBadge");
+
+const { FriendsIcon } = findByPropsLazy("FriendsIcon");
+const ChannelTypeIcon = findComponentByCodeLazy(".iconContainerWithGuildIcon,");
+
+const cl = classNameFactory("vc-channeltabs-");
+
+function XIcon({ size, fill }: { size: number, fill: string; }) {
+ return
+
+ ;
+}
+
+const GuildIcon = ({ guild }: { guild: Guild; }) => {
+ return guild.icon
+ ?
+ :
+ {guild.acronym}
+
;
+};
+
+const ChannelIcon = ({ channel }: { channel: Channel; }) =>
+ ;
+
+function TypingIndicator({ isTyping }: { isTyping: boolean; }) {
+ return isTyping
+ ?
+ : null;
+}
+
+export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => {
+ const [unreadCount, mentionCount] = useStateFromStores(
+ [ReadStateStore],
+ () => [
+ channelIds.reduce((count, channelId) => count + ReadStateStore.getUnreadCount(channelId), 0),
+ channelIds.reduce((count, channelId) => count + ReadStateStore.getMentionCount(channelId), 0),
+ ]
+ );
+
+ return unreadCount > 0 ?
+ node?.style.setProperty("background-color",
+ mentionCount ? "var(--red-400)" : "var(--brand-500)", "important"
+ )}
+ >
+ {mentionCount || unreadCount}
+
: null;
+};
+
+function ChannelTabContent(props: ChannelTabsProps & {
+ guild?: Guild,
+ channel?: Channel;
+}) {
+ const { guild, guildId, channel, channelId, compact } = props;
+ const userId = UserStore.getCurrentUser()?.id;
+ const recipients = channel?.recipients;
+ const {
+ noPomeloNames,
+ showStatusIndicators
+ } = settings.use(["noPomeloNames", "showStatusIndicators"]);
+
+ const [isTyping, status, isMobile] = useStateFromStores(
+ [TypingStore, PresenceStore],
+ () => [
+ !!((Object.keys(TypingStore.getTypingUsers(props.channelId)) as string[]).filter(id => id !== userId).length),
+ PresenceStore.getStatus(recipients?.[0]) as string,
+ PresenceStore.isMobileOnline(recipients?.[0]) as boolean
+ ]
+ );
+
+ if (guild) {
+ if (channel)
+ return (
+ <>
+
+
+ {!compact && {channel.name} }
+
+
+ >
+ );
+ else {
+ let name = `${i18n.Messages.UNKNOWN_CHANNEL} (${channelId})`;
+ switch (channelId) {
+ case "customize-community":
+ name = i18n.Messages.CHANNELS_AND_ROLES;
+ break;
+ case "channel-browser":
+ name = i18n.Messages.GUILD_SIDEBAR_CHANNEL_BROWSER;
+ break;
+ case "shop":
+ name = i18n.Messages.GUILD_SHOP_CHANNEL_LABEL;
+ break;
+ case "member-safety":
+ name = i18n.Messages.MEMBER_SAFETY_CHANNEL_TITLE;
+ break;
+ case "@home":
+ name = i18n.Messages.SERVER_GUIDE;
+ break;
+ }
+ return (
+ <>
+
+ {!compact && {name} }
+ >
+ );
+ }
+ }
+
+ if (channel && recipients?.length) {
+ if (recipients.length === 1) {
+ const user = UserStore.getUser(recipients[0]) as User & { globalName: string, isPomelo(): boolean; };
+ const username = noPomeloNames
+ ? user.globalName || user.username
+ : getUniqueUsername(user);
+
+ return (
+ <>
+
+ {!compact &&
+ {username}
+ }
+
+ {!showStatusIndicators && }
+ >
+ );
+ } else { // Group DM
+ return (
+ <>
+
+ {!compact && {channel?.name || i18n.Messages.GROUP_DM} }
+
+
+ >
+ );
+ }
+ }
+
+ if (guildId === "@me" || guildId === undefined)
+ return (
+ <>
+
+ {!compact && {i18n.Messages.FRIENDS} }
+ >
+ );
+
+ return (
+ <>
+
+ {!compact && {i18n.Messages.UNKNOWN_CHANNEL} }
+ >
+ );
+}
+
+export default function ChannelTab(props: ChannelTabsProps & { index: number; }) {
+ const { channelId, guildId, id, index, compact } = props;
+ const guild = GuildStore.getGuild(guildId);
+ const channel = ChannelStore.getChannel(channelId);
+
+ const ref = useRef(null);
+ const [, drag] = useDrag(() => ({
+ type: "vc_ChannelTab",
+ item: () => {
+ return { id, index };
+ },
+ collect: monitor => ({
+ isDragging: !!monitor.isDragging()
+ }),
+ }));
+ const [, drop] = useDrop(() => ({
+ accept: "vc_ChannelTab",
+ hover: (item, monitor) => {
+ if (!ref.current) return;
+
+ const dragIndex = item.index;
+ const hoverIndex = index;
+ if (dragIndex === hoverIndex) return;
+
+ const hoverBoundingRect = ref.current?.getBoundingClientRect();
+ const hoverMiddleX =
+ (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientX = clientOffset.x - hoverBoundingRect.left;
+ if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
+ || dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
+ return;
+ }
+
+ moveDraggedTabs(dragIndex, hoverIndex);
+ item.index = hoverIndex;
+ },
+ }), []);
+ drag(drop(ref));
+
+ return {
+ if (e.button === 1 /* middle click */)
+ closeTab(id);
+ }}
+ onContextMenu={e => ContextMenuApi.openContextMenu(e, () =>
)}
+ >
+
moveToTab(id)}
+ >
+
+
+
+
+
+ {openedTabs.length > 1 && (compact ? isTabSelected(id) : true) &&
closeTab(id)}
+ >
+
+ }
+
;
+}
+
+export const PreviewTab = (props: ChannelTabsProps) => {
+ const guild = GuildStore.getGuild(props.guildId);
+ const channel = ChannelStore.getChannel(props.channelId);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx
new file mode 100644
index 00000000..91c14721
--- /dev/null
+++ b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx
@@ -0,0 +1,130 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { classNameFactory } from "@api/Styles";
+import { useForceUpdater } from "@utils/react";
+import { findByPropsLazy } from "@webpack";
+import { Button, ContextMenuApi, Flex, FluxDispatcher, Forms, useCallback, useEffect, useRef, UserStore, useState } from "@webpack/common";
+
+import { BasicChannelTabsProps, ChannelTabsProps, createTab, handleChannelSwitch, openedTabs, openStartupTabs, saveTabs, settings, setUpdaterFunction, useGhostTabs } from "../util";
+import BookmarkContainer from "./BookmarkContainer";
+import ChannelTab, { PreviewTab } from "./ChannelTab";
+import { BasicContextMenu } from "./ContextMenus";
+
+type TabSet = Record;
+
+const { PlusSmallIcon } = findByPropsLazy("PlusSmallIcon");
+const cl = classNameFactory("vc-channeltabs-");
+
+export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
+ const [userId, setUserId] = useState("");
+ const { showBookmarkBar, widerTabsAndBookmarks } = settings.use(["showBookmarkBar", "widerTabsAndBookmarks"]);
+ const GhostTabs = useGhostTabs();
+
+ const _update = useForceUpdater();
+ const update = useCallback((save = true) => {
+ _update();
+ if (save) saveTabs(userId);
+ }, [userId]);
+
+ useEffect(() => {
+ // for some reason, the app directory is it's own page instead of a layer, so when it's opened
+ // everything behind it is destroyed, including our container. this workaround is required
+ // to properly add the container back without reinitializing everything
+ if ((Vencord.Plugins.plugins.ChannelTabs as any).appDirectoryClosed) {
+ setUserId(UserStore.getCurrentUser().id);
+ update(false);
+ }
+ }, []);
+
+ const ref = useRef(null);
+
+ useEffect(() => {
+ setUpdaterFunction(update);
+ const onLogin = () => {
+ const { id } = UserStore.getCurrentUser();
+ if (id === userId && openedTabs.length) return;
+ setUserId(id);
+
+ openStartupTabs({ ...props, userId: id }, setUserId);
+ };
+
+ FluxDispatcher.subscribe("CONNECTION_OPEN_SUPPLEMENTAL", onLogin);
+ return () => {
+ FluxDispatcher.unsubscribe("CONNECTION_OPEN_SUPPLEMENTAL", onLogin);
+ };
+ }, []);
+
+ useEffect(() => {
+ (Vencord.Plugins.plugins.ChannelTabs as any).containerHeight = ref.current?.clientHeight;
+ }, [userId, showBookmarkBar]);
+
+ useEffect(() => {
+ _update();
+ }, [widerTabsAndBookmarks]);
+
+ if (!userId) return null;
+ handleChannelSwitch(props);
+ saveTabs(userId);
+
+ return (
+ ContextMenuApi.openContextMenu(e, () =>
)}
+ >
+
+ {openedTabs.map((tab, i) =>
+
+ )}
+
+
createTab(props, true)}
+ className={cl("button", "new-button", "hoverable")}
+ >
+
+
+
+ {GhostTabs}
+
+ {showBookmarkBar && <>
+
+
+ >}
+
+
+ );
+}
+
+export function ChannelTabsPreview(p) {
+ const id = UserStore.getCurrentUser()?.id;
+ if (!id) return there's no logged in account????? ;
+
+ const { setValue }: { setValue: (v: TabSet) => void; } = p;
+ const { tabSet }: { tabSet: TabSet; } = settings.use(["tabSet"]);
+
+ const placeholder = [{ guildId: "@me", channelId: undefined as any }];
+ const [currentTabs, setCurrentTabs] = useState(tabSet?.[id] ?? placeholder);
+
+ return (
+ <>
+ Startup tabs
+
+ {currentTabs.map(t => <>
+
+ >)}
+
+
+ {
+ setCurrentTabs([...openedTabs]);
+ setValue({ ...tabSet, [id]: [...openedTabs] });
+ }}
+ >Set to currently open tabs
+
+ >
+ );
+}
diff --git a/src/equicordplugins/channelTabs/components/ContextMenus.tsx b/src/equicordplugins/channelTabs/components/ContextMenus.tsx
new file mode 100644
index 00000000..a2642e72
--- /dev/null
+++ b/src/equicordplugins/channelTabs/components/ContextMenus.tsx
@@ -0,0 +1,341 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { Margins } from "@utils/margins";
+import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
+import { Button, ChannelStore, FluxDispatcher, Forms, i18n, Menu, ReadStateStore, ReadStateUtils, Select, Text, TextInput, useState } from "@webpack/common";
+
+import { bookmarkFolderColors, bookmarkPlaceholderName, closeOtherTabs, closeTab, closeTabsToTheRight, createTab, hasClosedTabs, isBookmarkFolder, openedTabs, reopenClosedTab, settings, toggleCompactTab } from "../util";
+import { Bookmark, BookmarkFolder, Bookmarks, ChannelTabsProps, UseBookmarkMethods } from "../util/types";
+
+export function BasicContextMenu() {
+ const { showBookmarkBar } = settings.use(["showBookmarkBar"]);
+
+ return (
+ FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label="ChannelTabs Context Menu"
+ >
+
+ {
+ settings.store.showBookmarkBar = !settings.store.showBookmarkBar;
+ }}
+ />
+
+
+ );
+}
+
+export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
+ modalProps: ModalProps,
+ modalKey: string,
+ bookmark: Bookmark | BookmarkFolder,
+ onSave: (name: string, color: string) => void;
+}) {
+ const [name, setName] = useState(bookmark.name);
+ const [color, setColor] = useState(isBookmarkFolder(bookmark) ? bookmark.iconColor : undefined);
+ const placeholder = bookmarkPlaceholderName(bookmark);
+
+ return (
+
+
+ Edit Bookmark
+
+
+ Bookmark Name
+ setName(v)}
+ />
+ {isBookmarkFolder(bookmark) && <>
+ Folder Color
+ ({
+ label: name,
+ value,
+ default: bookmark.iconColor === value
+ }))
+ }
+ isSelected={v => color === v}
+ select={setColor}
+ serialize={String}
+ />
+ >}
+
+
+ onSave(name || placeholder, color!)}
+ >Save
+ closeModal(modalKey)}
+ >Cancel
+
+
+ );
+}
+
+function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
+ modalProps: any,
+ modalKey: string,
+ bookmarks: Bookmarks,
+ onSave: (folderIndex: number) => void;
+}) {
+ const [folderIndex, setIndex] = useState(-1);
+
+ return (
+
+
+ Add Bookmark to Folder
+
+
+ Select a folder
+ isBookmarkFolder(bookmark))
+ .map(([index, bookmark]) => ({
+ label: bookmark.name,
+ value: parseInt(index, 10)
+ })),
+ {
+ label: "Create one",
+ value: -1,
+ default: true
+ }]}
+ isSelected={v => v === folderIndex}
+ select={setIndex}
+ serialize={String}
+ />
+
+
+ onSave(folderIndex)}
+ >Save
+ closeModal(modalKey)}
+ >Cancel
+
+
+ );
+}
+
+function DeleteFolderConfirmationModal({ modalProps, modalKey, onConfirm }) {
+ return (
+
+
+ Are you sure?
+
+
+
+ Deleting a bookmark folder will also delete all bookmarks within it.
+
+
+
+
+ Delete
+
+ closeModal(modalKey)}
+ >
+ Cancel
+
+
+
+ );
+}
+
+export function BookmarkContextMenu({ bookmarks, index, methods }: { bookmarks: Bookmarks, index: number, methods: UseBookmarkMethods; }) {
+ const { showBookmarkBar, bookmarkNotificationDot } = settings.use(["showBookmarkBar", "bookmarkNotificationDot"]);
+ const bookmark = bookmarks[index];
+ const isFolder = isBookmarkFolder(bookmark);
+
+ return (
+ FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label="ChannelTabs Bookmark Context Menu"
+ >
+
+ {bookmarkNotificationDot && !isFolder &&
+ ReadStateUtils.ackChannel(ChannelStore.getChannel(bookmark.channelId))}
+ />
+ }
+ {isFolder
+ ? bookmark.bookmarks.forEach(b => createTab(b))}
+ />
+ : < Menu.MenuItem
+ id="open-in-tab"
+ label={"Open in New Tab"}
+ action={() => createTab(bookmark)}
+ />
+ }
+
+
+ {
+ const key = openModal(modalProps =>
+ {
+ methods.editBookmark(index, { name });
+ if (color) methods.editBookmark(index, { iconColor: color });
+ closeModal(key);
+ }
+ }
+ />
+ );
+ }}
+ />
+ {
+ if (isFolder) {
+ const key = openModal(modalProps =>
+ {
+ methods.deleteBookmark(index);
+ closeModal(key);
+ }}
+ />);
+ }
+ else methods.deleteBookmark(index);
+ }}
+ />
+ {
+ const key = openModal(modalProps =>
+ {
+ if (index === -1) {
+ const folderIndex = methods.addFolder();
+ methods.addBookmark(bookmark as Bookmark, folderIndex);
+ }
+ else methods.addBookmark(bookmark as Bookmark, index);
+ methods.deleteBookmark(bookmarks.indexOf(bookmark));
+ closeModal(key);
+ }
+ }
+ />
+ );
+ }}
+ />
+
+
+ {
+ settings.store.showBookmarkBar = !settings.store.showBookmarkBar;
+ }}
+ />
+
+
+ );
+}
+
+export function TabContextMenu({ tab }: { tab: ChannelTabsProps; }) {
+ const channel = ChannelStore.getChannel(tab.channelId);
+ const [compact, setCompact] = useState(tab.compact);
+ const { showBookmarkBar } = settings.use(["showBookmarkBar"]);
+
+ return (
+ FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label="ChannelTabs Tab Context Menu"
+ >
+
+ {channel &&
+ ReadStateUtils.ackChannel(channel)}
+ />
+ }
+ {
+ setCompact(compact => !compact);
+ toggleCompactTab(tab.id);
+ }}
+ />
+
+ {openedTabs.length !== 1 &&
+ closeTab(tab.id)}
+ />
+ closeOtherTabs(tab.id)}
+ />
+ closeTabsToTheRight(tab.id)}
+ />
+ reopenClosedTab()}
+ />
+ }
+
+ {
+ settings.store.showBookmarkBar = !settings.store.showBookmarkBar;
+ }}
+ />
+
+
+ );
+}
diff --git a/src/equicordplugins/channelTabs/index.tsx b/src/equicordplugins/channelTabs/index.tsx
new file mode 100644
index 00000000..c87e90e9
--- /dev/null
+++ b/src/equicordplugins/channelTabs/index.tsx
@@ -0,0 +1,135 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import "./style.css";
+
+import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
+import ErrorBoundary from "@components/ErrorBoundary";
+import { Devs, EquicordDevs } from "@utils/constants";
+import definePlugin from "@utils/types";
+import { ChannelStore, Menu } from "@webpack/common";
+import { Channel, Message } from "discord-types/general";
+
+import ChannelsTabsContainer from "./components/ChannelTabsContainer";
+import { BasicChannelTabsProps, createTab, settings } from "./util";
+import * as ChannelTabsUtils from "./util";
+
+const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channel: Channel, messageId?: string; }) =>
+ () => {
+ const { channel, messageId } = props;
+ const group = findGroupChildrenByChildId("channel-copy-link", children);
+ group?.push(
+ createTab({
+ guildId: channel.guild_id,
+ channelId: channel.id
+ }, true, messageId)}
+ />
+ );
+ };
+
+export default definePlugin({
+ name: "ChannelTabs",
+ description: "Group your commonly visited channels in tabs, like a browser",
+ authors: [Devs.TheSun, Devs.TheKodeToad, EquicordDevs.keifufu, Devs.Nickyux],
+ dependencies: ["ContextMenuAPI"],
+ patches: [
+ // add the channel tab container at the top
+ {
+ find: ".COLLECTIBLES_SHOP_FULLSCREEN))",
+ replacement: {
+ match: /(\?void 0:(\i)\.channelId.{0,120})\i\.Fragment,{/,
+ replace: "$1$self.render,{currentChannel:$2,"
+ }
+ },
+ // ctrl click to open in new tab in inbox unread
+ {
+ find: ".messageContainer,onKeyDown",
+ replacement: {
+ match: /.jumpButton,onJump:\i=>(\i)\(\i,(\i)\.id\)/,
+ replace: ".jumpButton,onJump: event => { if (event.ctrlKey) $self.open($2); else $1(event, $2.id) }"
+ }
+ },
+ // ctrl click to open in new tab in inbox mentions
+ {
+ find: ".deleteRecentMention(",
+ replacement: {
+ match: /(?<=.jumpMessageButton,onJump:)(\i)(?=.{0,20}message:(\i))/,
+ replace: "event => { if (event.ctrlKey) $self.open($2); else $1(event) }"
+ }
+ },
+ // ctrl click to open in new tab in search results
+ {
+ find: "(this,\"handleMessageClick\"",
+ replacement: {
+ match: /(?<=(\i)\.isSearchHit\));(?=null!=(\i))/,
+ replace: ";if ($1.ctrlKey) return $self.open($2);"
+ }
+ },
+ // prevent issues with the pins/inbox popouts being too tall
+ {
+ find: ".messagesPopoutWrap",
+ replacement: {
+ match: /\i&&\((\i).maxHeight-=\d{1,3}\)/,
+ replace: "$&;$1.maxHeight-=$self.containerHeight"
+ }
+ },
+ // workaround for app directory killing our component, see comments in ChannelTabContainer.tsx
+ {
+ find: ".ApplicationDirectoryEntrypointNames.EXTERNAL",
+ replacement: {
+ match: /(\.guildSettingsSection\).{0,30})},\[/,
+ replace: "$1;$self.onAppDirectoryClose()},["
+ }
+ }
+ ],
+
+ settings,
+
+ start() {
+ addContextMenuPatch("channel-mention-context", contextMenuPatch);
+ addContextMenuPatch("channel-context", contextMenuPatch);
+ },
+
+ stop() {
+ removeContextMenuPatch("channel-mention-context", contextMenuPatch);
+ removeContextMenuPatch("channel-context", contextMenuPatch);
+ },
+
+ containerHeight: 0,
+
+ render({ currentChannel, children }: {
+ currentChannel: BasicChannelTabsProps,
+ children: JSX.Element;
+ }) {
+ return (
+ <>
+
+
+
+ {children}
+ >
+ );
+ },
+
+ open(message: Message) {
+ const tab = {
+ channelId: message.channel_id,
+ guildId: ChannelStore.getChannel(message.channel_id)?.guild_id,
+ compact: false
+ };
+ createTab(tab, false, message.id);
+ },
+
+ onAppDirectoryClose() {
+ this.appDirectoryClosed = true;
+ setTimeout(() => this.appDirectoryClosed = false, 0);
+ },
+
+ util: ChannelTabsUtils,
+});
diff --git a/src/equicordplugins/channelTabs/style.css b/src/equicordplugins/channelTabs/style.css
new file mode 100644
index 00000000..2d4f187a
--- /dev/null
+++ b/src/equicordplugins/channelTabs/style.css
@@ -0,0 +1,262 @@
+.vc-channeltabs-icon {
+ height: 1.5rem;
+ width: 1.5rem;
+ border-radius: 50%;
+}
+
+.vc-channeltabs-guild-acronym-icon {
+ height: 1.25rem;
+ width: 1.5rem;
+ min-width: 1.5rem;
+ border-radius: 50%;
+ padding-top: 0.25rem;
+ background-color: var(--background-primary);
+ overflow: hidden;
+}
+
+/* user avatars */
+.vc-channeltabs-tab [class^="wrapper_"] {
+ min-width: 1.5rem;
+}
+
+.vc-channeltabs-name-text {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin-left: 0.125rem;
+}
+
+.vc-channeltabs-tab-inner {
+ border-radius: 0.5rem;
+ padding: 0.25rem 0 0.25rem 0.25rem;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.vc-channeltabs-tab-container,
+.vc-channeltabs-bookmark-container {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+}
+
+.platform-osx .vc-channeltabs-tab-container {
+ padding-left: 66px;
+}
+
+.vc-channeltabs-container {
+ --channeltabs-red: #f23f42;
+ --channeltabs-blue: #0052b6;
+ --channeltabs-yellow: #f0b132;
+ --channeltabs-green: #24934f;
+ --channeltabs-black: #000;
+ --channeltabs-white: #fff;
+ --channeltabs-orange: #e67e22;
+ --channeltabs-pink: #ff73fa;
+
+ padding: 0.35rem;
+ display: flex;
+ flex-direction: column;
+}
+
+.vc-channeltabs-separator {
+ width: 98vw;
+ height: 0.075rem;
+ background: var(--bg-overlay-5);
+ background-color: var(--background-secondary);
+ margin: 0.25rem 1vw;
+}
+
+.vc-channeltabs-scroller {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ overflow-x: auto;
+ white-space: nowrap;
+}
+
+.vc-channeltabs-scroller::-webkit-scrollbar {
+ display: none;
+}
+
+.vc-channeltabs-bookmarks {
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.vc-channeltabs-bookmark-placeholder-text {
+ color: var(--text-muted);
+ padding: 0.25rem 0 0 0.25rem;
+}
+
+.vc-channeltabs-bookmark-star {
+ fill: none;
+ stroke: var(--text-muted);
+ stroke-width: 0.075rem;
+}
+
+.vc-channeltabs-bookmark-star-checked {
+ fill: var(--yellow-300);
+}
+
+.vc-channeltabs-bookmark {
+ display: flex;
+ flex-direction: row;
+ max-width: 8rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+}
+
+.vc-channeltabs-bookmark.vc-channeltabs-wider {
+ max-width: 12rem;
+}
+
+.vc-channeltabs-bookmark .vc-channeltabs-name-text {
+ margin-left: 0.25rem;
+ margin-right: 0.25rem;
+}
+
+.vc-channeltabs-bookmark-icon {
+ height: 1rem;
+ width: 1rem;
+ border-radius: 50%;
+ background-color: var(--background-primary);
+ text-align: center;
+}
+
+.vc-channeltabs-bookmark-notification {
+ height: 1rem;
+ width: 1rem;
+}
+
+.vc-channeltabs-channel-info {
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.vc-channeltabs-tab {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ border-radius: 0.25rem;
+ width: 12rem;
+ min-width: 0;
+ margin-right: 0.25rem;
+}
+
+.vc-channeltabs-tab.vc-channeltabs-wider:not(.vc-channeltabs-tab-compact) {
+ width: 16rem;
+}
+
+.vc-channeltabs-tab-compact {
+ width: unset;
+ min-width: unset;
+}
+
+.vc-channeltabs-tab-selected {
+ background: var(--bg-overlay-selected);
+ background-color: var(--background-modifier-selected);
+}
+
+/* channel type container */
+.vc-channeltabs-container [class^="iconContainer_"] {
+ margin: 0 -0.25rem 0 0;
+ pointer-events: none;
+ height: 1rem;
+ transform: scale(0.8);
+}
+
+.vc-channeltabs-ghost-tab {
+ visibility: hidden;
+}
+
+.vc-channeltabs-hoverable:hover,
+.vc-channeltabs-tab:hover:not(.vc-channeltabs-tab-selected) {
+ background: var(--bg-overlay-hover);
+ background-color: var(--background-modifier-hover);
+}
+
+.vc-channeltabs-button {
+ background-color: transparent;
+ color: var(--text-normal);
+}
+
+.vc-channeltabs-new-button {
+ padding: 0.375rem 0.5rem;
+ border-radius: 0.25rem;
+}
+
+.vc-channeltabs-close-button {
+ height: 1rem;
+ padding: 0;
+ margin-right: 0.5rem;
+}
+
+.vc-channeltabs-close-button-compact {
+ position: absolute;
+ opacity: 0;
+ width: 2rem;
+ height: 2rem;
+ margin-left: 0.35rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+}
+
+.vc-channeltabs-close-button-compact:hover {
+ background-color: black;
+ opacity: 0.5;
+}
+
+.vc-channeltabs-emoji-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 1.25rem;
+ width: 1.25rem;
+ min-width: 1.25rem;
+ border-radius: 50%;
+}
+
+.vc-channeltabs-emoji {
+ height: 0.75rem;
+ width: 0.75rem;
+}
+
+.vc-channeltabs-favorites-star {
+ margin: 0 -0.25rem -1rem -0.75rem;
+ height: 0.75rem;
+ width: 0.75rem;
+}
+
+.vc-channeltabs-preview-tab {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ width: 12rem;
+ padding: 0.125rem 0;
+ min-width: 0;
+ border-radius: 0.25rem;
+ border: 1px solid var(--background-modifier-accent);
+}
+
+.vc-channeltabs-preview-tab-compact {
+ width: unset;
+ min-width: unset;
+}
+
+.vc-channeltabs-preview-tab> :is(.vc-channeltabs-emoji-container,
+.vc-channeltabs-typing-indicator,
+[data-has-mention]) {
+ display: none;
+}
+
+.vc-channeltabs-preview-text {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
diff --git a/src/equicordplugins/channelTabs/util/bookmarks.ts b/src/equicordplugins/channelTabs/util/bookmarks.ts
new file mode 100644
index 00000000..98854c88
--- /dev/null
+++ b/src/equicordplugins/channelTabs/util/bookmarks.ts
@@ -0,0 +1,117 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { DataStore } from "@api/index";
+import { useAwaiter } from "@utils/react";
+import { ChannelStore, useCallback, UserStore, useState } from "@webpack/common";
+
+import { bookmarkFolderColors, logger } from "./constants";
+import { Bookmark, BookmarkFolder, Bookmarks, UseBookmark, UseBookmarkMethods } from "./types";
+
+export function isBookmarkFolder(bookmark: Bookmark | BookmarkFolder): bookmark is BookmarkFolder {
+ return "bookmarks" in bookmark;
+}
+
+export function bookmarkPlaceholderName(bookmark: Omit) {
+ if (isBookmarkFolder(bookmark as Bookmark | BookmarkFolder)) return "Folder";
+ const channel = ChannelStore.getChannel((bookmark as Bookmark).channelId);
+
+ if (!channel) return "Bookmark";
+ if (channel.name) return `#${channel.name}`;
+ if (channel.recipients) return UserStore.getUser(channel.recipients?.[0])?.username
+ ?? "Unknown User";
+ return "Bookmark";
+}
+
+export function useBookmarks(userId: string): UseBookmark {
+ const [bookmarks, _setBookmarks] = useState<{ [k: string]: Bookmarks; }>({});
+ const setBookmarks = useCallback((bookmarks: { [k: string]: Bookmarks; }) => {
+ _setBookmarks(bookmarks);
+ DataStore.update("ChannelTabs_bookmarks", old => ({
+ ...old,
+ [userId]: bookmarks[userId]
+ }));
+ }, [userId]);
+
+ useAwaiter(() => DataStore.get("ChannelTabs_bookmarks"), {
+ fallbackValue: undefined,
+ onSuccess(bookmarks: { [k: string]: Bookmarks; }) {
+ if (!bookmarks) {
+ bookmarks = { [userId]: [] };
+ DataStore.set("ChannelTabs_bookmarks", { [userId]: [] });
+ }
+ if (!bookmarks[userId]) bookmarks[userId] = [];
+
+ setBookmarks(bookmarks);
+ },
+ });
+
+ const methods = {
+ addBookmark: (bookmark, folderIndex) => {
+ if (!bookmarks) return;
+
+ if (typeof folderIndex === "number" && !(isBookmarkFolder(bookmarks[userId][folderIndex])))
+ return logger.error("Attempted to add bookmark to non-folder " + folderIndex, bookmarks);
+
+ const name = bookmark.name ?? bookmarkPlaceholderName(bookmark);
+ if (typeof folderIndex === "number")
+ (bookmarks[userId][folderIndex] as BookmarkFolder).bookmarks.push({ ...bookmark, name });
+ else bookmarks[userId].push({ ...bookmark, name });
+
+ setBookmarks({
+ ...bookmarks
+ });
+ },
+ addFolder() {
+ if (!bookmarks) return;
+ const length = bookmarks[userId].push({
+ name: "Folder",
+ iconColor: bookmarkFolderColors.Black,
+ bookmarks: []
+ });
+
+ setBookmarks({
+ ...bookmarks
+ });
+ return length - 1;
+ },
+ editBookmark(index, newBookmark) {
+ if (!bookmarks) return;
+ Object.entries(newBookmark).forEach(([k, v]) => {
+ bookmarks[userId][index][k] = v;
+ });
+ setBookmarks({
+ ...bookmarks
+ });
+ },
+ deleteBookmark(index, folderIndex) {
+ if (!bookmarks) return;
+ if (index < 0 || index > (bookmarks[userId].length - 1))
+ return logger.error("Attempted to delete bookmark at index " + index, bookmarks);
+
+ if (typeof folderIndex === "number")
+ (bookmarks[userId][folderIndex] as BookmarkFolder).bookmarks.splice(index, 1);
+ else bookmarks[userId].splice(index, 1);
+
+ setBookmarks({
+ ...bookmarks
+ });
+ },
+ moveDraggedBookmarks(index1, index2) {
+ if (index1 < 0 || index2 > bookmarks[userId].length)
+ return logger.error(`Out of bounds drag (swap between indexes ${index1} and ${index2})`, bookmarks);
+
+ const firstItem = bookmarks[userId].splice(index1, 1)[0];
+ bookmarks[userId].splice(index2, 0, firstItem);
+
+ setBookmarks({
+ ...bookmarks
+ });
+ }
+ } as UseBookmarkMethods;
+
+ return [bookmarks[userId], methods];
+}
diff --git a/src/equicordplugins/channelTabs/util/constants.ts b/src/equicordplugins/channelTabs/util/constants.ts
new file mode 100644
index 00000000..45491dc8
--- /dev/null
+++ b/src/equicordplugins/channelTabs/util/constants.ts
@@ -0,0 +1,77 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { definePluginSettings } from "@api/Settings";
+import { Logger } from "@utils/Logger";
+import { OptionType } from "@utils/types";
+import { findByPropsLazy } from "@webpack";
+
+import { ChannelTabsPreview } from "../components/ChannelTabsContainer";
+
+export const logger = new Logger("ChannelTabs");
+
+export const bookmarkFolderColors = {
+ Red: "var(--channeltabs-red)",
+ Blue: "var(--channeltabs-blue)",
+ Yellow: "var(--channeltabs-yellow)",
+ Green: "var(--channeltabs-green)",
+ Black: "var(--channeltabs-black)",
+ White: "var(--channeltabs-white)",
+ Orange: "var(--channeltabs-orange)",
+ Pink: "var(--channeltabs-pink)"
+} as const;
+
+export const settings = definePluginSettings({
+ onStartup: {
+ type: OptionType.SELECT,
+ description: "On startup",
+ options: [{
+ label: "Do nothing (open on the friends tab)",
+ value: "nothing",
+ default: true
+ }, {
+ label: "Remember tabs from last session",
+ value: "remember"
+ }, {
+ label: "Open on a specific set of tabs",
+ value: "preset"
+ }],
+ },
+ tabSet: {
+ component: ChannelTabsPreview,
+ description: "Select which tabs to open at startup",
+ type: OptionType.COMPONENT,
+ default: {}
+ },
+ noPomeloNames: {
+ description: "Use display names instead of usernames for DM's",
+ type: OptionType.BOOLEAN,
+ default: false
+ },
+ showStatusIndicators: {
+ description: "Show status indicators for DM's",
+ type: OptionType.BOOLEAN,
+ default: true
+ },
+ showBookmarkBar: {
+ description: "",
+ type: OptionType.BOOLEAN,
+ default: true
+ },
+ bookmarkNotificationDot: {
+ description: "Show notification dot for bookmarks",
+ type: OptionType.BOOLEAN,
+ default: true
+ },
+ widerTabsAndBookmarks: {
+ description: "Extend the length of tabs and bookmarks for larger monitors",
+ type: OptionType.BOOLEAN,
+ default: false,
+ restartNeeded: false
+ }
+});
+
+export const { CircleQuestionIcon } = findByPropsLazy("CircleQuestionIcon");
diff --git a/src/equicordplugins/channelTabs/util/index.ts b/src/equicordplugins/channelTabs/util/index.ts
new file mode 100644
index 00000000..3cfe7f44
--- /dev/null
+++ b/src/equicordplugins/channelTabs/util/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export * from "./bookmarks";
+export * from "./constants";
+export * from "./tabs";
+export * from "./types";
diff --git a/src/equicordplugins/channelTabs/util/tabs.tsx b/src/equicordplugins/channelTabs/util/tabs.tsx
new file mode 100644
index 00000000..b5be0b3f
--- /dev/null
+++ b/src/equicordplugins/channelTabs/util/tabs.tsx
@@ -0,0 +1,260 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { DataStore } from "@api/index";
+import { classNameFactory } from "@api/Styles";
+import { NavigationRouter, SelectedChannelStore, SelectedGuildStore, showToast, Toasts, useState } from "@webpack/common";
+
+import { logger, settings } from "./constants";
+import { BasicChannelTabsProps, ChannelTabsProps, PersistedTabs } from "./types";
+
+const cl = classNameFactory("vc-channeltabs-");
+
+function replaceArray(array: T[], ...values: T[]) {
+ const len = array.length;
+ for (let i = 0; i < len; i++) array.pop();
+ array.push(...values);
+}
+
+let highestIdIndex = 0;
+const genId = () => highestIdIndex++;
+
+const openTabs: ChannelTabsProps[] = [];
+const closedTabs: ChannelTabsProps[] = [];
+let currentlyOpenTab: number;
+const openTabHistory: number[] = [];
+let persistedTabs: Promise;
+
+// horror
+const _ = {
+ get openedTabs() {
+ return openTabs;
+ }
+};
+export const { openedTabs } = _;
+
+let update = (save = true) => {
+ logger.warn("Update function not set");
+};
+let bumpGhostTabCount = () => {
+ logger.warn("Set ghost tab function not set");
+};
+let clearGhostTabs = () => {
+ logger.warn("Clear ghost tab function not set");
+};
+
+export function createTab(props: BasicChannelTabsProps | ChannelTabsProps, switchToTab?: boolean, messageId?: string, save = true) {
+ const id = genId();
+ openTabs.push({ ...props, id, messageId, compact: "compact" in props ? props.compact : false });
+ if (switchToTab) moveToTab(id);
+ clearGhostTabs();
+ update(save);
+}
+
+export function closeTab(id: number) {
+ if (openTabs.length <= 1) return;
+ const i = openTabs.findIndex(v => v.id === id);
+ if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
+
+ const closed = openTabs.splice(i, 1);
+ closedTabs.push(...closed);
+
+ if (id === currentlyOpenTab) {
+ if (openTabHistory.length) {
+ openTabHistory.pop();
+ let newTab: ChannelTabsProps | undefined = undefined;
+ while (!newTab) {
+ const maybeNewTabId = openTabHistory.at(-1);
+ openTabHistory.pop();
+ if (!maybeNewTabId) {
+ moveToTab(openTabs[Math.max(i - 1, 0)].id);
+ }
+ const maybeNewTab = openTabs.find(t => t.id === maybeNewTabId);
+ if (maybeNewTab) newTab = maybeNewTab;
+ }
+
+ moveToTab(newTab.id);
+ openTabHistory.pop();
+ }
+ else moveToTab(openTabs[Math.max(i - 1, 0)].id);
+ }
+ if (i !== openTabs.length) bumpGhostTabCount();
+ else clearGhostTabs();
+ update();
+}
+
+export function closeOtherTabs(id: number) {
+ const tab = openTabs.find(v => v.id === id);
+ if (tab === undefined) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
+
+ const removedTabs = openTabs.filter(v => v.id !== id);
+ closedTabs.push(...removedTabs.reverse());
+ const lastTab = openTabs.find(v => v.id === currentlyOpenTab)!;
+ replaceArray(openTabs, tab);
+ setOpenTab(id);
+ replaceArray(openTabHistory, id);
+
+ if (tab.channelId !== lastTab.channelId) moveToTab(id);
+ else update();
+}
+
+export function closeTabsToTheRight(id: number) {
+ const i = openTabs.findIndex(v => v.id === id);
+ if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
+
+ const tabsToTheRight = openTabs.filter((_, ind) => ind > i);
+ closedTabs.push(...tabsToTheRight.reverse());
+ const tabsToTheLeft = openTabs.filter((_, ind) => ind <= i);
+ replaceArray(openTabs, ...tabsToTheLeft);
+
+ if (!tabsToTheLeft.some(v => v.id === currentlyOpenTab)) moveToTab(openTabs.at(-1)!.id);
+ else update();
+}
+
+export function handleChannelSwitch(ch: BasicChannelTabsProps) {
+ const tab = openTabs.find(c => c.id === currentlyOpenTab);
+ if (tab === undefined) return logger.error("Couldn't find the currently open channel " + currentlyOpenTab, openTabs);
+
+ if (tab.channelId !== ch.channelId) openTabs[openTabs.indexOf(tab)] = { id: tab.id, compact: tab.compact, ...ch };
+}
+
+export function hasClosedTabs() {
+ return !!closedTabs.length;
+}
+
+export function isTabSelected(id: number) {
+ return id === currentlyOpenTab;
+}
+
+export function moveDraggedTabs(index1: number, index2: number) {
+ if (index1 < 0 || index2 > openTabs.length)
+ return logger.error(`Out of bounds drag (swap between indexes ${index1} and ${index2})`, openTabs);
+
+ const firstItem = openTabs.splice(index1, 1)[0];
+ openTabs.splice(index2, 0, firstItem);
+ update();
+}
+
+export function moveToTab(id: number) {
+ const tab = openTabs.find(v => v.id === id);
+ if (tab === undefined) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
+
+ setOpenTab(id);
+ if (tab.messageId) {
+ 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())
+ NavigationRouter.transitionToGuild(tab.guildId, tab.channelId);
+ else update();
+}
+
+export function openStartupTabs(props: BasicChannelTabsProps & { userId: string; }, setUserId: (id: string) => void) {
+ const { userId } = props;
+ persistedTabs ??= DataStore.get("ChannelTabs_openChannels_v2");
+ replaceArray(openTabs);
+ replaceArray(openTabHistory);
+ highestIdIndex = 0;
+
+ if (settings.store.onStartup !== "nothing" && Vencord.Plugins.isPluginEnabled("KeepCurrentChannel"))
+ return showToast("Not restoring tabs as KeepCurrentChannel is enabled", Toasts.Type.FAILURE);
+
+ switch (settings.store.onStartup) {
+ case "remember": {
+ persistedTabs.then(tabs => {
+ const t = tabs?.[userId];
+ if (!t) {
+ createTab({ channelId: props.channelId, guildId: props.guildId }, true);
+ return showToast("Failed to restore tabs", Toasts.Type.FAILURE);
+ }
+ replaceArray(openTabs); // empty the array
+ t.openTabs.forEach(tab => createTab(tab));
+ currentlyOpenTab = openTabs[t.openTabIndex]?.id ?? 0;
+
+ setUserId(userId);
+ moveToTab(currentlyOpenTab);
+ });
+ break;
+ }
+ case "preset": {
+ const tabs = settings.store.tabSet?.[userId];
+ if (!tabs) break;
+ tabs.forEach(t => createTab(t));
+ setOpenTab(0);
+ setUserId(userId);
+ break;
+ }
+ default: {
+ setUserId(userId);
+ }
+ }
+
+ if (!openTabs.length) createTab({ channelId: props.channelId, guildId: props.guildId }, true, undefined, false);
+ for (let i = 0; i < openTabHistory.length; i++) openTabHistory.pop();
+ moveToTab(currentlyOpenTab);
+}
+
+export function reopenClosedTab() {
+ if (!closedTabs.length) return;
+ const tab = closedTabs.pop()!;
+ createTab(tab, true);
+}
+
+export const saveTabs = async (userId: string) => {
+ if (!userId) return;
+
+ DataStore.update("ChannelTabs_openChannels_v2", old => {
+ return {
+ ...(old ?? {}),
+ [userId]: { openTabs, openTabIndex: openTabs.findIndex(t => t.id === currentlyOpenTab) }
+ };
+ });
+};
+
+export function setOpenTab(id: number) {
+ const i = openTabs.findIndex(v => v.id === id);
+ if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
+
+ currentlyOpenTab = id;
+ openTabHistory.push(id);
+}
+
+export function setUpdaterFunction(fn: () => void) {
+ update = fn;
+}
+
+export function switchChannel(ch: BasicChannelTabsProps) {
+ handleChannelSwitch(ch);
+ moveToTab(openTabs.find(t => t.id === currentlyOpenTab)!.id);
+}
+
+export function toggleCompactTab(id: number) {
+ const i = openTabs.findIndex(v => v.id === id);
+ if (i === -1) return logger.error("Couldn't find channel tab with ID " + id, openTabs);
+
+ openTabs[i] = {
+ ...openTabs[i],
+ compact: !openTabs[i].compact
+ };
+ update();
+}
+
+export function useGhostTabs() {
+ let timeout;
+ const [count, setCount] = useState(0);
+ bumpGhostTabCount = () => {
+ setCount(count + 1);
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ setCount(0);
+ }, 3000);
+ };
+ clearGhostTabs = () => {
+ clearTimeout(timeout);
+ setCount(0);
+ };
+ return new Array(count).fill(
);
+}
diff --git a/src/equicordplugins/channelTabs/util/types.ts b/src/equicordplugins/channelTabs/util/types.ts
new file mode 100644
index 00000000..01bc3a88
--- /dev/null
+++ b/src/equicordplugins/channelTabs/util/types.ts
@@ -0,0 +1,46 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export type BasicChannelTabsProps = {
+ guildId: string;
+ channelId: string;
+};
+export interface ChannelTabsProps extends BasicChannelTabsProps {
+ compact: boolean;
+ messageId?: string;
+ id: number;
+}
+export interface PersistedTabs {
+ [userId: string]: {
+ openTabs: ChannelTabsProps[],
+ openTabIndex: number;
+ };
+}
+
+export interface Bookmark {
+ channelId: string;
+ guildId: string;
+ name: string;
+}
+export interface BookmarkFolder {
+ bookmarks: Bookmark[];
+ name: string;
+ iconColor: string;
+}
+export interface BookmarkProps {
+ bookmarks: Bookmarks,
+ index: number,
+ methods: UseBookmarkMethods;
+}
+export type Bookmarks = (Bookmark | BookmarkFolder)[];
+export type UseBookmarkMethods = {
+ addBookmark: (bookmark: Omit & { name?: string; }, folderIndex?: number) => void;
+ addFolder: () => number;
+ deleteBookmark: (index: number, folderIndex?: number) => void;
+ editBookmark: (index: number, bookmark: Partial, modalKey?) => void;
+ moveDraggedBookmarks: (index1: number, index2: number) => void;
+};
+export type UseBookmark = [Bookmarks | undefined, UseBookmarkMethods];
diff --git a/src/equicordplugins/loginWithQR/index.tsx b/src/equicordplugins/loginWithQR/index.tsx
index 9f9cfb3a..3d9f1b41 100644
--- a/src/equicordplugins/loginWithQR/index.tsx
+++ b/src/equicordplugins/loginWithQR/index.tsx
@@ -5,6 +5,7 @@
*/
import { definePluginSettings } from "@api/Settings";
+import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByProps } from "@webpack";
import { Button, Forms, i18n, Menu, TabBar } from "@webpack/common";
@@ -13,7 +14,6 @@ import { ReactElement } from "react";
import { preload, unload } from "./images";
import { cl, QrCodeCameraIcon } from "./ui";
import openQrModal from "./ui/modals/QrModal";
-import { EquicordDevs } from "@utils/constants";
export default definePlugin({
name: "LoginWithQR",
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 7a19fe70..ccd73b20 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -715,6 +715,10 @@ export const EquicordDevs = Object.freeze({
name: "SimplyData",
id: 301494563514613762n
},
+ keifufu: {
+ name: "keifufu",
+ id: 469588398110146590n
+ },
} satisfies Record);
export const SuncordDevs = /* #__PURE__*/ Object.freeze({
diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts
index 8a2807ff..23f9a1c7 100644
--- a/src/webpack/common/components.ts
+++ b/src/webpack/common/components.ts
@@ -48,6 +48,7 @@ export let Paginator: t.Paginator;
export let ScrollerThin: t.ScrollerThin;
export let Clickable: t.Clickable;
export let Avatar: t.Avatar;
+export let Dots: t.Dots;
export let FocusLock: t.FocusLock;
// token lagger real
/** css colour resolver stuff, no clue what exactly this does, just copied usage from Discord */
@@ -82,7 +83,8 @@ waitFor(["FormItem", "Button"], m => {
Clickable,
Avatar,
FocusLock,
- Heading
+ Heading,
+ Dots
} = m);
Forms = m;
});
diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts
index d4e549de..74a97a6c 100644
--- a/src/webpack/common/stores.ts
+++ b/src/webpack/common/stores.ts
@@ -45,6 +45,7 @@ export let UserProfileStore: GenericStore;
export let SelectedChannelStore: Stores.SelectedChannelStore & t.FluxStore;
export let SelectedGuildStore: t.FluxStore & Record;
export let ChannelStore: Stores.ChannelStore & t.FluxStore;
+export let TypingStore: GenericStore;
export let GuildMemberStore: Stores.GuildMemberStore & t.FluxStore;
export let RelationshipStore: Stores.RelationshipStore & t.FluxStore & {
/** Get the date (as a string) that the relationship was created */
@@ -84,3 +85,4 @@ waitForStore("GuildChannelStore", m => GuildChannelStore = m);
waitForStore("MessageStore", m => MessageStore = m);
waitForStore("WindowStore", m => WindowStore = m);
waitForStore("EmojiStore", m => EmojiStore = m);
+waitForStore("TypingStore", m => TypingStore = m);
diff --git a/src/webpack/common/types/components.d.ts b/src/webpack/common/types/components.d.ts
index 1d7647f5..93f1670d 100644
--- a/src/webpack/common/types/components.d.ts
+++ b/src/webpack/common/types/components.d.ts
@@ -484,6 +484,7 @@ export type Avatar = ComponentType;
}>>;
+
+export type Dots = ComponentType>;
diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts
index 2aba23dd..763721c7 100644
--- a/src/webpack/common/utils.ts
+++ b/src/webpack/common/utils.ts
@@ -49,6 +49,10 @@ export const moment: typeof import("moment") = findByPropsLazy("parseTwoDigitYea
export const hljs: typeof import("highlight.js") = findByPropsLazy("highlight", "registerLanguage");
+export const useDrag = findByCodeLazy("useDrag::spec.begin was deprecated");
+// you cant make a better finder i love that they remove display names sm
+export const useDrop = findByCodeLazy(".options);return", ".collect,");
+
export const lodash: typeof import("lodash") = findByPropsLazy("debounce", "cloneDeep");
export const i18n: t.i18n = findLazy(m => m.Messages?.["en-US"]);
@@ -160,6 +164,10 @@ export const InviteActions = findByPropsLazy("resolveInvite");
export const IconUtils: t.IconUtils = findByPropsLazy("getGuildBannerURL", "getUserAvatarURL");
+export const ReadStateUtils = mapMangledModuleLazy('type:"ENABLE_AUTOMATIC_ACK",', {
+ ackChannel: filters.byCode(".getActiveJoinedThreadsForParent(")
+});
+
const openExpressionPickerMatcher = canonicalizeMatch(/setState\({activeView:\i,activeViewType:/);
// TODO: type
export const ExpressionPickerStore: t.ExpressionPickerStore = mapMangledModuleLazy("expression-picker-last-active-view", {