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:
Jake 2026-03-14 23:28:43 +00:00 • committed by GitHub
parent a5830e597e
commit c9e9d8eacb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 978 additions and 160 deletions

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

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

View file

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

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