ChannelTabs

This commit is contained in:
thororen1234 2024-07-27 23:51:47 -04:00
parent 5a164e7903
commit 0e0377f093
17 changed files with 1983 additions and 2 deletions

View file

@ -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 (
<path
fill={fill}
d="M20 7H12L10.553 5.106C10.214 4.428 9.521 4 8.764 4H3C2.447 4 2 4.447 2 5V19C2 20.104 2.895 21 4 21H20C21.104 21 22 20.104 22 19V9C22 7.896 21.104 7 20 7Z"
/>
);
}
function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
if (isBookmarkFolder(bookmark)) return (
<svg
height={16}
width={16}
viewBox="0 0 24 24"
>
<FolderIcon fill={bookmark.iconColor} />
</svg>
);
const channel = ChannelStore.getChannel(bookmark.channelId);
const guild = GuildStore.getGuild(bookmark.guildId);
if (guild) return guild.icon
? <img
src={`https://${window.GLOBAL_ENV.CDN_HOST}/icons/${guild?.id}/${guild?.icon}.png`}
className={cl("bookmark-icon")}
/>
: <div className={cl("bookmark-icon")}>
<Text variant="text-xxs/semibold" tag="span">{guild.acronym}</Text>
</div>;
if (channel?.recipients?.length) {
if (channel.recipients.length === 1) return (
<Avatar
size="SIZE_16"
src={UserStore.getUser(channel.recipients[0]).getAvatarURL(undefined, 128)}
/>
);
else return (
<img
src={channel.icon
? `https://${window.GLOBAL_ENV.CDN_HOST}/channel-icons/${channel?.id}/${channel?.icon}.png`
: "/assets/c6851bd0b03f1cca5a8c1e720ea6ea17.png" // Default Group Icon
}
className={cl("bookmark-icon")}
/>
);
}
return (
<CircleQuestionIcon height={16} width={16} />
);
}
function BookmarkFolderOpenMenu(props: BookmarkProps) {
const { bookmarks, index, methods } = props;
const bookmark = bookmarks[index] as BookmarkFolder;
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
return (
<Menu.Menu
navId="bookmark-folder-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Bookmark Folder Menu"
>
{bookmark.bookmarks.map((b, i) => <Menu.MenuItem
key={`bookmark-folder-entry-${b.channelId}`}
id={`bookmark-folder-entry-${b.channelId}`}
label={
<div style={{
display: "flex",
alignItems: "center",
gap: "0.25rem"
}}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{b.name}
</span>
{bookmarkNotificationDot && <NotificationDot channelIds={[b.channelId]} />}
</div>
}
icon={() => <BookmarkIcon bookmark={b} />}
showIconFirst={true}
action={() => switchChannel(b)}
children={[
(
bookmarkNotificationDot && <Menu.MenuGroup>
<Menu.MenuItem
key="mark-as-read"
id="mark-as-read"
label={i18n.Messages.MARK_AS_READ}
disabled={!ReadStateStore.hasUnread(b.channelId)}
action={() => ReadStateUtils.ackChannel(ChannelStore.getChannel(b.channelId))}
/>
</Menu.MenuGroup>
),
<Menu.MenuGroup>
<Menu.MenuItem
key="edit-bookmark"
id="edit-bookmark"
label="Edit Bookmark"
action={() => {
const key = openModal(modalProps =>
<EditModal
modalProps={modalProps}
modalKey={key}
bookmark={b}
onSave={name => {
const newBookmarks = [...bookmark.bookmarks];
newBookmarks[i].name = name;
methods.editBookmark(index, { bookmarks: newBookmarks });
closeModal(key);
}}
/>
);
}}
/>
<Menu.MenuItem
key="delete-bookmark"
id="delete-bookmark"
label="Delete Bookmark"
action={() => {
methods.deleteBookmark(i, index);
}}
/>
<Menu.MenuItem
key="remove-bookmark-from-folder"
id="remove-bookmark-from-folder"
label="Remove Bookmark from Folder"
action={() => {
const newBookmarks = [...bookmark.bookmarks];
newBookmarks.splice(i, 1);
methods.addBookmark(b);
methods.editBookmark(index, { bookmarks: newBookmarks });
}}
/>
</Menu.MenuGroup>]}
/>)}
</Menu.Menu>
);
}
function Bookmark(props: BookmarkProps) {
const { bookmarks, index, methods } = props;
const bookmark = bookmarks[index];
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
const ref = useRef<HTMLDivElement>(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 (
<div
className={cl("bookmark", "hoverable", { wider: settings.store.widerTabsAndBookmarks })}
ref={ref}
onClick={e => isBookmarkFolder(bookmark)
? ContextMenuApi.openContextMenu(e, () => <BookmarkFolderOpenMenu {...props} />)
: switchChannel(bookmark)
}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () =>
<BookmarkContextMenu {...props} />
)}
>
<BookmarkIcon bookmark={bookmark} />
<Text variant="text-sm/normal" className={cl("name-text")}>
{bookmark.name}
</Text>
{bookmarkNotificationDot && <NotificationDot channelIds={isBookmarkFolder(bookmark)
? bookmark.bookmarks.map(b => b.channelId)
: [bookmark.channelId]
} />}
</div>
);
}
function HorizontalScroller({ children, className }: React.PropsWithChildren<{ className?: string; }>) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ref.current!.addEventListener("wheel", e => {
e.preventDefault();
ref.current!.scrollLeft += e.deltaY;
});
}, []);
return (
<div className={classes(cl("scroller"), className)} ref={ref}>
{children}
</div>
);
}
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 (
<div className={cl("bookmark-container")}>
<HorizontalScroller className={cl("bookmarks")}>
{!bookmarks && <Text className={cl("bookmark-placeholder-text")} variant="text-xs/normal">
Loading bookmarks...
</Text>}
{bookmarks && !bookmarks.length && <Text className={cl("bookmark-placeholder-text")} variant="text-xs/normal">
You have no bookmarks. You can add an open tab or hide this by right clicking it
</Text>}
{bookmarks?.length &&
bookmarks.map((_, i) => (
<Bookmark key={i} index={i} bookmarks={bookmarks} methods={methods} />
))
}
</HorizontalScroller>
<Tooltip text={isCurrentChannelBookmarked ? "Remove from Bookmarks" : "Add to Bookmarks"} position="left" >
{p => <button className={cl("button")} {...p} onClick={() => {
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
});
}}
>
<StarIcon
height={20}
width={20}
colorClass={isCurrentChannelBookmarked ? cl("bookmark-star-checked") : cl("bookmark-star")}
/>
</button>}
</Tooltip>
</div>
);
}

View file

@ -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 <svg width={size} height={size} viewBox="0 0 24 24">
<path fill={fill}
d="M17.3 18.7a1 1 0 0 0 1.4-1.4L13.42 12l5.3-5.3a1 1 0 0 0-1.42-1.4L12 10.58l-5.3-5.3a1 1 0 0 0-1.4 1.42L10.58 12l-5.3 5.3a1 1 0 1 0 1.42 1.4L12 13.42l5.3 5.3Z"
/>
</svg>;
}
const GuildIcon = ({ guild }: { guild: Guild; }) => {
return guild.icon
? <img
src={`https://${window.GLOBAL_ENV.CDN_HOST}/icons/${guild?.id}/${guild?.icon}.png`}
className={cl("icon")}
/>
: <div className={cl("guild-acronym-icon")}>
<Text variant="text-xs/semibold" tag="span">{guild.acronym}</Text>
</div>;
};
const ChannelIcon = ({ channel }: { channel: Channel; }) =>
<img
src={channel?.icon
? `https://${window.GLOBAL_ENV.CDN_HOST}/channel-icons/${channel?.id}/${channel?.icon}.png`
: "https://discord.com/assets/c6851bd0b03f1cca5a8c1e720ea6ea17.png" // Default Group Icon
}
className={cl("icon")}
/>;
function TypingIndicator({ isTyping }: { isTyping: boolean; }) {
return isTyping
? <Dots dotRadius={3} themed={true} className={cl("typing-indicator")} />
: 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 ?
<div
data-has-mention={!!mentionCount}
className={classes(dotStyles.numberBadge, dotStyles.baseShapeRound)}
style={{
width: getBadgeWidthForValue(mentionCount || unreadCount)
}}
ref={node => node?.style.setProperty("background-color",
mentionCount ? "var(--red-400)" : "var(--brand-500)", "important"
)}
>
{mentionCount || unreadCount}
</div> : 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 (
<>
<GuildIcon guild={guild} />
<ChannelTypeIcon channel={channel} guild={guild} />
{!compact && <Text className={cl("name-text")}>{channel.name}</Text>}
<NotificationDot channelIds={[channel.id]} />
<TypingIndicator isTyping={isTyping} />
</>
);
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 (
<>
<GuildIcon guild={guild} />
{!compact && <Text className={cl("name-text")}>{name}</Text>}
</>
);
}
}
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 (
<>
<Avatar
size="SIZE_24"
src={user.getAvatarURL(guildId, 128)}
status={showStatusIndicators ? status : undefined}
isTyping={isTyping}
isMobile={isMobile}
/>
{!compact && <Text className={cl("name-text")} data-pomelo={user.isPomelo()}>
{username}
</Text>}
<NotificationDot channelIds={[channel.id]} />
{!showStatusIndicators && <TypingIndicator isTyping={isTyping} />}
</>
);
} else { // Group DM
return (
<>
<ChannelIcon channel={channel} />
{!compact && <Text className={cl("name-text")}>{channel?.name || i18n.Messages.GROUP_DM}</Text>}
<NotificationDot channelIds={[channel.id]} />
<TypingIndicator isTyping={isTyping} />
</>
);
}
}
if (guildId === "@me" || guildId === undefined)
return (
<>
<FriendsIcon />
{!compact && <Text className={cl("name-text")}>{i18n.Messages.FRIENDS}</Text>}
</>
);
return (
<>
<CircleQuestionIcon />
{!compact && <Text className={cl("name-text")}>{i18n.Messages.UNKNOWN_CHANNEL}</Text>}
</>
);
}
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<HTMLDivElement>(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 <div
className={cl("tab", { "tab-compact": compact, "tab-selected": isTabSelected(id), wider: settings.store.widerTabsAndBookmarks })}
key={index}
ref={ref}
onAuxClick={e => {
if (e.button === 1 /* middle click */)
closeTab(id);
}}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <TabContextMenu tab={props} />)}
>
<button
className={cl("button", "channel-info")}
onClick={() => moveToTab(id)}
>
<div
className={cl("tab-inner")}
data-compact={compact}
>
<ChannelTabContent {...props} guild={guild} channel={channel} />
</div>
</button>
{openedTabs.length > 1 && (compact ? isTabSelected(id) : true) && <button
className={cl("button", "close-button", { "close-button-compact": compact, "hoverable": !compact })}
onClick={() => closeTab(id)}
>
<XIcon size={16} fill="var(--interactive-normal)" />
</button>}
</div>;
}
export const PreviewTab = (props: ChannelTabsProps) => {
const guild = GuildStore.getGuild(props.guildId);
const channel = ChannelStore.getChannel(props.channelId);
return (
<div className={classes(cl("preview-tab"), props.compact ? cl("preview-tab-compact") : null)}>
<ChannelTabContent {...props} guild={guild} channel={channel} />
</div>
);
};

View file

@ -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<string, ChannelTabsProps[]>;
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<HTMLDivElement>(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 (
<div
className={cl("container")}
ref={ref}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <BasicContextMenu />)}
>
<div className={cl("tab-container")}>
{openedTabs.map((tab, i) =>
<ChannelTab {...tab} index={i} />
)}
<button
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon height={20} width={20} />
</button>
{GhostTabs}
</div >
{showBookmarkBar && <>
<div className={cl("separator")} />
<BookmarkContainer {...props} userId={userId} />
</>}
</div>
);
}
export function ChannelTabsPreview(p) {
const id = UserStore.getCurrentUser()?.id;
if (!id) return <Forms.FormText>there's no logged in account?????</Forms.FormText>;
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 (
<>
<Forms.FormTitle>Startup tabs</Forms.FormTitle>
<Flex flexDirection="row" style={{ gap: "2px" }}>
{currentTabs.map(t => <>
<PreviewTab {...t} />
</>)}
</Flex>
<Flex flexDirection="row-reverse">
<Button
onClick={() => {
setCurrentTabs([...openedTabs]);
setValue({ ...tabSet, [id]: [...openedTabs] });
}}
>Set to currently open tabs</Button>
</Flex>
</>
);
}

View file

@ -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 (
<Menu.Menu
navId="channeltabs-context"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="ChannelTabs Context Menu"
>
<Menu.MenuGroup>
<Menu.MenuCheckboxItem
checked={showBookmarkBar}
id="show-bookmark-bar"
label="Bookmark Bar"
action={() => {
settings.store.showBookmarkBar = !settings.store.showBookmarkBar;
}}
/>
</Menu.MenuGroup>
</Menu.Menu>
);
}
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 (
<ModalRoot {...modalProps}>
<ModalHeader>
<Text variant="heading-lg/semibold">Edit Bookmark</Text>
</ModalHeader>
<ModalContent>
<Forms.FormTitle className={Margins.top16}>Bookmark Name</Forms.FormTitle>
<TextInput
value={name === placeholder ? undefined : name}
placeholder={placeholder}
onChange={v => setName(v)}
/>
{isBookmarkFolder(bookmark) && <>
<Forms.FormTitle className={Margins.top16}>Folder Color</Forms.FormTitle>
<Select
options={
Object.entries(bookmarkFolderColors).map(([name, value]) => ({
label: name,
value,
default: bookmark.iconColor === value
}))
}
isSelected={v => color === v}
select={setColor}
serialize={String}
/>
</>}
</ModalContent>
<ModalFooter>
<Button
onClick={() => onSave(name || placeholder, color!)}
>Save</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
onClick={() => closeModal(modalKey)}
>Cancel</Button>
</ModalFooter>
</ModalRoot>
);
}
function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
modalProps: any,
modalKey: string,
bookmarks: Bookmarks,
onSave: (folderIndex: number) => void;
}) {
const [folderIndex, setIndex] = useState(-1);
return (
<ModalRoot {...modalProps}>
<ModalHeader>
<Text variant="heading-lg/semibold">Add Bookmark to Folder</Text>
</ModalHeader>
<ModalContent>
<Forms.FormTitle className={Margins.top16}>Select a folder</Forms.FormTitle>
<Select
options={[...Object.entries(bookmarks)
.filter(([, bookmark]) => 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}
/>
</ModalContent>
<ModalFooter>
<Button
onClick={() => onSave(folderIndex)}
>Save</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
onClick={() => closeModal(modalKey)}
>Cancel</Button>
</ModalFooter>
</ModalRoot>
);
}
function DeleteFolderConfirmationModal({ modalProps, modalKey, onConfirm }) {
return (
<ModalRoot {...modalProps}>
<ModalHeader>
<Text variant="heading-lg/semibold">Are you sure?</Text>
</ModalHeader>
<ModalContent>
<Forms.FormText className={Margins.top16}>
Deleting a bookmark folder will also delete all bookmarks within it.
</Forms.FormText>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.RED}
onClick={onConfirm}
>
Delete
</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
onClick={() => closeModal(modalKey)}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>
);
}
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 (
<Menu.Menu
navId="channeltabs-bookmark-context"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="ChannelTabs Bookmark Context Menu"
>
<Menu.MenuGroup>
{bookmarkNotificationDot && !isFolder &&
<Menu.MenuItem
id="mark-as-read"
label={i18n.Messages.MARK_AS_READ}
disabled={!ReadStateStore.hasUnread(bookmark.channelId)}
action={() => ReadStateUtils.ackChannel(ChannelStore.getChannel(bookmark.channelId))}
/>
}
{isFolder
? <Menu.MenuItem
id="open-all-in-folder"
label={"Open All Bookmarks"}
action={() => bookmark.bookmarks.forEach(b => createTab(b))}
/>
: < Menu.MenuItem
id="open-in-tab"
label={"Open in New Tab"}
action={() => createTab(bookmark)}
/>
}
</Menu.MenuGroup>
<Menu.MenuGroup>
<Menu.MenuItem
id="edit-bookmark"
label="Edit Bookmark"
action={() => {
const key = openModal(modalProps =>
<EditModal
modalProps={modalProps}
modalKey={key}
bookmark={bookmark}
onSave={(name, color) => {
methods.editBookmark(index, { name });
if (color) methods.editBookmark(index, { iconColor: color });
closeModal(key);
}
}
/>
);
}}
/>
<Menu.MenuItem
id="delete-bookmark"
label="Delete Bookmark"
action={() => {
if (isFolder) {
const key = openModal(modalProps =>
<DeleteFolderConfirmationModal
modalProps={modalProps}
modalKey={key}
onConfirm={() => {
methods.deleteBookmark(index);
closeModal(key);
}}
/>);
}
else methods.deleteBookmark(index);
}}
/>
<Menu.MenuItem
id="add-to-folder"
label="Add Bookmark to Folder"
disabled={isFolder}
action={() => {
const key = openModal(modalProps =>
<AddToFolderModal
modalProps={modalProps}
modalKey={key}
bookmarks={bookmarks}
onSave={index => {
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);
}
}
/>
);
}}
/>
</Menu.MenuGroup>
<Menu.MenuGroup>
<Menu.MenuCheckboxItem
checked={showBookmarkBar}
id="show-bookmark-bar"
label="Bookmark Bar"
action={() => {
settings.store.showBookmarkBar = !settings.store.showBookmarkBar;
}}
/>
</Menu.MenuGroup>
</Menu.Menu>
);
}
export function TabContextMenu({ tab }: { tab: ChannelTabsProps; }) {
const channel = ChannelStore.getChannel(tab.channelId);
const [compact, setCompact] = useState(tab.compact);
const { showBookmarkBar } = settings.use(["showBookmarkBar"]);
return (
<Menu.Menu
navId="channeltabs-tab-context"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="ChannelTabs Tab Context Menu"
>
<Menu.MenuGroup>
{channel &&
<Menu.MenuItem
id="mark-as-read"
label={i18n.Messages.MARK_AS_READ}
disabled={!ReadStateStore.hasUnread(channel.id)}
action={() => ReadStateUtils.ackChannel(channel)}
/>
}
<Menu.MenuCheckboxItem
checked={compact}
id="toggle-compact-tab"
label="Compact"
action={() => {
setCompact(compact => !compact);
toggleCompactTab(tab.id);
}}
/>
</Menu.MenuGroup>
{openedTabs.length !== 1 && <Menu.MenuGroup>
<Menu.MenuItem
id="close-tab"
label="Close Tab"
action={() => closeTab(tab.id)}
/>
<Menu.MenuItem
id="close-other-tabs"
label="Close Other Tabs"
action={() => closeOtherTabs(tab.id)}
/>
<Menu.MenuItem
id="close-right-tabs"
label="Close Tabs to the Right"
disabled={openedTabs.indexOf(tab) === openedTabs.length - 1}
action={() => closeTabsToTheRight(tab.id)}
/>
<Menu.MenuItem
id="reopen-closed-tab"
label="Reopen Closed Tab"
disabled={!hasClosedTabs()}
action={() => reopenClosedTab()}
/>
</Menu.MenuGroup>}
<Menu.MenuGroup>
<Menu.MenuCheckboxItem
checked={showBookmarkBar}
id="show-bookmark-bar"
label="Bookmark Bar"
action={() => {
settings.store.showBookmarkBar = !settings.store.showBookmarkBar;
}}
/>
</Menu.MenuGroup>
</Menu.Menu>
);
}

View file

@ -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(
<Menu.MenuItem
label="Open in New Tab"
id="open-link-in-tab"
action={() => 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 (
<>
<ErrorBoundary>
<ChannelsTabsContainer {...currentChannel} />
</ErrorBoundary>
{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,
});

View file

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

View file

@ -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<Bookmark | BookmarkFolder, "name">) {
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];
}

View file

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

View file

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

View file

@ -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<T>(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<PersistedTabs | undefined>;
// 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<PersistedTabs>("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<JSX.Element>(count).fill(<div className={cl("tab", "ghost-tab")} />);
}

View file

@ -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<Bookmark, "name"> & { name?: string; }, folderIndex?: number) => void;
addFolder: () => number;
deleteBookmark: (index: number, folderIndex?: number) => void;
editBookmark: (index: number, bookmark: Partial<Bookmark | BookmarkFolder>, modalKey?) => void;
moveDraggedBookmarks: (index1: number, index2: number) => void;
};
export type UseBookmark = [Bookmarks | undefined, UseBookmarkMethods];

View file

@ -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",

View file

@ -715,6 +715,10 @@ export const EquicordDevs = Object.freeze({
name: "SimplyData",
id: 301494563514613762n
},
keifufu: {
name: "keifufu",
id: 469588398110146590n
},
} satisfies Record<string, Dev>);
export const SuncordDevs = /* #__PURE__*/ Object.freeze({

View file

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

View file

@ -45,6 +45,7 @@ export let UserProfileStore: GenericStore;
export let SelectedChannelStore: Stores.SelectedChannelStore & t.FluxStore;
export let SelectedGuildStore: t.FluxStore & Record<string, any>;
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);

View file

@ -484,6 +484,7 @@ export type Avatar = ComponentType<PropsWithChildren<{
src?: string;
size?: "SIZE_16" | "SIZE_20" | "SIZE_24" | "SIZE_32" | "SIZE_40" | "SIZE_48" | "SIZE_56" | "SIZE_80" | "SIZE_120";
status?: string;
statusColor?: string;
statusTooltip?: string;
statusBackdropColor?: string;
@ -501,3 +502,9 @@ export type Avatar = ComponentType<PropsWithChildren<{
type FocusLock = ComponentType<PropsWithChildren<{
containerRef: RefObject<HTMLElement>;
}>>;
export type Dots = ComponentType<PropsWithChildren<{
dotRadius: number;
themed?: boolean;
className?: string;
}>>;

View file

@ -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", {