mirror of
https://github.com/Equicord/Equicord.git
synced 2025-02-20 15:18:50 -05:00
ChannelTabs
This commit is contained in:
parent
5a164e7903
commit
0e0377f093
17 changed files with 1983 additions and 2 deletions
306
src/equicordplugins/channelTabs/components/BookmarkContainer.tsx
Normal file
306
src/equicordplugins/channelTabs/components/BookmarkContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
274
src/equicordplugins/channelTabs/components/ChannelTab.tsx
Normal file
274
src/equicordplugins/channelTabs/components/ChannelTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
341
src/equicordplugins/channelTabs/components/ContextMenus.tsx
Normal file
341
src/equicordplugins/channelTabs/components/ContextMenus.tsx
Normal 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>
|
||||
);
|
||||
}
|
135
src/equicordplugins/channelTabs/index.tsx
Normal file
135
src/equicordplugins/channelTabs/index.tsx
Normal 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,
|
||||
});
|
262
src/equicordplugins/channelTabs/style.css
Normal file
262
src/equicordplugins/channelTabs/style.css
Normal 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;
|
||||
}
|
117
src/equicordplugins/channelTabs/util/bookmarks.ts
Normal file
117
src/equicordplugins/channelTabs/util/bookmarks.ts
Normal 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];
|
||||
}
|
77
src/equicordplugins/channelTabs/util/constants.ts
Normal file
77
src/equicordplugins/channelTabs/util/constants.ts
Normal 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");
|
10
src/equicordplugins/channelTabs/util/index.ts
Normal file
10
src/equicordplugins/channelTabs/util/index.ts
Normal 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";
|
260
src/equicordplugins/channelTabs/util/tabs.tsx
Normal file
260
src/equicordplugins/channelTabs/util/tabs.tsx
Normal 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")} />);
|
||||
}
|
46
src/equicordplugins/channelTabs/util/types.ts
Normal file
46
src/equicordplugins/channelTabs/util/types.ts
Normal 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];
|
|
@ -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",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
7
src/webpack/common/types/components.d.ts
vendored
7
src/webpack/common/types/components.d.ts
vendored
|
@ -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;
|
||||
}>>;
|
||||
|
|
|
@ -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", {
|
||||
|
|
Loading…
Add table
Reference in a new issue