Equicord/src/equicordplugins/channelTabs/components/ChannelTab.tsx
2026-03-01 21:32:06 -05:00

516 lines
19 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { ChannelTabsProps, closeTab, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings } from "@equicordplugins/channelTabs/util";
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
import { classNameFactory } from "@utils/css";
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
import { classes } from "@utils/misc";
import { Channel, Guild, User } from "@vencord/discord-types";
import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
import { Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, ThemeStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
import { JSX } from "react";
import { TabContextMenu } from "./ContextMenus";
const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:");
const dotStyles = findCssClassesLazy("numberBadge", "baseShapeRound");
const ChannelTypeIcon = findComponentByCodeLazy('"ChannelItemIcon")');
// Custom SVG icons for pages that don't have findable components
function LibraryIcon(height: number = 20, width: number = 20, className?: string): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
height={height}
width={width}
fill="none"
className={className}
>
<path fill="currentColor" d="M3 3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3zm2 1v16h10V4H5zm13-1h2a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-2V3zm0 2v12h1V5h-1z" />
</svg>
);
}
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")}>
<BaseText size="xs" weight="semibold" tag="span">{getGuildAcronym(guild)}</BaseText>
</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
? <ThreeDots 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(cl("notification-badge"), dotStyles.numberBadge, dotStyles.baseShapeRound)}
style={{
width: "16px"
}}
ref={node => node?.style.setProperty("background-color",
mentionCount ? "var(--red-400)" : "var(--brand-500)", "important"
)}
>
{mentionCount || unreadCount}
</div> : null;
};
interface TabNumberBadgeProps {
number: number;
position: "left" | "right";
isSelected: boolean;
isCompact: boolean;
isHovered: boolean;
}
export const TabNumberBadge = ({ number, position, isSelected, isCompact, isHovered }: TabNumberBadgeProps) => {
// hide badge if:
// 1. tab is currently selected
// 2. tab is compact AND not hovered
const shouldHide = isSelected || (isCompact && !isHovered);
if (shouldHide) return null;
return (
<div
className={cl("tab-number-badge", `position-${position}`)}
data-position={position}
>
{number}
</div>
);
};
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],
() => {
const recipientId = recipients?.[0] ?? "";
return [
!!((Object.keys(TypingStore.getTypingUsers(props.channelId)) as string[]).filter(id => id !== userId).length),
PresenceStore.getStatus(recipientId),
PresenceStore.isMobileOnline(recipientId)
];
}
);
if (guild) {
if (channel)
return (
<>
<GuildIcon guild={guild} />
<ChannelTypeIcon channel={channel} guild={guild} />
<BaseText className={cl("name-text")}>{channel.name}</BaseText>
<NotificationDot channelIds={[channel.id]} />
<TypingIndicator isTyping={isTyping} />
</>
);
else {
let name = `${getIntlMessage("UNKNOWN_CHANNEL")} (${channelId})`;
switch (channelId) {
case "customize-community":
name = getIntlMessage("CHANNELS_AND_ROLES");
break;
case "channel-browser":
name = getIntlMessage("GUILD_SIDEBAR_CHANNEL_BROWSER");
break;
case "shop":
name = getIntlMessage("GUILD_SHOP_CHANNEL_LABEL");
break;
case "member-safety":
name = getIntlMessage("MEMBER_SAFETY_CHANNEL_TITLE");
break;
case "@home":
name = getIntlMessage("SERVER_GUIDE");
break;
}
return (
<>
<GuildIcon guild={guild} />
<BaseText className={cl("name-text")}>{name}</BaseText>
</>
);
}
}
if (channel && recipients?.length) {
if (recipients.length === 1) {
const user = UserStore.getUser(recipients[0]) as User & { globalName: string; };
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}
/>
<BaseText className={cl("name-text")}>
{username}
</BaseText>
<NotificationDot channelIds={[channel.id]} />
{!showStatusIndicators && <TypingIndicator isTyping={isTyping} />}
</>
);
} else {
// Group DM
return (
<>
<ChannelIcon channel={channel} />
<BaseText className={cl("name-text")}>{channel?.name || getIntlMessage("GROUP_DM")}</BaseText>
<NotificationDot channelIds={[channel.id]} />
<TypingIndicator isTyping={isTyping} />
</>
);
}
}
// handle special synthetic pages
if (channelId && channelId.startsWith("__")) {
const specialPagesConfig: Record<string, { label: string, Icon: React.ComponentType<any>; }> = {
"__quests__": { label: "Quests", Icon: QuestIcon },
"__message-requests__": { label: "Message Requests", Icon: EnvelopeIcon },
"__friends__": { label: getIntlMessage("FRIENDS"), Icon: FriendsIcon },
"__shop__": { label: "Shop", Icon: ShopIcon },
"__library__": { label: "Library", Icon: () => LibraryIcon(20, 20) },
"__discovery__": { label: "Discovery", Icon: DiscoveryIcon },
"__nitro__": { label: "Nitro", Icon: NitroIcon },
"__icymi__": { label: "ICYMI", Icon: ICYMIIcon },
"__activity__": { label: "Activity", Icon: ActivityIcon },
};
const pageConfig = specialPagesConfig[channelId];
if (pageConfig) {
const { label, Icon } = pageConfig;
return (
<>
<Icon />
<BaseText className={cl("name-text")}>{label}</BaseText>
</>
);
}
}
if (guildId === "@me" || guildId === undefined) {
return (
<>
<FriendsIcon />
<BaseText className={cl("name-text")}>{getIntlMessage("FRIENDS")}</BaseText>
</>
);
}
return (
<>
<CircleQuestionIcon />
<BaseText className={cl("name-text")}>{getIntlMessage("UNKNOWN_CHANNEL")}</BaseText>
</>
);
}
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 [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isDropTarget, setIsDropTarget] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const { showTabNumbers, tabNumberPosition } = settings.use(["showTabNumbers", "tabNumberPosition"]);
useEffect(() => {
if (isEntering) {
const timer = setTimeout(() => setIsEntering(false), 300);
return () => clearTimeout(timer);
}
}, [isEntering]);
useEffect(() => {
if (isDropTarget && !isDragging) {
const timer = setTimeout(() => setIsDropTarget(false), 100);
return () => clearTimeout(timer);
}
}, [isDropTarget, isDragging]);
const ref = useRef<HTMLDivElement>(null);
const lastSwapTimeRef = useRef(0);
const SWAP_THROTTLE_MS = 100;
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = ref.current?.getBoundingClientRect().width || 0;
const baseWidth = 192; // 12rem in pixels (assuming 16px base font)
let pendingScale = settings.store.tabWidthScale;
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const newWidth = startWidth + deltaX;
const newScale = newWidth / baseWidth;
// 50% and 200% scale
const clampedScale = Math.max(0.5, Math.min(2, newScale));
pendingScale = Math.round(clampedScale * 100);
// update CSS variable immediately for visual feedback
if (ref.current) {
ref.current.style.setProperty("--tab-width-scale", String(pendingScale / 100));
}
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
settings.store.tabWidthScale = pendingScale;
if (ref.current) {
ref.current.style.removeProperty("--tab-width-scale");
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const [, drag] = useDrag(() => ({
type: "vc_ChannelTab",
item: () => {
setIsDragging(true);
lastSwapTimeRef.current = Date.now() - SWAP_THROTTLE_MS;
// get fresh tab data dynamically to avoid stale closures
const tab = openedTabs.find(t => t.id === id);
return {
id,
channelId: tab?.channelId || channelId,
guildId: tab?.guildId || guildId
};
},
collect: monitor => ({
isDragging: !!monitor.isDragging()
}),
end: () => {
setIsDragging(false);
setIsDropTarget(false);
lastSwapTimeRef.current = 0;
}
}), [id, channelId, guildId]);
const [, drop] = useDrop(() => ({
accept: "vc_ChannelTab",
hover: (item, monitor) => {
if (!ref.current) return;
const now = Date.now();
const draggedId = item.id;
const hoveredId = id;
if (draggedId === hoveredId) return;
const dragIndex = openedTabs.findIndex(t => t.id === draggedId);
const hoverIndex = openedTabs.findIndex(t => t.id === hoveredId);
if (dragIndex === -1 || hoverIndex === -1) return;
const isOver = monitor.isOver({ shallow: true });
setIsDropTarget(isOver);
if (now - lastSwapTimeRef.current < SWAP_THROTTLE_MS) {
return;
}
const hoverBoundingRect = ref.current.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
const hoverWidth = hoverBoundingRect.right - hoverBoundingRect.left;
const hoverMiddleX = hoverWidth / 2;
// get tab width
const draggedElement = document.querySelector(".vc-channeltabs-tab-dragging") as HTMLElement;
const draggedWidth = draggedElement?.getBoundingClientRect().width || hoverWidth;
const halfDraggedWidth = draggedWidth / 2;
const hysteresis = hoverWidth * 0.05;
// When dragging RIGHT: check if right edge of dragged tab has crossed midpoint
if (dragIndex < hoverIndex) {
const draggedRightEdge = hoverClientX + halfDraggedWidth;
if (draggedRightEdge < hoverMiddleX + hysteresis) return;
}
// When dragging LEFT: check if left edge of dragged tab has crossed midpoint
if (dragIndex > hoverIndex) {
const draggedLeftEdge = hoverClientX - halfDraggedWidth;
if (draggedLeftEdge > hoverMiddleX - hysteresis) return;
}
lastSwapTimeRef.current = now;
moveDraggedTabs(dragIndex, hoverIndex);
},
drop: () => {
setIsDropTarget(false);
}
}), []);
drag(drop(ref));
// check if quests running (questify momentLet)
const hasActiveQuests = activeQuestIntervals.size > 0;
console.log(ThemeStore.theme);
return <div
className={cl("tab",
ThemeStore.theme === "light" ? "light-theme" : "dark-theme",
{
"tab-compact": compact,
"tab-selected": isTabSelected(id),
"tab-entering": isEntering,
"tab-closing": isClosing,
"tab-dragging": isDragging,
"tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks
})}
key={index}
ref={ref}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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}
>
{/* left position badge */}
{showTabNumbers && tabNumberPosition === "left" && (
<TabNumberBadge
number={index + 1}
position="left"
isSelected={isTabSelected(id)}
isCompact={compact}
isHovered={isHovered}
/>
)}
<ChannelTabContent {...props} guild={guild} channel={channel} />
{/* right position badge */}
{showTabNumbers && tabNumberPosition === "right" && (
<TabNumberBadge
number={index + 1}
position="right"
isSelected={isTabSelected(id)}
isCompact={compact}
isHovered={isHovered}
/>
)}
</div>
</button>
{openedTabs.length > 1 && <button
className={cl("button", "close-button", { "close-button-compact": compact, "hoverable": !compact })}
onClick={() => {
setIsClosing(true);
setTimeout(() => closeTab(id), 150);
}}
>
<XIcon size={16} fill="var(--interactive-icon-default)" />
</button>}
{!compact && settings.store.showResizeHandle && <div
className={cl("tab-resize-handle")}
onMouseDown={handleResizeStart}
/>}
</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>
);
};