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 => } + +
+ ); +} 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, () => )} + > + + + {openedTabs.length > 1 && (compact ? isTabSelected(id) : true) && } +
; +} + +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) => + + )} + + + + {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 => <> + + )} + + + + + + ); +} 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 + 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} + /> + + + + + + + ); +} + +function DeleteFolderConfirmationModal({ modalProps, modalKey, onConfirm }) { + return ( + + + Are you sure? + + + + Deleting a bookmark folder will also delete all bookmarks within it. + + + + + + + + ); +} + +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", {