feat(channelTabs): bookmark improvements (#903)
* feat(channelTabs): improve folder icon UX, bookmark search, and drag feedback * fix icon * fix(channelTabs): persist unread badge fallbacks * fix(betterFolders): sanitize nested folder state * chore(betterFolders): drop nested folder regression test * fix(betterFolders): expand nested folder ancestors on select
This commit is contained in:
parent
a5830e597e
commit
c9e9d8eacb
13 changed files with 978 additions and 160 deletions
|
|
@ -5,14 +5,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseText } from "@components/BaseText";
|
import { BaseText } from "@components/BaseText";
|
||||||
import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, isBookmarkFolder, isTabSelected, navigateToBookmark, openedTabs, settings, switchChannel, useBookmarks } from "@equicordplugins/channelTabs/util";
|
import { BasicChannelTabsProps, Bookmark, BookmarkFolder, BookmarkProps, getDiscordFolderIcon, isBookmarkFolder, isTabSelected, navigateToBookmark, openedTabs, settings, switchChannel, UseBookmarkMethods, useBookmarks } from "@equicordplugins/channelTabs/util";
|
||||||
import { CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
|
import { CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
|
||||||
import { classNameFactory } from "@utils/css";
|
import { classNameFactory } from "@utils/css";
|
||||||
import { getGuildAcronym, getIntlMessage } from "@utils/discord";
|
import { getGuildAcronym, getIntlMessage } from "@utils/discord";
|
||||||
import { classes } from "@utils/misc";
|
import { classes } from "@utils/misc";
|
||||||
import { closeModal, openModal } from "@utils/modal";
|
import { closeModal, openModal } from "@utils/modal";
|
||||||
import { findComponentByCodeLazy } from "@webpack";
|
import { findComponentByCodeLazy } from "@webpack";
|
||||||
import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, React, ReadStateStore, ReadStateUtils, SelectedChannelStore, SelectedGuildStore, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore, useState } from "@webpack/common";
|
import { Avatar, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, React, ReadStateStore, ReadStateUtils, SelectedChannelStore, SelectedGuildStore, TextInput, Tooltip, useDrag, useDrop, useEffect, useRef, UserStore, useState } from "@webpack/common";
|
||||||
|
|
||||||
import { NotificationDot } from "./ChannelTab";
|
import { NotificationDot } from "./ChannelTab";
|
||||||
import { BookmarkContextMenu, EditModal } from "./ContextMenus";
|
import { BookmarkContextMenu, EditModal } from "./ContextMenus";
|
||||||
|
|
@ -44,15 +44,28 @@ function FolderIcon({ fill }: { fill: string; }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
|
function BookmarkIcon({ bookmark }: { bookmark: Bookmark | BookmarkFolder; }) {
|
||||||
if (isBookmarkFolder(bookmark)) return (
|
if (isBookmarkFolder(bookmark)) {
|
||||||
<svg
|
const IconComponent = getDiscordFolderIcon(bookmark.iconName);
|
||||||
height={16}
|
|
||||||
width={16}
|
const fill = bookmark.iconName === "CircleShieldIcon" ? "var(--background-base-low)" : bookmark.iconColor;
|
||||||
viewBox="0 0 24 24"
|
const customIconSize = 16;
|
||||||
>
|
|
||||||
<FolderIcon fill={bookmark.iconColor} />
|
return IconComponent
|
||||||
</svg>
|
? <IconComponent
|
||||||
);
|
height={customIconSize}
|
||||||
|
width={customIconSize}
|
||||||
|
color={bookmark.iconColor}
|
||||||
|
fill={fill}
|
||||||
|
style={{ transform: "scale(0.6666667)", transformOrigin: "center" }}
|
||||||
|
/>
|
||||||
|
: <svg
|
||||||
|
height={16}
|
||||||
|
width={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<FolderIcon fill={bookmark.iconColor} />
|
||||||
|
</svg>;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle special synthetic pages
|
// Handle special synthetic pages
|
||||||
const { channelId } = bookmark;
|
const { channelId } = bookmark;
|
||||||
|
|
@ -205,19 +218,22 @@ function BookmarkFolderOpenMenu(props: BookmarkProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isCurrentChannel }: {
|
function FolderBookmarkItem({ bookmark, bookmarks, folderIndex, bookmarkIndex, methods, isCurrentChannel, searchActive }: {
|
||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
|
bookmarks: (Bookmark | BookmarkFolder)[];
|
||||||
folderIndex: number;
|
folderIndex: number;
|
||||||
bookmarkIndex: number;
|
bookmarkIndex: number;
|
||||||
methods: any;
|
methods: UseBookmarkMethods;
|
||||||
isCurrentChannel?: boolean;
|
isCurrentChannel?: boolean;
|
||||||
|
searchActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
|
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// drag implementation for folder items
|
// drag implementation for folder items
|
||||||
const [, drag] = useDrag(() => ({
|
const [{ isDragging }, drag] = useDrag(() => ({
|
||||||
type: "vc_Bookmark",
|
type: "vc_Bookmark",
|
||||||
|
canDrag: () => !searchActive,
|
||||||
item: () => ({
|
item: () => ({
|
||||||
index: bookmarkIndex, // index within folder
|
index: bookmarkIndex, // index within folder
|
||||||
folderIndex: folderIndex, // the folder its in
|
folderIndex: folderIndex, // the folder its in
|
||||||
|
|
@ -227,17 +243,68 @@ function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isC
|
||||||
collect: monitor => ({
|
collect: monitor => ({
|
||||||
isDragging: !!monitor.isDragging()
|
isDragging: !!monitor.isDragging()
|
||||||
}),
|
}),
|
||||||
}), [bookmark, folderIndex, bookmarkIndex]);
|
}), [bookmark, folderIndex, bookmarkIndex, searchActive]);
|
||||||
|
|
||||||
// combine drag ref
|
const [dropSide, setDropSide] = useState<"before" | "after" | null>(null);
|
||||||
drag(ref);
|
|
||||||
|
const [{ isOver: isReorderOver, canDrop: canReorderDrop }, drop] = useDrop(() => ({
|
||||||
|
accept: "vc_Bookmark",
|
||||||
|
canDrop: item => !searchActive && item.isFromFolder && item.folderIndex === folderIndex,
|
||||||
|
hover: (item, monitor) => {
|
||||||
|
if (!ref.current || searchActive || !item.isFromFolder || item.folderIndex !== folderIndex) return;
|
||||||
|
|
||||||
|
const dragIndex = item.index;
|
||||||
|
const hoverIndex = bookmarkIndex;
|
||||||
|
if (dragIndex === hoverIndex) {
|
||||||
|
setDropSide(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||||
|
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
|
||||||
|
const clientOffset = monitor.getClientOffset();
|
||||||
|
if (!clientOffset) return;
|
||||||
|
|
||||||
|
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
|
||||||
|
const nextDropSide = hoverClientX < hoverMiddleX ? "before" : "after";
|
||||||
|
setDropSide(nextDropSide);
|
||||||
|
|
||||||
|
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
|
||||||
|
|| dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = bookmarks[folderIndex];
|
||||||
|
if (!isBookmarkFolder(folder)) return;
|
||||||
|
|
||||||
|
const newBookmarks = [...folder.bookmarks];
|
||||||
|
const moved = newBookmarks.splice(dragIndex, 1)[0];
|
||||||
|
newBookmarks.splice(hoverIndex, 0, moved);
|
||||||
|
methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
|
||||||
|
item.index = hoverIndex;
|
||||||
|
},
|
||||||
|
collect: monitor => ({
|
||||||
|
isOver: monitor.isOver({ shallow: true }),
|
||||||
|
canDrop: monitor.canDrop()
|
||||||
|
})
|
||||||
|
}), [bookmarkIndex, bookmarks, folderIndex, methods, searchActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReorderOver && dropSide) setDropSide(null);
|
||||||
|
}, [dropSide, isReorderOver]);
|
||||||
|
|
||||||
|
drag(drop(ref));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classes(
|
className={classes(
|
||||||
cl("bookmark", "folder-item", "hoverable"),
|
cl("bookmark", "folder-item", "hoverable"),
|
||||||
isCurrentChannel && cl("bookmark-active")
|
isCurrentChannel && cl("bookmark-active"),
|
||||||
|
isDragging && cl("bookmark-dragging"),
|
||||||
|
isReorderOver && canReorderDrop && cl("bookmark-drop-target"),
|
||||||
|
isReorderOver && canReorderDrop && dropSide === "before" && cl("bookmark-drop-before"),
|
||||||
|
isReorderOver && canReorderDrop && dropSide === "after" && cl("bookmark-drop-after")
|
||||||
)}
|
)}
|
||||||
onClick={() => navigateToBookmark(bookmark)}
|
onClick={() => navigateToBookmark(bookmark)}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
|
|
@ -258,7 +325,9 @@ function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isC
|
||||||
modalKey={key}
|
modalKey={key}
|
||||||
bookmark={bookmark}
|
bookmark={bookmark}
|
||||||
onSave={name => {
|
onSave={name => {
|
||||||
const folder = methods.bookmarks[folderIndex] as BookmarkFolder;
|
const folder = bookmarks[folderIndex];
|
||||||
|
if (!isBookmarkFolder(folder)) return;
|
||||||
|
|
||||||
const newBookmarks = [...folder.bookmarks];
|
const newBookmarks = [...folder.bookmarks];
|
||||||
newBookmarks[bookmarkIndex].name = name;
|
newBookmarks[bookmarkIndex].name = name;
|
||||||
methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
|
methods.editBookmark(folderIndex, { bookmarks: newBookmarks });
|
||||||
|
|
@ -307,16 +376,17 @@ function FolderBookmarkItem({ bookmark, folderIndex, bookmarkIndex, methods, isC
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?: () => void; isCurrentChannel?: boolean; }) {
|
function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?: () => void; isCurrentChannel?: boolean; searchActive?: boolean; }) {
|
||||||
const { bookmarks, index, methods, isExpanded, onToggleFolder, isCurrentChannel } = props;
|
const { bookmarks, index, methods, isExpanded, onToggleFolder, isCurrentChannel, searchActive } = props;
|
||||||
const bookmark = bookmarks[index];
|
const bookmark = bookmarks[index];
|
||||||
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
|
const { bookmarkNotificationDot } = settings.use(["bookmarkNotificationDot"]);
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropSide, setDropSide] = useState<"before" | "after" | null>(null);
|
||||||
|
|
||||||
const [, drag] = useDrag(() => ({
|
const [{ isDragging }, drag] = useDrag(() => ({
|
||||||
type: "vc_Bookmark",
|
type: "vc_Bookmark",
|
||||||
canDrag: () => !isBookmarkFolder(bookmark), // Don't allow dragging folders
|
canDrag: () => !isBookmarkFolder(bookmark) && !searchActive,
|
||||||
item: () => {
|
item: () => {
|
||||||
// find current index dynamically instead of using stale closure
|
// find current index dynamically instead of using stale closure
|
||||||
const currentIndex = bookmarks.findIndex(b =>
|
const currentIndex = bookmarks.findIndex(b =>
|
||||||
|
|
@ -334,22 +404,30 @@ function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?
|
||||||
collect: monitor => ({
|
collect: monitor => ({
|
||||||
isDragging: !!monitor.isDragging()
|
isDragging: !!monitor.isDragging()
|
||||||
}),
|
}),
|
||||||
}), [bookmarks, bookmark, index]);
|
}), [bookmarks, bookmark, index, searchActive]);
|
||||||
|
|
||||||
const [, drop] = useDrop(() => ({
|
const [{ isOver: isReorderOver, canDrop: canReorderDrop }, drop] = useDrop(() => ({
|
||||||
accept: "vc_Bookmark",
|
accept: "vc_Bookmark",
|
||||||
|
canDrop: item => !searchActive && !item.isFromFolder,
|
||||||
hover: (item, monitor) => {
|
hover: (item, monitor) => {
|
||||||
if (!ref.current) return;
|
if (!ref.current || searchActive || item.isFromFolder) return;
|
||||||
|
|
||||||
const dragIndex = item.index;
|
const dragIndex = item.index;
|
||||||
const hoverIndex = index;
|
const hoverIndex = index;
|
||||||
if (dragIndex === hoverIndex) return;
|
if (dragIndex === hoverIndex) {
|
||||||
|
setDropSide(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||||
const hoverMiddleX =
|
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
|
||||||
(hoverBoundingRect.right - hoverBoundingRect.left) / 2;
|
|
||||||
const clientOffset = monitor.getClientOffset();
|
const clientOffset = monitor.getClientOffset();
|
||||||
|
if (!clientOffset) return;
|
||||||
|
|
||||||
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
|
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
|
||||||
|
const nextDropSide = hoverClientX < hoverMiddleX ? "before" : "after";
|
||||||
|
setDropSide(nextDropSide);
|
||||||
|
|
||||||
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
|
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX
|
||||||
|| dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
|
|| dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -358,7 +436,15 @@ function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?
|
||||||
methods.moveDraggedBookmarks(dragIndex, hoverIndex);
|
methods.moveDraggedBookmarks(dragIndex, hoverIndex);
|
||||||
item.index = hoverIndex;
|
item.index = hoverIndex;
|
||||||
},
|
},
|
||||||
}), []);
|
collect: monitor => ({
|
||||||
|
isOver: monitor.isOver({ shallow: true }),
|
||||||
|
canDrop: monitor.canDrop()
|
||||||
|
}),
|
||||||
|
}), [index, methods, searchActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReorderOver && dropSide) setDropSide(null);
|
||||||
|
}, [dropSide, isReorderOver]);
|
||||||
|
|
||||||
// separate drop zone for folders to accept tabs and bookmarks (prevents duplicates)
|
// separate drop zone for folders to accept tabs and bookmarks (prevents duplicates)
|
||||||
const [{ isOver: isFolderDropTarget }, folderDrop] = useDrop(() => ({
|
const [{ isOver: isFolderDropTarget }, folderDrop] = useDrop(() => ({
|
||||||
|
|
@ -416,9 +502,14 @@ function Bookmark(props: BookmarkProps & { isExpanded?: boolean; onToggleFolder?
|
||||||
<div
|
<div
|
||||||
className={classes(
|
className={classes(
|
||||||
cl("bookmark", "hoverable", { wider: settings.store.widerTabsAndBookmarks }),
|
cl("bookmark", "hoverable", { wider: settings.store.widerTabsAndBookmarks }),
|
||||||
|
isBookmarkFolder(bookmark) && cl("bookmark-folder"),
|
||||||
isFolderDropTarget && cl("folder-drop-target"),
|
isFolderDropTarget && cl("folder-drop-target"),
|
||||||
isExpanded && cl("folder-expanded"),
|
isExpanded && cl("folder-expanded"),
|
||||||
isCurrentChannel && cl("bookmark-active")
|
isCurrentChannel && cl("bookmark-active"),
|
||||||
|
isDragging && cl("bookmark-dragging"),
|
||||||
|
isReorderOver && canReorderDrop && cl("bookmark-drop-target"),
|
||||||
|
isReorderOver && canReorderDrop && dropSide === "before" && cl("bookmark-drop-before"),
|
||||||
|
isReorderOver && canReorderDrop && dropSide === "after" && cl("bookmark-drop-after")
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|
@ -485,6 +576,9 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
|
||||||
const { guildId, channelId, userId } = props;
|
const { guildId, channelId, userId } = props;
|
||||||
const [bookmarks, methods] = useBookmarks(userId);
|
const [bookmarks, methods] = useBookmarks(userId);
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// make setting reactive to changes
|
// make setting reactive to changes
|
||||||
const { bookmarksIndependentFromTabs } = settings.use(["bookmarksIndependentFromTabs"]);
|
const { bookmarksIndependentFromTabs } = settings.use(["bookmarksIndependentFromTabs"]);
|
||||||
|
|
@ -498,6 +592,14 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
|
||||||
activeTab.channelId === currentChannelId &&
|
activeTab.channelId === currentChannelId &&
|
||||||
(activeTab.guildId || "@me") === currentGuildId;
|
(activeTab.guildId || "@me") === currentGuildId;
|
||||||
|
|
||||||
|
const normalizedSearchQuery = searchQuery.trim().toLowerCase();
|
||||||
|
const searchActive = normalizedSearchQuery.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSearchOpen) return;
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}, [isSearchOpen]);
|
||||||
|
|
||||||
const toggleFolder = (folderIndex: number) => {
|
const toggleFolder = (folderIndex: number) => {
|
||||||
setExpandedFolders(prev => {
|
setExpandedFolders(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -523,8 +625,9 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
|
||||||
|
|
||||||
const [{ isOver }, dropRef] = useDrop(() => ({
|
const [{ isOver }, dropRef] = useDrop(() => ({
|
||||||
accept: ["vc_ChannelTab", "vc_Bookmark"],
|
accept: ["vc_ChannelTab", "vc_Bookmark"],
|
||||||
|
canDrop: () => !searchActive,
|
||||||
drop: (item: any, monitor) => {
|
drop: (item: any, monitor) => {
|
||||||
if (!bookmarks) return;
|
if (!bookmarks || searchActive) return;
|
||||||
|
|
||||||
// check if a nested drop zone (like a folder) already handled this
|
// check if a nested drop zone (like a folder) already handled this
|
||||||
if (monitor.didDrop()) {
|
if (monitor.didDrop()) {
|
||||||
|
|
@ -557,7 +660,9 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
|
||||||
collect: monitor => ({
|
collect: monitor => ({
|
||||||
isOver: monitor.isOver({ shallow: true })
|
isOver: monitor.isOver({ shallow: true })
|
||||||
})
|
})
|
||||||
}), [bookmarks, methods]);
|
}), [bookmarks, methods, searchActive]);
|
||||||
|
|
||||||
|
const matchesSearch = (value?: string) => value?.toLowerCase().includes(normalizedSearchQuery) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cl("bookmark-container")}>
|
<div className={cl("bookmark-container")}>
|
||||||
|
|
@ -570,52 +675,121 @@ export default function BookmarkContainer(props: BasicChannelTabsProps & { userI
|
||||||
</BaseText>}
|
</BaseText>}
|
||||||
{Array.isArray(bookmarks) && bookmarks.length > 0 &&
|
{Array.isArray(bookmarks) && bookmarks.length > 0 &&
|
||||||
bookmarks.flatMap((bookmark, i) => {
|
bookmarks.flatMap((bookmark, i) => {
|
||||||
const items = [
|
if (!isBookmarkFolder(bookmark)) {
|
||||||
|
if (searchActive && !matchesSearch(bookmark.name)) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Bookmark
|
||||||
|
key={i}
|
||||||
|
index={i}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
methods={methods}
|
||||||
|
searchActive={searchActive}
|
||||||
|
isExpanded={false}
|
||||||
|
isCurrentChannel={
|
||||||
|
bookmarksIndependentFromTabs &&
|
||||||
|
!channelMatchesActiveTab &&
|
||||||
|
bookmark.channelId === currentChannelId &&
|
||||||
|
(bookmark.guildId || "@me") === currentGuildId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMatches = searchActive ? matchesSearch(bookmark.name) : false;
|
||||||
|
const visibleFolderBookmarks = bookmark.bookmarks
|
||||||
|
.map((childBookmark, childIndex) => ({ childBookmark, childIndex }))
|
||||||
|
.filter(({ childBookmark }) => !searchActive || folderMatches || matchesSearch(childBookmark.name));
|
||||||
|
|
||||||
|
if (searchActive && !folderMatches && !visibleFolderBookmarks.length) return [];
|
||||||
|
|
||||||
|
const isExpanded = searchActive
|
||||||
|
? visibleFolderBookmarks.length > 0
|
||||||
|
: expandedFolders.has(i);
|
||||||
|
|
||||||
|
const folderItems = [
|
||||||
<Bookmark
|
<Bookmark
|
||||||
key={i}
|
key={i}
|
||||||
index={i}
|
index={i}
|
||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
methods={methods}
|
methods={methods}
|
||||||
isExpanded={expandedFolders.has(i)}
|
searchActive={searchActive}
|
||||||
onToggleFolder={() => toggleFolder(i)}
|
isExpanded={isExpanded}
|
||||||
isCurrentChannel={
|
onToggleFolder={searchActive ? undefined : () => toggleFolder(i)}
|
||||||
bookmarksIndependentFromTabs &&
|
isCurrentChannel={false}
|
||||||
!channelMatchesActiveTab && (
|
|
||||||
!isBookmarkFolder(bookmark) &&
|
|
||||||
bookmark.channelId === currentChannelId &&
|
|
||||||
(bookmark.guildId || "@me") === currentGuildId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
|
|
||||||
// if folder is expanded, render its bookmarks
|
if (isExpanded) {
|
||||||
if (isBookmarkFolder(bookmark) && expandedFolders.has(i)) {
|
visibleFolderBookmarks.forEach(({ childBookmark, childIndex }) => {
|
||||||
bookmark.bookmarks.forEach((childBookmark, childIndex) => {
|
folderItems.push(
|
||||||
items.push(
|
|
||||||
<FolderBookmarkItem
|
<FolderBookmarkItem
|
||||||
key={`${i}-${childIndex}`}
|
key={`${i}-${childIndex}`}
|
||||||
bookmark={childBookmark}
|
bookmark={childBookmark}
|
||||||
|
bookmarks={bookmarks}
|
||||||
folderIndex={i}
|
folderIndex={i}
|
||||||
bookmarkIndex={childIndex}
|
bookmarkIndex={childIndex}
|
||||||
methods={methods}
|
methods={methods}
|
||||||
|
searchActive={searchActive}
|
||||||
isCurrentChannel={
|
isCurrentChannel={
|
||||||
bookmarksIndependentFromTabs &&
|
bookmarksIndependentFromTabs &&
|
||||||
!channelMatchesActiveTab && (
|
!channelMatchesActiveTab &&
|
||||||
childBookmark.channelId === currentChannelId &&
|
childBookmark.channelId === currentChannelId &&
|
||||||
(childBookmark.guildId || "@me") === currentGuildId
|
(childBookmark.guildId || "@me") === currentGuildId
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return folderItems;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</HorizontalScroller>
|
</HorizontalScroller>
|
||||||
|
|
||||||
|
<div className={classes(cl("bookmark-search-shell"), isSearchOpen && cl("bookmark-search-shell-open"))}>
|
||||||
|
<div className={cl("bookmark-search-field")}>
|
||||||
|
<TextInput
|
||||||
|
inputRef={searchInputRef}
|
||||||
|
inputClassName={cl("bookmark-search-input")}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Search bookmarks"
|
||||||
|
onBlur={() => {
|
||||||
|
if (!searchQuery.trim()) setIsSearchOpen(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
setSearchQuery("");
|
||||||
|
} else {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tooltip text="Search bookmarks" position="left">
|
||||||
|
{p => <button
|
||||||
|
className={classes(cl("button"), cl("bookmark-search-button"))}
|
||||||
|
{...p}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSearchOpen && !searchQuery.trim()) {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="m16 16 4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tooltip text={isCurrentChannelBookmarked ? "Remove from Bookmarks" : "Add to Bookmarks"} position="left" >
|
<Tooltip text={isCurrentChannelBookmarked ? "Remove from Bookmarks" : "Add to Bookmarks"} position="left" >
|
||||||
{p => <button className={cl("button")} {...p} onClick={() => {
|
{p => <button className={cl("button")} {...p} onClick={() => {
|
||||||
if (isCurrentChannelBookmarked) {
|
if (isCurrentChannelBookmarked) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseText } from "@components/BaseText";
|
import { BaseText } from "@components/BaseText";
|
||||||
import { ChannelTabsProps, closeTab, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings } from "@equicordplugins/channelTabs/util";
|
import { ChannelTabsProps, closeTab, ensureUnreadFallbackCountsLoaded, getNotificationDotState, getUnreadFallbackCounts, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings, updateUnreadFallbackCounts } from "@equicordplugins/channelTabs/util";
|
||||||
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
|
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
|
||||||
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
|
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
|
||||||
import { classNameFactory } from "@utils/css";
|
import { classNameFactory } from "@utils/css";
|
||||||
|
|
@ -76,26 +76,57 @@ function TypingIndicator({ isTyping }: { isTyping: boolean; }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => {
|
export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => {
|
||||||
const [unreadCount, mentionCount] = useStateFromStores(
|
const userId = UserStore.getCurrentUser()?.id;
|
||||||
|
const { persistUnreadCountFallback } = settings.use(["persistUnreadCountFallback"]);
|
||||||
|
const [, forceUpdate] = useState(0);
|
||||||
|
const channelStateKey = channelIds.join(",");
|
||||||
|
const channelStates = useStateFromStores(
|
||||||
[ReadStateStore],
|
[ReadStateStore],
|
||||||
() => [
|
() => channelIds.map(channelId => ({
|
||||||
channelIds.reduce((count, channelId) => count + ReadStateStore.getUnreadCount(channelId), 0),
|
channelId,
|
||||||
channelIds.reduce((count, channelId) => count + ReadStateStore.getMentionCount(channelId), 0),
|
hasUnread: ReadStateStore.hasUnread(channelId),
|
||||||
]
|
mentionCount: ReadStateStore.getMentionCount(channelId),
|
||||||
|
unreadCount: ReadStateStore.getUnreadCount(channelId)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const stateSignature = channelStates.map(state => `${state.channelId}:${Number(state.hasUnread)}:${state.mentionCount}:${state.unreadCount}`).join("|");
|
||||||
|
const { badgeText, hasMention, shouldShow } = getNotificationDotState(
|
||||||
|
channelStates,
|
||||||
|
userId ? getUnreadFallbackCounts(userId) : {},
|
||||||
|
persistUnreadCountFallback
|
||||||
);
|
);
|
||||||
|
|
||||||
return unreadCount > 0 ?
|
useEffect(() => {
|
||||||
|
if (!userId || !persistUnreadCountFallback) return;
|
||||||
|
|
||||||
|
let didCancel = false;
|
||||||
|
ensureUnreadFallbackCountsLoaded(userId).then(() => {
|
||||||
|
if (didCancel) return;
|
||||||
|
forceUpdate(prev => prev + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
didCancel = true;
|
||||||
|
};
|
||||||
|
}, [persistUnreadCountFallback, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || !persistUnreadCountFallback) return;
|
||||||
|
updateUnreadFallbackCounts(userId, channelStates);
|
||||||
|
}, [channelStateKey, persistUnreadCountFallback, stateSignature, userId]);
|
||||||
|
|
||||||
|
return shouldShow ?
|
||||||
<div
|
<div
|
||||||
data-has-mention={!!mentionCount}
|
data-has-mention={hasMention}
|
||||||
className={classes(cl("notification-badge"), dotStyles.numberBadge, dotStyles.baseShapeRound)}
|
className={classes(cl("notification-badge"), dotStyles.numberBadge, dotStyles.baseShapeRound)}
|
||||||
style={{
|
style={{
|
||||||
width: "16px"
|
width: "16px"
|
||||||
}}
|
}}
|
||||||
ref={node => node?.style.setProperty("background-color",
|
ref={node => node?.style.setProperty("background-color",
|
||||||
mentionCount ? "var(--red-400)" : "var(--brand-500)", "important"
|
hasMention ? "var(--red-400)" : "var(--brand-500)", "important"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mentionCount || unreadCount}
|
{badgeText}
|
||||||
</div> : null;
|
</div> : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,39 @@
|
||||||
import { BaseText } from "@components/BaseText";
|
import { BaseText } from "@components/BaseText";
|
||||||
import { Heading } from "@components/Heading";
|
import { Heading } from "@components/Heading";
|
||||||
import { Paragraph } from "@components/Paragraph";
|
import { Paragraph } from "@components/Paragraph";
|
||||||
import { bookmarkFolderColors, bookmarkPlaceholderName, closeOtherTabs, closeTab, closeTabsToTheLeft, closeTabsToTheRight, createTab, hasClosedTabs, isBookmarkFolder, openedTabs, reopenClosedTab, settings, toggleCompactTab } from "@equicordplugins/channelTabs/util";
|
import { bookmarkFolderColors, bookmarkPlaceholderName, closeOtherTabs, closeTab, closeTabsToTheLeft, closeTabsToTheRight, createTab, getDiscordFolderIcon, getDiscordFolderIconNames, hasClosedTabs, isBookmarkFolder, openedTabs, reopenClosedTab, settings, toggleCompactTab } from "@equicordplugins/channelTabs/util";
|
||||||
import { Bookmark, BookmarkFolder, Bookmarks, ChannelTabsProps, UseBookmarkMethods } from "@equicordplugins/channelTabs/util/types";
|
import { Bookmark, BookmarkFolder, Bookmarks, ChannelTabsProps, UseBookmarkMethods } from "@equicordplugins/channelTabs/util/types";
|
||||||
import { getIntlMessage } from "@utils/discord";
|
import { getIntlMessage } from "@utils/discord";
|
||||||
import { Margins } from "@utils/margins";
|
import { Margins } from "@utils/margins";
|
||||||
import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
|
import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
|
||||||
import { Button, ChannelStore, FluxDispatcher, Menu, ReadStateStore, ReadStateUtils, Select, TextInput, useState } from "@webpack/common";
|
import { Button, ChannelStore, ColorPicker, FluxDispatcher, Menu, ReadStateStore, ReadStateUtils, Select, TextInput, useMemo, useState } from "@webpack/common";
|
||||||
|
|
||||||
|
const legacyFolderColors: Record<string, string> = {
|
||||||
|
"var(--channeltabs-red)": bookmarkFolderColors.Red,
|
||||||
|
"var(--channeltabs-blue)": bookmarkFolderColors.Blue,
|
||||||
|
"var(--channeltabs-yellow)": bookmarkFolderColors.Yellow,
|
||||||
|
"var(--channeltabs-green)": bookmarkFolderColors.Green,
|
||||||
|
"var(--channeltabs-black)": bookmarkFolderColors.Black,
|
||||||
|
"var(--channeltabs-white)": bookmarkFolderColors.White,
|
||||||
|
"var(--channeltabs-orange)": bookmarkFolderColors.Orange,
|
||||||
|
"var(--channeltabs-pink)": bookmarkFolderColors.Pink
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFolderColor = (color?: string) => {
|
||||||
|
if (!color) return bookmarkFolderColors.Black;
|
||||||
|
return legacyFolderColors[color] ?? color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorToInt = (color?: string) => {
|
||||||
|
const normalized = normalizeFolderColor(color);
|
||||||
|
if (!/^#([0-9a-f]{6})$/i.test(normalized)) return parseInt(bookmarkFolderColors.Black.slice(1), 16);
|
||||||
|
return parseInt(normalized.slice(1), 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const intToColor = (value: number | null) =>
|
||||||
|
value == null
|
||||||
|
? bookmarkFolderColors.Black
|
||||||
|
: `#${value.toString(16).padStart(6, "0")}`;
|
||||||
|
|
||||||
export function BasicContextMenu() {
|
export function BasicContextMenu() {
|
||||||
const { showBookmarkBar } = settings.use(["showBookmarkBar"]);
|
const { showBookmarkBar } = settings.use(["showBookmarkBar"]);
|
||||||
|
|
@ -37,14 +64,213 @@ export function BasicContextMenu() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FolderGlyph({ color, iconName }: { color: string; iconName?: string; }) {
|
||||||
|
const IconComponent = getDiscordFolderIcon(iconName);
|
||||||
|
const resolvedColor = normalizeFolderColor(color);
|
||||||
|
const fill = iconName === "CircleShieldIcon" ? "var(--background-base-low)" : resolvedColor;
|
||||||
|
const customIconSize = 16;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
alignItems: "center",
|
||||||
|
color: resolvedColor,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
lineHeight: 0
|
||||||
|
}}>
|
||||||
|
{IconComponent
|
||||||
|
? <IconComponent
|
||||||
|
height={customIconSize}
|
||||||
|
width={customIconSize}
|
||||||
|
color={resolvedColor}
|
||||||
|
fill={fill}
|
||||||
|
style={{ transform: "scale(0.6666667)", transformOrigin: "center" }}
|
||||||
|
/>
|
||||||
|
: <svg height={16} width={16} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</svg>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderChipPreview({ name, color, iconName }: { name: string; color: string; iconName?: string; }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
alignItems: "center",
|
||||||
|
border: "1px solid var(--border-subtle)",
|
||||||
|
borderRadius: 8,
|
||||||
|
display: "inline-flex",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
maxWidth: "100%",
|
||||||
|
padding: "6px 10px"
|
||||||
|
}}>
|
||||||
|
<FolderGlyph color={color} iconName={iconName} />
|
||||||
|
<BaseText size="sm">{name.trim() || "Folder"}</BaseText>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderIconPickerModal({ modalProps, modalKey, name, color, iconName, onSelect, onColorChange }: {
|
||||||
|
modalProps: ModalProps,
|
||||||
|
modalKey: string,
|
||||||
|
name: string,
|
||||||
|
color: string,
|
||||||
|
iconName?: string,
|
||||||
|
onSelect: (iconName: string) => void,
|
||||||
|
onColorChange: (color: string) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [localColor, setLocalColor] = useState(normalizeFolderColor(color));
|
||||||
|
const iconNames = useMemo(() => getDiscordFolderIconNames(), []);
|
||||||
|
const filteredIconNames = useMemo(() => {
|
||||||
|
const normalized = search.trim().toLowerCase();
|
||||||
|
if (!normalized) return iconNames;
|
||||||
|
return iconNames.filter(iconName => iconName.toLowerCase().includes(normalized));
|
||||||
|
}, [iconNames, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps}>
|
||||||
|
<ModalHeader>
|
||||||
|
<BaseText size="lg" weight="semibold">Choose Folder Icon</BaseText>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent>
|
||||||
|
<Heading className={Margins.top16}>Preview</Heading>
|
||||||
|
<FolderChipPreview name={name} color={localColor} iconName={iconName} />
|
||||||
|
<Heading className={Margins.top16}>Icon Color</Heading>
|
||||||
|
<div className={Margins.top8}>
|
||||||
|
<ColorPicker
|
||||||
|
color={colorToInt(localColor)}
|
||||||
|
onChange={value => {
|
||||||
|
const nextColor = intToColor(value);
|
||||||
|
setLocalColor(nextColor);
|
||||||
|
onColorChange(nextColor);
|
||||||
|
}}
|
||||||
|
showEyeDropper={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Heading className={Margins.top16}>Search</Heading>
|
||||||
|
<TextInput
|
||||||
|
value={search}
|
||||||
|
placeholder={`Search ${iconNames.length} icons...`}
|
||||||
|
onChange={setSearch}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(52px, 1fr))",
|
||||||
|
marginTop: 16,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: "auto"
|
||||||
|
}}>
|
||||||
|
{filteredIconNames.map(name => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(name);
|
||||||
|
closeModal(modalKey);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
background: name === iconName ? "var(--background-modifier-hover)" : "var(--background-secondary)",
|
||||||
|
border: "1px solid var(--border-subtle)",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
height: 52,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FolderGlyph color={localColor} iconName={name} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
color={Button.Colors.TRANSPARENT}
|
||||||
|
look={Button.Looks.FILLED}
|
||||||
|
onClick={() => closeModal(modalKey)}
|
||||||
|
>Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderAppearanceFields({
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
color,
|
||||||
|
setColor,
|
||||||
|
iconName,
|
||||||
|
setIconName,
|
||||||
|
placeholder = "Folder"
|
||||||
|
}: {
|
||||||
|
name: string,
|
||||||
|
setName: (value: string) => void,
|
||||||
|
color: string,
|
||||||
|
setColor: (value: string) => void,
|
||||||
|
iconName?: string,
|
||||||
|
setIconName: (value?: string) => void,
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return <>
|
||||||
|
<Heading className={Margins.top16}>Folder Name</Heading>
|
||||||
|
<TextInput
|
||||||
|
value={name === placeholder ? undefined : name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={setName}
|
||||||
|
/>
|
||||||
|
<Heading className={Margins.top16}>Folder Color</Heading>
|
||||||
|
<div className={Margins.top8}>
|
||||||
|
<ColorPicker
|
||||||
|
color={colorToInt(color)}
|
||||||
|
onChange={value => setColor(intToColor(value))}
|
||||||
|
showEyeDropper={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Heading className={Margins.top16}>Folder Icon</Heading>
|
||||||
|
<FolderChipPreview name={name} color={color} iconName={iconName} />
|
||||||
|
<Button
|
||||||
|
className={Margins.top8}
|
||||||
|
onClick={() => {
|
||||||
|
const key = openModal(modalProps => (
|
||||||
|
<FolderIconPickerModal
|
||||||
|
modalProps={modalProps}
|
||||||
|
modalKey={key}
|
||||||
|
name={name}
|
||||||
|
color={color}
|
||||||
|
iconName={iconName}
|
||||||
|
onSelect={setIconName}
|
||||||
|
onColorChange={setColor}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
>Choose Icon</Button>
|
||||||
|
{iconName && <Button
|
||||||
|
className={Margins.top8}
|
||||||
|
color={Button.Colors.TRANSPARENT}
|
||||||
|
look={Button.Looks.FILLED}
|
||||||
|
onClick={() => setIconName(undefined)}
|
||||||
|
>Use Default Icon</Button>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
|
export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
|
||||||
modalProps: ModalProps,
|
modalProps: ModalProps,
|
||||||
modalKey: string,
|
modalKey: string,
|
||||||
bookmark: Bookmark | BookmarkFolder,
|
bookmark: Bookmark | BookmarkFolder,
|
||||||
onSave: (name: string, color: string) => void;
|
onSave: (name: string, color: string, iconName?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(bookmark.name);
|
const [name, setName] = useState(bookmark.name);
|
||||||
const [color, setColor] = useState(isBookmarkFolder(bookmark) ? bookmark.iconColor : undefined);
|
const [color, setColor] = useState(isBookmarkFolder(bookmark) ? normalizeFolderColor(bookmark.iconColor) : bookmarkFolderColors.Black);
|
||||||
|
const [iconName, setIconName] = useState(isBookmarkFolder(bookmark) ? bookmark.iconName : undefined);
|
||||||
const placeholder = bookmarkPlaceholderName(bookmark);
|
const placeholder = bookmarkPlaceholderName(bookmark);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -53,31 +279,28 @@ export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
|
||||||
<BaseText size="lg" weight="semibold">Edit Bookmark</BaseText>
|
<BaseText size="lg" weight="semibold">Edit Bookmark</BaseText>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<Heading className={Margins.top16}>Bookmark Name</Heading>
|
{isBookmarkFolder(bookmark)
|
||||||
<TextInput
|
? <FolderAppearanceFields
|
||||||
value={name === placeholder ? undefined : name}
|
name={name}
|
||||||
placeholder={placeholder}
|
setName={setName}
|
||||||
onChange={v => setName(v)}
|
color={color}
|
||||||
/>
|
setColor={setColor}
|
||||||
{isBookmarkFolder(bookmark) && <>
|
iconName={iconName}
|
||||||
<Heading className={Margins.top16}>Folder Color</Heading>
|
setIconName={setIconName}
|
||||||
<Select
|
placeholder={placeholder}
|
||||||
options={
|
|
||||||
Object.entries(bookmarkFolderColors).map(([name, value]) => ({
|
|
||||||
label: name,
|
|
||||||
value,
|
|
||||||
default: bookmark.iconColor === value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
isSelected={v => color === v}
|
|
||||||
select={setColor}
|
|
||||||
serialize={String}
|
|
||||||
/>
|
/>
|
||||||
</>}
|
: <>
|
||||||
|
<Heading className={Margins.top16}>Bookmark Name</Heading>
|
||||||
|
<TextInput
|
||||||
|
value={name === placeholder ? undefined : name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={setName}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onSave(name || placeholder, color!)}
|
onClick={() => onSave(name || placeholder, color, iconName)}
|
||||||
>Save</Button>
|
>Save</Button>
|
||||||
<Button
|
<Button
|
||||||
color={Button.Colors.TRANSPARENT}
|
color={Button.Colors.TRANSPARENT}
|
||||||
|
|
@ -93,9 +316,12 @@ function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
|
||||||
modalProps: any,
|
modalProps: any,
|
||||||
modalKey: string,
|
modalKey: string,
|
||||||
bookmarks: Bookmarks,
|
bookmarks: Bookmarks,
|
||||||
onSave: (folderIndex: number) => void;
|
onSave: (folderIndex: number, folderName: string, folderColor: string, iconName?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [folderIndex, setIndex] = useState(-1);
|
const [folderIndex, setIndex] = useState(-1);
|
||||||
|
const [folderName, setFolderName] = useState("");
|
||||||
|
const [folderColor, setFolderColor] = useState<string>(bookmarkFolderColors.Black);
|
||||||
|
const [iconName, setIconName] = useState<string | undefined>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalRoot {...modalProps}>
|
<ModalRoot {...modalProps}>
|
||||||
|
|
@ -120,10 +346,18 @@ function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
|
||||||
select={setIndex}
|
select={setIndex}
|
||||||
serialize={String}
|
serialize={String}
|
||||||
/>
|
/>
|
||||||
|
{folderIndex === -1 && <FolderAppearanceFields
|
||||||
|
name={folderName}
|
||||||
|
setName={setFolderName}
|
||||||
|
color={folderColor}
|
||||||
|
setColor={setFolderColor}
|
||||||
|
iconName={iconName}
|
||||||
|
setIconName={setIconName}
|
||||||
|
/>}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onSave(folderIndex)}
|
onClick={() => onSave(folderIndex, folderName, folderColor, iconName)}
|
||||||
>Save</Button>
|
>Save</Button>
|
||||||
<Button
|
<Button
|
||||||
color={Button.Colors.TRANSPARENT}
|
color={Button.Colors.TRANSPARENT}
|
||||||
|
|
@ -208,9 +442,11 @@ export function BookmarkContextMenu({ bookmarks, index, methods }: { bookmarks:
|
||||||
modalProps={modalProps}
|
modalProps={modalProps}
|
||||||
modalKey={key}
|
modalKey={key}
|
||||||
bookmark={bookmark}
|
bookmark={bookmark}
|
||||||
onSave={(name, color) => {
|
onSave={(name, color, iconName) => {
|
||||||
methods.editBookmark(index, { name });
|
methods.editBookmark(index, {
|
||||||
if (color) methods.editBookmark(index, { iconColor: color });
|
name,
|
||||||
|
...(isFolder && { iconColor: normalizeFolderColor(color), iconName })
|
||||||
|
});
|
||||||
closeModal(key);
|
closeModal(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,9 +482,9 @@ export function BookmarkContextMenu({ bookmarks, index, methods }: { bookmarks:
|
||||||
modalProps={modalProps}
|
modalProps={modalProps}
|
||||||
modalKey={key}
|
modalKey={key}
|
||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
onSave={index => {
|
onSave={(index, folderName, folderColor, iconName) => {
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
const folderIndex = methods.addFolder();
|
const folderIndex = methods.addFolder(folderName, normalizeFolderColor(folderColor), iconName);
|
||||||
methods.addBookmark(bookmark as Bookmark, folderIndex);
|
methods.addBookmark(bookmark as Bookmark, folderIndex);
|
||||||
}
|
}
|
||||||
else methods.addBookmark(bookmark as Bookmark, index);
|
else methods.addBookmark(bookmark as Bookmark, index);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,14 @@
|
||||||
|
:root {
|
||||||
|
--channeltabs-red: #f23f42;
|
||||||
|
--channeltabs-blue: #0052b6;
|
||||||
|
--channeltabs-yellow: #f0b132;
|
||||||
|
--channeltabs-green: #24934f;
|
||||||
|
--channeltabs-black: #000;
|
||||||
|
--channeltabs-white: #fff;
|
||||||
|
--channeltabs-orange: #e67e22;
|
||||||
|
--channeltabs-pink: #ff73fa;
|
||||||
|
}
|
||||||
|
|
||||||
.vc-channeltabs-icon {
|
.vc-channeltabs-icon {
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
|
|
@ -45,16 +56,11 @@
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vc-channeltabs-container {
|
.vc-channeltabs-bookmark-container {
|
||||||
--channeltabs-red: #f23f42;
|
position: relative;
|
||||||
--channeltabs-blue: #0052b6;
|
}
|
||||||
--channeltabs-yellow: #f0b132;
|
|
||||||
--channeltabs-green: #24934f;
|
|
||||||
--channeltabs-black: #000;
|
|
||||||
--channeltabs-white: #fff;
|
|
||||||
--channeltabs-orange: #e67e22;
|
|
||||||
--channeltabs-pink: #ff73fa;
|
|
||||||
|
|
||||||
|
.vc-channeltabs-container {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
|
|
@ -159,12 +165,19 @@
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease, background-color 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vc-channeltabs-bookmark.vc-channeltabs-wider {
|
.vc-channeltabs-bookmark.vc-channeltabs-wider {
|
||||||
max-width: calc(12rem * var(--tab-width-scale, 1));
|
max-width: calc(12rem * var(--tab-width-scale, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-folder {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.vc-channeltabs-bookmark .vc-channeltabs-name-text {
|
.vc-channeltabs-bookmark .vc-channeltabs-name-text {
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
|
|
@ -361,6 +374,61 @@
|
||||||
color: var(--text-default);
|
color: var(--text-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-shell {
|
||||||
|
position: relative;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-right: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-shell-open {
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-field {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(-50%) scaleX(0.92);
|
||||||
|
transform-origin: right center;
|
||||||
|
transition: width 0.18s ease, opacity 0.15s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-shell-open .vc-channeltabs-bookmark-search-field {
|
||||||
|
width: 160px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(-50%) scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-input {
|
||||||
|
padding-right: 34px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark-search-button:hover {
|
||||||
|
color: var(--interactive-icon-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.vc-channeltabs-new-button {
|
.vc-channeltabs-new-button {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
|
|
@ -1004,19 +1072,56 @@
|
||||||
|
|
||||||
/* Drag and drop visual feedback */
|
/* Drag and drop visual feedback */
|
||||||
.vc-channeltabs-bookmarks-drop-target {
|
.vc-channeltabs-bookmarks-drop-target {
|
||||||
background-color: color-mix(in srgb, var(--brand-500) 20%, transparent);
|
background-color: color-mix(in srgb, var(--brand-500) 15%, transparent);
|
||||||
border: 2px dashed var(--brand-500);
|
border: 1px dashed color-mix(in srgb, var(--brand-500) 65%, transparent);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-target {
|
||||||
|
border-color: color-mix(in srgb, var(--brand-500) 70%, var(--border-subtle));
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--brand-500) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-before::before,
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-after::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
width: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--brand-500);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand-500) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-before::before {
|
||||||
|
left: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-after::after {
|
||||||
|
right: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-before {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-drop-after {
|
||||||
|
transform: translateX(-5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vc-channeltabs-bookmark.vc-channeltabs-folder-drop-target {
|
.vc-channeltabs-bookmark.vc-channeltabs-folder-drop-target {
|
||||||
background-color: color-mix(in srgb, var(--brand-500) 30%, transparent);
|
background-color: color-mix(in srgb, var(--brand-500) 22%, transparent);
|
||||||
border: 2px solid var(--brand-500);
|
border: 1px solid color-mix(in srgb, var(--brand-500) 80%, var(--border-subtle));
|
||||||
transform: scale(1.05);
|
transform: scale(1.03);
|
||||||
box-shadow: 0 0 8px var(--brand-500);
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand-500) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Folder expansion */
|
|
||||||
.vc-channeltabs-bookmark.vc-channeltabs-folder-expanded {
|
.vc-channeltabs-bookmark.vc-channeltabs-folder-expanded {
|
||||||
background-color: var(--background-mod-strong);
|
background-color: var(--background-mod-strong);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,12 @@ export function useBookmarks(userId: string): UseBookmark {
|
||||||
...bookmarks
|
...bookmarks
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
addFolder() {
|
addFolder(name, iconColor, iconName) {
|
||||||
if (!bookmarks) return;
|
if (!bookmarks) return;
|
||||||
const length = bookmarks[userId].push({
|
const length = bookmarks[userId].push({
|
||||||
name: "Folder",
|
name: name?.trim() || "Folder",
|
||||||
iconColor: bookmarkFolderColors.Black,
|
iconColor: iconColor ?? bookmarkFolderColors.Black,
|
||||||
|
iconName,
|
||||||
bookmarks: []
|
bookmarks: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,12 +110,22 @@ export function useBookmarks(userId: string): UseBookmark {
|
||||||
},
|
},
|
||||||
deleteBookmark(index, folderIndex) {
|
deleteBookmark(index, folderIndex) {
|
||||||
if (!bookmarks) return;
|
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")
|
if (typeof folderIndex === "number") {
|
||||||
(bookmarks[userId][folderIndex] as BookmarkFolder).bookmarks.splice(index, 1);
|
const folder = bookmarks[userId][folderIndex];
|
||||||
else bookmarks[userId].splice(index, 1);
|
if (!isBookmarkFolder(folder))
|
||||||
|
return logger.error("Attempted to delete bookmark from non-folder " + folderIndex, bookmarks);
|
||||||
|
|
||||||
|
if (index < 0 || index > (folder.bookmarks.length - 1))
|
||||||
|
return logger.error("Attempted to delete bookmark at index " + index, bookmarks);
|
||||||
|
|
||||||
|
folder.bookmarks.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
if (index < 0 || index > (bookmarks[userId].length - 1))
|
||||||
|
return logger.error("Attempted to delete bookmark at index " + index, bookmarks);
|
||||||
|
|
||||||
|
bookmarks[userId].splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
setBookmarks({
|
setBookmarks({
|
||||||
...bookmarks
|
...bookmarks
|
||||||
|
|
|
||||||
|
|
@ -109,14 +109,14 @@ function AnimationSettings(): JSX.Element {
|
||||||
export const logger = new Logger("ChannelTabs");
|
export const logger = new Logger("ChannelTabs");
|
||||||
|
|
||||||
export const bookmarkFolderColors = {
|
export const bookmarkFolderColors = {
|
||||||
Red: "var(--channeltabs-red)",
|
Red: "#f23f42",
|
||||||
Blue: "var(--channeltabs-blue)",
|
Blue: "#0052b6",
|
||||||
Yellow: "var(--channeltabs-yellow)",
|
Yellow: "#f0b132",
|
||||||
Green: "var(--channeltabs-green)",
|
Green: "#24934f",
|
||||||
Black: "var(--channeltabs-black)",
|
Black: "#000",
|
||||||
White: "var(--channeltabs-white)",
|
White: "#fff",
|
||||||
Orange: "var(--channeltabs-orange)",
|
Orange: "#e67e22",
|
||||||
Pink: "var(--channeltabs-pink)"
|
Pink: "#ff73fa"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const settings = definePluginSettings({
|
export const settings = definePluginSettings({
|
||||||
|
|
@ -160,6 +160,12 @@ export const settings = definePluginSettings({
|
||||||
type: OptionType.BOOLEAN,
|
type: OptionType.BOOLEAN,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
persistUnreadCountFallback: {
|
||||||
|
description: "Persist unread badge fallbacks across reloads for tabs and bookmarks",
|
||||||
|
type: OptionType.BOOLEAN,
|
||||||
|
default: true,
|
||||||
|
restartNeeded: false
|
||||||
|
},
|
||||||
widerTabsAndBookmarks: {
|
widerTabsAndBookmarks: {
|
||||||
description: "Extend the length of tabs and bookmarks for larger monitors",
|
description: "Extend the length of tabs and bookmarks for larger monitors",
|
||||||
type: OptionType.BOOLEAN,
|
type: OptionType.BOOLEAN,
|
||||||
|
|
|
||||||
29
src/equicordplugins/channelTabs/util/folderIcons.tsx
Normal file
29
src/equicordplugins/channelTabs/util/folderIcons.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Icon } from "@vencord/discord-types";
|
||||||
|
import { findByPropsLazy } from "@webpack";
|
||||||
|
|
||||||
|
const iconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
|
||||||
|
|
||||||
|
let iconNames: string[] | undefined;
|
||||||
|
|
||||||
|
export function getDiscordFolderIcon(name?: string): Icon | undefined {
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const icon = iconsModule[name];
|
||||||
|
return typeof icon === "function" && name.endsWith("Icon") ? icon : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiscordFolderIconNames(): string[] {
|
||||||
|
if (iconNames) return iconNames;
|
||||||
|
|
||||||
|
iconNames = Object.keys(iconsModule)
|
||||||
|
.filter(name => name.endsWith("Icon") && typeof iconsModule[name] === "function")
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return iconNames;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
|
|
||||||
export * from "./bookmarks";
|
export * from "./bookmarks";
|
||||||
export * from "./constants";
|
export * from "./constants";
|
||||||
|
export * from "./folderIcons";
|
||||||
export * from "./icons";
|
export * from "./icons";
|
||||||
export * from "./tabs";
|
export * from "./tabs";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export * from "./unread";
|
||||||
|
export * from "./unreadState";
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface BookmarkFolder {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
name: string;
|
name: string;
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
|
iconName?: string;
|
||||||
}
|
}
|
||||||
export interface BookmarkProps {
|
export interface BookmarkProps {
|
||||||
bookmarks: Bookmarks,
|
bookmarks: Bookmarks,
|
||||||
|
|
@ -38,7 +39,7 @@ export interface BookmarkProps {
|
||||||
export type Bookmarks = (Bookmark | BookmarkFolder)[];
|
export type Bookmarks = (Bookmark | BookmarkFolder)[];
|
||||||
export type UseBookmarkMethods = {
|
export type UseBookmarkMethods = {
|
||||||
addBookmark: (bookmark: Omit<Bookmark, "name"> & { name?: string; }, folderIndex?: number) => void;
|
addBookmark: (bookmark: Omit<Bookmark, "name"> & { name?: string; }, folderIndex?: number) => void;
|
||||||
addFolder: () => number;
|
addFolder: (name?: string, iconColor?: string, iconName?: string) => number;
|
||||||
deleteBookmark: (index: number, folderIndex?: number) => void;
|
deleteBookmark: (index: number, folderIndex?: number) => void;
|
||||||
editBookmark: (index: number, bookmark: Partial<Bookmark | BookmarkFolder>, modalKey?) => void;
|
editBookmark: (index: number, bookmark: Partial<Bookmark | BookmarkFolder>, modalKey?) => void;
|
||||||
moveDraggedBookmarks: (index1: number, index2: number) => void;
|
moveDraggedBookmarks: (index1: number, index2: number) => void;
|
||||||
|
|
|
||||||
57
src/equicordplugins/channelTabs/util/unread.ts
Normal file
57
src/equicordplugins/channelTabs/util/unread.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DataStore } from "@api/index";
|
||||||
|
import { ChannelUnreadState, reconcileUnreadFallbackCache } from "@equicordplugins/channelTabs/util/unreadState";
|
||||||
|
|
||||||
|
export interface PersistedUnreadFallbacks {
|
||||||
|
[userId: string]: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATASTORE_KEY = "ChannelTabs_unreadFallbacks_v1";
|
||||||
|
const unreadFallbacks: PersistedUnreadFallbacks = {};
|
||||||
|
const unreadFallbackLoads = new Map<string, Promise<Record<string, number>>>();
|
||||||
|
const unreadFallbackSaves = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
export function getUnreadFallbackCounts(userId: string) {
|
||||||
|
return unreadFallbacks[userId] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureUnreadFallbackCountsLoaded(userId: string) {
|
||||||
|
if (unreadFallbacks[userId]) return unreadFallbacks[userId];
|
||||||
|
if (unreadFallbackLoads.has(userId)) return unreadFallbackLoads.get(userId)!;
|
||||||
|
|
||||||
|
const loadPromise = DataStore.get<PersistedUnreadFallbacks>(DATASTORE_KEY)
|
||||||
|
.then(fallbacks => {
|
||||||
|
unreadFallbacks[userId] = {
|
||||||
|
...(fallbacks?.[userId] ?? {}),
|
||||||
|
...(unreadFallbacks[userId] ?? {})
|
||||||
|
};
|
||||||
|
unreadFallbackLoads.delete(userId);
|
||||||
|
return unreadFallbacks[userId];
|
||||||
|
});
|
||||||
|
|
||||||
|
unreadFallbackLoads.set(userId, loadPromise);
|
||||||
|
return loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUnreadFallbackCounts(userId: string, channelStates: ChannelUnreadState[]) {
|
||||||
|
const currentFallbacks = unreadFallbacks[userId] ?? {};
|
||||||
|
const nextFallbacks = reconcileUnreadFallbackCache(currentFallbacks, channelStates);
|
||||||
|
if (JSON.stringify(nextFallbacks) === JSON.stringify(currentFallbacks)) return;
|
||||||
|
|
||||||
|
unreadFallbacks[userId] = nextFallbacks;
|
||||||
|
|
||||||
|
const pendingSave = unreadFallbackSaves.get(userId) ?? Promise.resolve();
|
||||||
|
const nextSave = pendingSave
|
||||||
|
.catch(() => void 0)
|
||||||
|
.then(() => DataStore.update<PersistedUnreadFallbacks>(DATASTORE_KEY, old => ({
|
||||||
|
...(old ?? {}),
|
||||||
|
[userId]: unreadFallbacks[userId]
|
||||||
|
})));
|
||||||
|
|
||||||
|
unreadFallbackSaves.set(userId, nextSave);
|
||||||
|
}
|
||||||
78
src/equicordplugins/channelTabs/util/unreadState.ts
Normal file
78
src/equicordplugins/channelTabs/util/unreadState.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChannelUnreadState {
|
||||||
|
channelId: string;
|
||||||
|
hasUnread: boolean;
|
||||||
|
mentionCount: number;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationDotState {
|
||||||
|
badgeText: string | null;
|
||||||
|
hasMention: boolean;
|
||||||
|
shouldShow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationDotState(
|
||||||
|
channelStates: ChannelUnreadState[],
|
||||||
|
cachedUnreadCounts: Record<string, number>,
|
||||||
|
shouldUseFallback: boolean
|
||||||
|
): NotificationDotState {
|
||||||
|
const mentionCount = channelStates.reduce((count, state) => count + state.mentionCount, 0);
|
||||||
|
if (mentionCount > 0) {
|
||||||
|
return {
|
||||||
|
badgeText: String(mentionCount),
|
||||||
|
hasMention: true,
|
||||||
|
shouldShow: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let unreadCount = 0;
|
||||||
|
let fallbackCount = 0;
|
||||||
|
|
||||||
|
for (const state of channelStates) {
|
||||||
|
unreadCount += state.unreadCount;
|
||||||
|
if (state.unreadCount > 0 || !shouldUseFallback || !state.hasUnread) continue;
|
||||||
|
|
||||||
|
fallbackCount += Math.max(cachedUnreadCounts[state.channelId] ?? 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = unreadCount + fallbackCount;
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return {
|
||||||
|
badgeText: null,
|
||||||
|
hasMention: false,
|
||||||
|
shouldShow: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
badgeText: fallbackCount > 0 ? `${totalCount}+` : String(totalCount),
|
||||||
|
hasMention: false,
|
||||||
|
shouldShow: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileUnreadFallbackCache(
|
||||||
|
cachedUnreadCounts: Record<string, number>,
|
||||||
|
channelStates: ChannelUnreadState[]
|
||||||
|
): Record<string, number> {
|
||||||
|
const nextCachedUnreadCounts = { ...cachedUnreadCounts };
|
||||||
|
|
||||||
|
for (const state of channelStates) {
|
||||||
|
if (state.unreadCount > 0) {
|
||||||
|
nextCachedUnreadCounts[state.channelId] = state.unreadCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.hasUnread) {
|
||||||
|
delete nextCachedUnreadCounts[state.channelId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCachedUnreadCounts;
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import FolderSideBar from "./FolderSideBar";
|
import FolderSideBar from "./FolderSideBar";
|
||||||
|
import { areNestedRelated as areNestedRelatedInMap, getAncestorFolderIds as getAncestorFolderIdsFromMap, getChildFolderIds as getChildFolderIdsFromMap, getDescendantFolderIds as getDescendantFolderIdsFromMap, hasParentInChain as hasParentInChainInMap, sanitizeNestedFolderMap } from "./nestedFolders";
|
||||||
|
|
||||||
enum FolderIconDisplay {
|
enum FolderIconDisplay {
|
||||||
Never,
|
Never,
|
||||||
|
|
@ -116,7 +117,19 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNestedFolderMap(): Record<string, string> {
|
function getNestedFolderMap(): Record<string, string> {
|
||||||
return settings.store.nestedFolders ?? {};
|
const nestedFolders = settings.store.nestedFolders ?? {};
|
||||||
|
const validFolderIds = new Set<string>(
|
||||||
|
SortedGuildStore.getGuildFolders()
|
||||||
|
.map((folder: GuildFolder) => folder.folderId?.toString())
|
||||||
|
.filter((folderId): folderId is string => folderId != null)
|
||||||
|
);
|
||||||
|
const sanitizedFolders = sanitizeNestedFolderMap(nestedFolders, validFolderIds);
|
||||||
|
|
||||||
|
if (JSON.stringify(sanitizedFolders) !== JSON.stringify(nestedFolders)) {
|
||||||
|
settings.store.nestedFolders = sanitizedFolders;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizedFolders;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveNestedFolderMap(map: Record<string, string>) {
|
function saveNestedFolderMap(map: Record<string, string>) {
|
||||||
|
|
@ -128,24 +141,15 @@ export function getParentFolderId(childId: string | number): string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChildFolderIds(parentId: string | number): string[] {
|
export function getChildFolderIds(parentId: string | number): string[] {
|
||||||
const map = getNestedFolderMap();
|
return getChildFolderIdsFromMap(getNestedFolderMap(), parentId);
|
||||||
return Object.entries(map)
|
|
||||||
.filter(([, pid]) => pid === String(parentId))
|
|
||||||
.map(([cid]) => cid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDescendantFolderIds(parentId: string | number): string[] {
|
function getDescendantFolderIds(parentId: string | number): string[] {
|
||||||
const descendants: string[] = [];
|
return getDescendantFolderIdsFromMap(getNestedFolderMap(), parentId);
|
||||||
const queue = [...getChildFolderIds(parentId)];
|
}
|
||||||
|
|
||||||
while (queue.length) {
|
function getAncestorFolderIds(childId: string | number): string[] {
|
||||||
const current = queue.shift();
|
return getAncestorFolderIdsFromMap(getNestedFolderMap(), childId);
|
||||||
if (!current) continue;
|
|
||||||
descendants.push(current);
|
|
||||||
queue.push(...getChildFolderIds(current));
|
|
||||||
}
|
|
||||||
|
|
||||||
return descendants;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nestFolder(childId: string, parentId: string) {
|
function nestFolder(childId: string, parentId: string) {
|
||||||
|
|
@ -168,20 +172,11 @@ function unnestFolder(childId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasParentInChain(childId: string, parentId: string): boolean {
|
function hasParentInChain(childId: string, parentId: string): boolean {
|
||||||
const seen = new Set<string>();
|
return hasParentInChainInMap(getNestedFolderMap(), childId, parentId);
|
||||||
let current = getParentFolderId(childId);
|
|
||||||
|
|
||||||
while (current != null && !seen.has(current)) {
|
|
||||||
if (current === parentId) return true;
|
|
||||||
seen.add(current);
|
|
||||||
current = getParentFolderId(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function areNestedRelated(firstId: string, secondId: string): boolean {
|
function areNestedRelated(firstId: string, secondId: string): boolean {
|
||||||
return hasParentInChain(firstId, secondId) || hasParentInChain(secondId, firstId);
|
return areNestedRelatedInMap(getNestedFolderMap(), firstId, secondId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings = definePluginSettings({
|
export const settings = definePluginSettings({
|
||||||
|
|
@ -254,7 +249,7 @@ export default definePlugin({
|
||||||
|
|
||||||
settings,
|
settings,
|
||||||
start() {
|
start() {
|
||||||
settings.store.nestedFolders ??= {};
|
settings.store.nestedFolders = getNestedFolderMap();
|
||||||
},
|
},
|
||||||
|
|
||||||
patches: [
|
patches: [
|
||||||
|
|
@ -446,14 +441,17 @@ export default definePlugin({
|
||||||
|
|
||||||
flux: {
|
flux: {
|
||||||
CHANNEL_SELECT(data) {
|
CHANNEL_SELECT(data) {
|
||||||
if (!settings.store.closeAllFolders && !settings.store.forceOpen && !settings.store.closeServerFolder)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (lastGuildId !== data.guildId) {
|
if (lastGuildId !== data.guildId) {
|
||||||
lastGuildId = data.guildId;
|
lastGuildId = data.guildId;
|
||||||
const guildFolder = getGuildFolder(data.guildId);
|
const guildFolder = getGuildFolder(data.guildId);
|
||||||
|
|
||||||
if (guildFolder?.folderId) {
|
if (guildFolder?.folderId) {
|
||||||
|
for (const folderId of getAncestorFolderIds(guildFolder.folderId).reverse()) {
|
||||||
|
if (!ExpandedGuildFolderStore.isFolderExpanded(folderId)) {
|
||||||
|
FolderUtils.toggleGuildFolderExpand(folderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
|
if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
|
||||||
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
|
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
src/plugins/betterFolders/nestedFolders.ts
Normal file
89
src/plugins/betterFolders/nestedFolders.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a modification for Discord's desktop app
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NestedFolderMap = Record<string, string>;
|
||||||
|
|
||||||
|
export function getChildFolderIds(map: NestedFolderMap, parentId: string | number): string[] {
|
||||||
|
return Object.entries(map)
|
||||||
|
.filter(([, id]) => id === String(parentId))
|
||||||
|
.map(([id]) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasParentInChain(map: NestedFolderMap, childId: string, parentId: string): boolean {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let current = map[childId];
|
||||||
|
|
||||||
|
while (current != null && !seen.has(current)) {
|
||||||
|
if (current === parentId) return true;
|
||||||
|
seen.add(current);
|
||||||
|
current = map[current];
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areNestedRelated(map: NestedFolderMap, firstId: string, secondId: string): boolean {
|
||||||
|
return hasParentInChain(map, firstId, secondId) || hasParentInChain(map, secondId, firstId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAncestorFolderIds(map: NestedFolderMap, childId: string | number): string[] {
|
||||||
|
const ancestors: string[] = [];
|
||||||
|
const seen = new Set<string>([String(childId)]);
|
||||||
|
let current = map[String(childId)];
|
||||||
|
|
||||||
|
while (current != null && !seen.has(current)) {
|
||||||
|
ancestors.push(current);
|
||||||
|
seen.add(current);
|
||||||
|
current = map[current];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDescendantFolderIds(map: NestedFolderMap, parentId: string | number): string[] {
|
||||||
|
const descendants: string[] = [];
|
||||||
|
const rootId = String(parentId);
|
||||||
|
const seen = new Set<string>([rootId]);
|
||||||
|
const queue = [...getChildFolderIds(map, parentId)];
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (!current || seen.has(current)) continue;
|
||||||
|
|
||||||
|
seen.add(current);
|
||||||
|
descendants.push(current);
|
||||||
|
queue.push(...getChildFolderIds(map, current));
|
||||||
|
}
|
||||||
|
|
||||||
|
return descendants;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNestedFolderMap(map: NestedFolderMap, validFolderIds: Set<string>): NestedFolderMap {
|
||||||
|
const sanitized: NestedFolderMap = {};
|
||||||
|
|
||||||
|
for (const [childId, parentId] of Object.entries(map)) {
|
||||||
|
if (!validFolderIds.has(childId) || !validFolderIds.has(parentId)) continue;
|
||||||
|
if (childId === parentId) continue;
|
||||||
|
delete sanitized[childId];
|
||||||
|
if (hasParentInChain(sanitized, parentId, childId)) continue;
|
||||||
|
|
||||||
|
sanitized[childId] = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue