From a684702afa3755f06114ae1523345c5c7ef252bb Mon Sep 17 00:00:00 2001 From: rmitnam <153247638+rmitnam@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:14:11 +0700 Subject: [PATCH 1/2] feat: add FlexibleDMs --- src/plugins/flexibleDMs/components.tsx | 242 ++++++++++++++++++++++++ src/plugins/flexibleDMs/index.tsx | 191 +++++++++++++++++++ src/plugins/flexibleDMs/model.ts | 120 ++++++++++++ src/plugins/flexibleDMs/pinDms.ts | 26 +++ src/plugins/flexibleDMs/settings.tsx | 36 ++++ src/plugins/flexibleDMs/state.ts | 99 ++++++++++ src/plugins/flexibleDMs/style.css | 252 +++++++++++++++++++++++++ src/plugins/pinDms/data.ts | 4 +- src/plugins/pinDms/index.tsx | 6 +- src/utils/constants.ts | 4 + 10 files changed, 976 insertions(+), 4 deletions(-) create mode 100644 src/plugins/flexibleDMs/components.tsx create mode 100644 src/plugins/flexibleDMs/index.tsx create mode 100644 src/plugins/flexibleDMs/model.ts create mode 100644 src/plugins/flexibleDMs/pinDms.ts create mode 100644 src/plugins/flexibleDMs/settings.tsx create mode 100644 src/plugins/flexibleDMs/state.ts create mode 100644 src/plugins/flexibleDMs/style.css diff --git a/src/plugins/flexibleDMs/components.tsx b/src/plugins/flexibleDMs/components.tsx new file mode 100644 index 00000000000..c87383c5ae8 --- /dev/null +++ b/src/plugins/flexibleDMs/components.tsx @@ -0,0 +1,242 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { FolderIcon } from "@components/Icons"; +import { classes } from "@utils/misc"; +import { RenderModalProps } from "@vencord/discord-types"; +import { ChannelStore, ContextMenuApi, FluxDispatcher, IconUtils, Menu, Modal, openModal, React, ReadStateStore, SelectedChannelStore, TextInput, UserStore, useState, useStateFromStores } from "@webpack/common"; + +import { Folder, Placement, Row } from "./model"; +import { accountId, closeAll, dissolveFolder, drop, editFolder, PrivateChannelSortStore } from "./state"; + +const DRAG_TYPE = "application/x-vencord-dm-folder"; +let dragging: { id: string; userId: string | undefined; folder: boolean; } | undefined; + +export function clearDrag() { + dragging = undefined; +} + +const DEFAULT_COLOR = 0x5865f2; +const SWATCHES = [ + 1752220, 3066993, 3447003, 10181046, 15277667, 15844367, 15105570, 15158332, 9807270, 6323595, + 1146986, 2067276, 2123412, 7419530, 11342935, 12745742, 11027200, 10038562, 9936031, 5533306 +]; + +function FolderColorPicker({ color, onChange }: { color: number; onChange(color: number): void; }) { + const hex = `#${color.toString(16).padStart(6, "0")}`; + const check = ( + + ); + return ( +
+
+ + +
+
+ {SWATCHES.map(value => ( + + ))} +
+
+ ); +} + +function FolderSettings({ folder, modalProps, userId }: { folder: Folder; modalProps: RenderModalProps; userId: string | undefined; }) { + const [name, setName] = useState(folder.name); + const [color, setColor] = useState(parseInt(folder.color.slice(1), 16)); + const save = () => { + if (!name.trim()) return; + editFolder(folder.id, { name: name.trim(), color: `#${color.toString(16).padStart(6, "0")}` }, userId); + modalProps.onClose(); + }; + return ( + +
{ e.preventDefault(); save(); }}> + + + Folder Color + + +
+ ); +} + +export function openFolderSettings(folder: Folder) { + const userId = accountId(); + openModal(modalProps => ( + + + + )); +} + +function markFolderRead(folder: Folder) { + const active = new Set(PrivateChannelSortStore.getPrivateChannelIds()); + const channels = folder.channels.filter(id => active.has(id) && ReadStateStore.hasUnread(id)).map(channelId => ({ + channelId, + messageId: ReadStateStore.lastMessageId(channelId), + readStateType: 0 + })).filter(c => c.messageId); + if (channels.length) FluxDispatcher.dispatch({ type: "BULK_ACK", context: "APP", channels }); +} + +function folderMenu(event: React.MouseEvent, folder: Folder) { + ContextMenuApi.openContextMenu(event, () => ( + + markFolderRead(folder)} /> + + openFolderSettings(folder)} /> + + + dissolveFolder(folder.id)} /> + + )); +} + +function avatar(id: string) { + const channel = ChannelStore.getChannel(id); + if (!channel) return; + const recipient = channel.getRecipientId(); + if (channel.isDM()) return recipient ? UserStore.getUser(recipient)?.getAvatarURL(undefined, 32, false) : undefined; + return IconUtils.getChannelIconURL({ id, icon: channel.icon, size: 32 }); +} + +export function FolderRow({ folder }: { folder: Folder; }) { + const active = new Set(PrivateChannelSortStore.getPrivateChannelIds()); + const ids = folder.channels.filter(id => active.has(id)); + const unread = useStateFromStores([ReadStateStore], () => ids.some(id => ReadStateStore.hasUnread(id))); + const mentions = useStateFromStores([ReadStateStore], () => ids.reduce((sum, id) => sum + ReadStateStore.getMentionCount(id), 0)); + const selected = useStateFromStores([SelectedChannelStore], () => ids.includes(SelectedChannelStore.getChannelId())); + useStateFromStores([UserStore, ChannelStore], () => ids.map(id => avatar(id)).join("|")); + return ( + + ); +} + +export function DragRow({ row, height, children }: React.PropsWithChildren<{ row: Row; height: number; }>) { + const [over, setOver] = useState(null); + const getPlacement = (event: React.DragEvent): Placement | null => { + if (!dragging || dragging.id === row.id || dragging.userId !== accountId() || !event.dataTransfer.types.includes(DRAG_TYPE)) return null; + if (dragging.folder && row.parent) return null; + const rect = event.currentTarget.getBoundingClientRect(); + const ratio = (event.clientY - rect.top) / rect.height; + const placement = ratio < 0.25 ? "before" : ratio > 0.75 ? "after" : "inside"; + if (dragging.folder && placement === "inside") return ratio < 0.5 ? "before" : "after"; + return placement; + }; + return ( +
{ + if ((e.target as HTMLElement).closest("button[aria-label*=Close],input")) { + e.preventDefault(); + return; + } + dragging = { id: row.id, userId: accountId(), folder: !!row.folder }; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData(DRAG_TYPE, row.id); + e.dataTransfer.setData("text/plain", row.folder?.name ?? row.id); + e.dataTransfer.setDragImage(e.currentTarget, 24, height / 2); + // Override native link/image dragging inside Discord's channel row. + e.stopPropagation(); + }} + onDragEnd={() => { clearDrag(); setOver(null); }} + onDragOver={e => { + const placement = getPlacement(e); + setOver(placement); + if (!placement) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + }} + onDragLeave={e => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOver(null); + }} + onDrop={e => { + const placement = getPlacement(e); + const source = dragging; + clearDrag(); + setOver(null); + if (!placement || !source) return; + e.preventDefault(); + e.stopPropagation(); + drop(source.id, row.id, placement); + }} + > + {children} +
+ ); +} diff --git a/src/plugins/flexibleDMs/index.tsx b/src/plugins/flexibleDMs/index.tsx new file mode 100644 index 00000000000..8a23f270132 --- /dev/null +++ b/src/plugins/flexibleDMs/index.tsx @@ -0,0 +1,191 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { migratePluginSettings, useSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { Channel } from "@vencord/discord-types"; +import { Menu, React, ReadStateStore, SelectedChannelStore, useEffect, useMemo, useRef, UserStore, useStateFromStores } from "@webpack/common"; + +import { clearDrag, DragRow, FolderRow } from "./components"; +import { getRows, Row, withoutPinnedChannels } from "./model"; +import { getDMSection, getPinDmsVersion, getPinnedIds, isDMSectionCollapsed, isPinned } from "./pinDms"; +import { settings } from "./settings"; +import { currentRows, drop, editFolder, getLayout, getMessages, removeFromFolder, start, stop, syncPinnedChats } from "./state"; + +migratePluginSettings("FlexibleDMs", "DMFolders"); + +interface DMList { + props: { + privateChannelIds: string[]; + density?: string; + vcDmfRows?: Row[]; + vcDmfVersion?: string; + vcDmfSection?: number; + }; + renderDM(section: number, row: number): React.ReactNode; +} + +const contextMenu: NavContextMenuPatchCallback = (children, { channel }: { channel?: Channel; }) => { + if (!channel || ![1, 3].includes(channel.type) || isPinned(channel.id)) return; + const group = findGroupChildrenByChildId(["close-dm", "leave-channel"], children); + if (!group) return; + const layout = getLayout(); + if (!layout.folders.length) return; + const parent = layout.folders.find(f => f.channels.includes(channel.id)); + group.push( + + {parent && removeFromFolder(channel.id)} />} + {layout.folders.filter(f => f.id !== parent?.id).map(folder => ( + drop(channel.id, folder.id, "inside")} + /> + ))} + + ); +}; + +export default definePlugin({ + name: "FlexibleDMs", + description: "Rearrange DMs and group chats and organize them into collapsible folders.", + authors: [Devs.frisk], + tags: ["Friends", "Organisation"], + searchTerms: ["folders", "reorder", "dm"], + settings, + patches: [ + { + // Folder IDs belong to this list, not ChannelStore. + find: '"dm-quick-launcher"===', + group: true, + replacement: [ + { + // Leave the original prop in place so PinDMs can still patch its filter. + match: /(?<=channels:\i,)privateChannelIds:(\i)[^,]*,listRef:\i,/, + replace: "$&...$self.useRows($1)," + }, + { + match: /renderRow(?:",|=)(\i)=>{/, + replace: "$&const vcDmfRow=$self.renderRow(this,$1);if(vcDmfRow!==undefined)return vcDmfRow;" + }, + { + // The scroller caches rows separately from the outer DM list. + match: /renderRow:this\.renderRow,/, + replace: "renderRow:(...args)=>this.renderRow(...args),vcDmfVersion:this.props.vcDmfVersion," + }, + { + match: /(reportAnalytics=.{0,300}?let\{privateChannelIds:)(\i)(,channels:\i\}=this.props;)/, + replace: "$1$2$3$2=$2.filter(id=>!id.startsWith('dm-folder:'));" + }, + { + match: /scrollToChannel\((\i)\){/, + replace: "$&$1=$self.scrollTarget($1,this.props.vcDmfRows);" + } + ] + }, + { + // Keep Alt+Up/Down consistent with manually ordered chat rows. + find: ".APPLICATION_STORE&&", + replacement: { + match: /\[\.\.\.\i\(\),\.\.\..+?\](?=,)/, + replace: "$self.navigationIds($&)" + } + }, + { + find: "=()=>!1,ensureChatIsVisible:", + replacement: { + match: /\i\.\i\.getPrivateChannelIds\(\)/, + replace: "$self.navigationIds($&)" + } + } + ], + contextMenus: { + "user-context": contextMenu, + "gdm-context": contextMenu + }, + start, + stop() { + stop(); + clearDrag(); + }, + flux: { + LOGOUT: clearDrag, + CONNECTION_OPEN() { + clearDrag(); + syncPinnedChats(); + } + }, + + useRows(ids: string[]) { + useSettings(["plugins.PinDMs.*"]); + const { accounts, keepFoldersOnTop } = settings.use(["accounts", "keepFoldersOnTop"]); + const userId = useStateFromStores([UserStore], () => UserStore.getCurrentUser()?.id); + const selectedId = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getChannelId()); + const messagesKey = useStateFromStores([ReadStateStore], () => ids.map(id => ReadStateStore.lastMessageId(id) ?? "0").join(","), [ids]); + const navigation = `${userId}:${selectedId}`; + const previousNavigation = useRef(undefined); + const reveal = previousNavigation.current !== navigation; + const pinned = getPinnedIds(); + const pinsKey = JSON.stringify([...pinned]); + const layout = withoutPinnedChannels(accounts[userId ?? ""] ?? getLayout(), pinned); + useEffect(syncPinnedChats, [userId, pinsKey]); + // Reveal in the same render as navigation before native scrolling runs. + const visibleLayout = reveal ? { + ...layout, + folders: layout.folders.map(f => f.channels.includes(selectedId) ? { ...f, expanded: true } : f) + } : layout; + useEffect(() => { + previousNavigation.current = navigation; + const parent = getLayout().folders.find(f => f.channels.includes(selectedId)); + if (parent && !parent.expanded) editFolder(parent.id, { expanded: true }); + }, [navigation]); + // Include metadata, not just IDs: rename and color edits must invalidate cached rows. + const version = JSON.stringify([visibleLayout, keepFoldersOnTop, userId, messagesKey, ids, getPinDmsVersion()]); + return useMemo(() => { + const rows = getRows(visibleLayout, ids.filter(id => !pinned.has(id)), getMessages(ids), keepFoldersOnTop); + return { privateChannelIds: rows.map(r => r.id), vcDmfRows: rows, vcDmfVersion: version, vcDmfSection: getDMSection() }; + }, [version]); + }, + + renderRow(instance: DMList, { section, row }: { section: number; row: number; }) { + if (section !== instance.props.vcDmfSection) return; + if (isDMSectionCollapsed()) return null; + const id = instance.props.privateChannelIds[row]; + if (!id) return; + // Use the same snapshot as the list IDs, including navigation's reveal. + const data = instance.props.vcDmfRows?.[row]; + if (!data || data.id !== id) return; + const { folder } = data; + const height = instance.props.density === "compact" ? 40 : instance.props.density === "default" || !instance.props.density ? 44 : 50; + return ( + + + {folder ? : instance.renderDM(section, row)} + + + ); + }, + + scrollTarget(id: string | null, rows?: Row[]) { + if (!id) return id; + if (rows?.some(r => r.id === id)) return id; + return rows?.find(r => r.folder?.channels.includes(id))?.id ?? id; + }, + + navigationIds(original: string[]) { + const allowed = new Set(original); + const pinned = getPinnedIds(); + // Static destinations and PinDMs categories already have their own order. + const prefix = original.filter(id => pinned.has(id) || id.startsWith("/")); + return [...prefix, ...currentRows().filter(r => !r.folder && allowed.has(r.id)).map(r => r.id)]; + } +}); diff --git a/src/plugins/flexibleDMs/model.ts b/src/plugins/flexibleDMs/model.ts new file mode 100644 index 00000000000..892d51e29b2 --- /dev/null +++ b/src/plugins/flexibleDMs/model.ts @@ -0,0 +1,120 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface Folder { + id: string; + name: string; + color: string; + channels: string[]; + expanded: boolean; + naturalOrder?: boolean; +} + +export interface Layout { + folders: Folder[]; + order: string[]; + seen: Record; +} + +export interface Row { + id: string; + folder?: Folder; + parent?: Folder; +} + +export type Placement = "before" | "inside" | "after"; +export const emptyLayout = (): Layout => ({ folders: [], order: [], seen: {} }); + +function compareIds(a = "0", b = "0") { + return a.length - b.length || a.localeCompare(b); +} + +/** Keep closed DMs in saved membership, but only render channels Discord lists. */ +export function getRows(layout: Layout, activeIds: string[], messages: Record, keepOnTop: boolean): Row[] { + const active = new Set(activeIds); + const folders = layout.folders.filter(f => f.channels.some(id => active.has(id))); + const parents = new Map(folders.flatMap(f => f.channels.map(id => [id, f] as const))); + const byId = new Map(folders.map(f => [f.id, f])); + const natural = [...new Set(activeIds.map(id => parents.get(id)?.id ?? id))]; + const valid = new Set(natural); + const saved = layout.order.filter(id => valid.has(id)); + const savedSet = new Set(saved); + const order = [...natural.filter(id => !savedSet.has(id)), ...saved]; + const activity = new Map(); + for (const id of activeIds) { + const last = messages[id]; + if (!last || compareIds(last, layout.seen[id]) <= 0) continue; + const key = parents.get(id)?.id ?? id; + if (compareIds(last, activity.get(key)) > 0) activity.set(key, last); + } + order.sort((a, b) => { + if (keepOnTop && byId.has(a) !== byId.has(b)) return byId.has(a) ? -1 : 1; + return compareIds(activity.get(b), activity.get(a)); + }); + return order.flatMap(id => { + const folder = byId.get(id); + if (!folder) return [{ id }]; + return [{ id, folder }, ...(folder.expanded ? (folder.naturalOrder ? activeIds.filter(id => folder.channels.includes(id)) : folder.channels.filter(c => active.has(c))).map(id => ({ id, parent: folder })) : [])]; + }); +} + +export function move(layout: Layout, rows: Row[], sourceId: string, targetId: string, placement: Placement, newId: string): Layout { + const next: Layout = JSON.parse(JSON.stringify(layout)); + const source = rows.find(r => r.id === sourceId); + const target = rows.find(r => r.id === targetId); + if (!source || !target || sourceId === targetId) return next; + if (source.folder && (placement === "inside" || target.parent)) return next; + if (target.folder && target.folder.id === source.parent?.id && placement === "inside") return next; + + next.order = rows.filter(r => !r.parent).map(r => r.id); + const folder = (id: string) => next.folders.find(f => f.id === id)!; + const detach = () => { + for (const f of next.folders) f.channels = f.channels.filter(id => id !== sourceId); + next.order = next.order.filter(id => id !== sourceId); + }; + const insert = (ids: string[], target: string, id: string, after: boolean) => { + ids.splice(ids.indexOf(target) + (after ? 1 : 0), 0, id); + }; + + detach(); + if (placement === "inside") { + const parent = target.folder ?? target.parent; + if (parent) folder(parent.id).channels.push(sourceId); + else { + const created: Folder = { id: newId, name: "New Folder", color: "#3ba55c", channels: [targetId, sourceId], expanded: false }; + next.folders.push(created); + next.order.splice(next.order.indexOf(targetId), 1, newId); + } + } else if (target.parent) { + const parent = folder(target.parent.id); + if (parent.naturalOrder) { + const visible = rows.filter(r => r.parent?.id === parent.id && r.id !== sourceId).map(r => r.id); + parent.channels = [...visible, ...parent.channels.filter(id => !visible.includes(id))]; + parent.naturalOrder = false; + } + insert(parent.channels, targetId, sourceId, placement === "after"); + } else { + insert(next.order, targetId, sourceId, placement === "after"); + } + next.folders = next.folders.filter(f => f.channels.length > 0); + const removed = new Set(layout.folders.filter(f => !next.folders.some(n => n.id === f.id)).map(f => f.id)); + next.order = next.order.filter(id => !removed.has(id)); + return next; +} + +export function withoutPinnedChannels(layout: Layout, pinned: Set): Layout { + if (!layout.folders.some(folder => folder.channels.some(id => pinned.has(id)))) return layout; + + const folders = layout.folders + .map(folder => ({ ...folder, channels: folder.channels.filter(id => !pinned.has(id)) })) + .filter(folder => folder.channels.length > 0); + const removed = new Set(layout.folders.filter(folder => !folders.some(f => f.id === folder.id)).map(folder => folder.id)); + return { ...layout, folders, order: layout.order.filter(id => !pinned.has(id) && !removed.has(id)) }; +} + +export function resetLayoutOrder(layout: Layout): Layout { + return { ...layout, order: [], seen: {}, folders: layout.folders.map(folder => ({ ...folder, naturalOrder: true })) }; +} diff --git a/src/plugins/flexibleDMs/pinDms.ts b/src/plugins/flexibleDMs/pinDms.ts new file mode 100644 index 00000000000..14cb3607350 --- /dev/null +++ b/src/plugins/flexibleDMs/pinDms.ts @@ -0,0 +1,26 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { isPluginEnabled } from "@api/PluginManager"; +import { settings } from "@plugins/pinDms"; +import { categoryLen } from "@plugins/pinDms/data"; +import { UserStore } from "@webpack/common"; + +export function getPinnedIds(): Set { + const userId = UserStore.getCurrentUser()?.id; + if (!isPluginEnabled("PinDMs") || !userId) return new Set(); + return new Set(settings.store.userBasedCategoryList[userId]?.flatMap(category => category.channels)); +} + +export const isPinned = (id: string) => getPinnedIds().has(id); +export const getDMSection = () => 1 + (isPluginEnabled("PinDMs") ? categoryLen() : 0); +export const isDMSectionCollapsed = () => isPluginEnabled("PinDMs") && settings.store.canCollapseDmSection && settings.store.dmSectionCollapsed; + +export function getPinDmsVersion() { + if (!isPluginEnabled("PinDMs")) return ""; + const { userBasedCategoryList, pinOrder, canCollapseDmSection, dmSectionCollapsed } = settings.store; + return JSON.stringify([userBasedCategoryList[UserStore.getCurrentUser()?.id ?? ""], pinOrder, canCollapseDmSection, dmSectionCollapsed]); +} diff --git a/src/plugins/flexibleDMs/settings.tsx b/src/plugins/flexibleDMs/settings.tsx new file mode 100644 index 00000000000..30a86a6e8b2 --- /dev/null +++ b/src/plugins/flexibleDMs/settings.tsx @@ -0,0 +1,36 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; +import { Button } from "@webpack/common"; + +import { Layout } from "./model"; +import { resetOrder } from "./state"; + +export const settings = definePluginSettings({ + keepFoldersOnTop: { + type: OptionType.BOOLEAN, + displayName: "Always keep folders on top", + description: "Keep folders above unpinned DMs, including chats with new messages. PinDMs categories always stay above folders.", + default: false + }, + persistence: { + type: OptionType.BOOLEAN, + displayName: "Persistence", + description: "Keep manually arranged chat order after restarting Discord.", + default: false + }, + resetOrder: { + type: OptionType.COMPONENT, + component: () => + }, + accounts: { + type: OptionType.CUSTOM, + default: {} as Record, + hidden: true + } +}); diff --git a/src/plugins/flexibleDMs/state.ts b/src/plugins/flexibleDMs/state.ts new file mode 100644 index 00000000000..c07e4406054 --- /dev/null +++ b/src/plugins/flexibleDMs/state.ts @@ -0,0 +1,99 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { SettingsStore } from "@api/Settings"; +import { findStoreLazy } from "@webpack"; +import { ReadStateStore, UserStore } from "@webpack/common"; + +import { emptyLayout, Folder, getRows, Layout, move, Placement, resetLayoutOrder, withoutPinnedChannels } from "./model"; +import { getPinnedIds, isPinned } from "./pinDms"; +import { settings } from "./settings"; + +export const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds(): string[]; }; +export const accountId = () => UserStore.getCurrentUser()?.id; +export const getLayout = (): Layout => settings.store.accounts[accountId() ?? ""] ?? emptyLayout(); +export const getMessages = (ids: string[]) => Object.fromEntries(ids.map(id => [id, ReadStateStore.lastMessageId(id) ?? "0"])); + +export function currentRows() { + const pinned = getPinnedIds(); + const ids = PrivateChannelSortStore.getPrivateChannelIds().filter(id => !pinned.has(id)); + return getRows(getLayout(), ids, getMessages(ids), settings.store.keepFoldersOnTop); +} + +export function save(layout: Layout, userId = accountId()) { + if (!userId || userId !== accountId()) return; + settings.store.accounts = JSON.parse(JSON.stringify({ ...settings.store.accounts, [userId]: layout })); +} + +export function editFolder(id: string, update: Partial>, userId = accountId()) { + const layout = getLayout(); + save({ ...layout, folders: layout.folders.map(f => f.id === id ? { ...f, ...update } : f) }, userId); +} + +export function closeAll() { + const layout = getLayout(); + save({ ...layout, folders: layout.folders.map(f => ({ ...f, expanded: false })) }); +} + +export function drop(source: string, target: string, placement: Placement) { + if (isPinned(source) || isPinned(target)) return; + const rows = currentRows(); + if (!rows.some(r => r.id === target)) { + const folder = getLayout().folders.find(f => f.id === target); + if (folder) rows.push({ id: target, folder }); + } + if (!rows.some(r => r.id === source)) { + const parent = getLayout().folders.find(f => f.channels.includes(source)); + if (parent && PrivateChannelSortStore.getPrivateChannelIds().includes(source)) rows.push({ id: source, parent }); + } + const layout = move(getLayout(), rows, source, target, placement, `dm-folder:${crypto.randomUUID()}`); + layout.seen = getMessages(PrivateChannelSortStore.getPrivateChannelIds()); + save(layout); +} + +export function removeFromFolder(channelId: string) { + const rows = currentRows(); + const parent = rows.find(r => r.id === channelId)?.parent ?? getLayout().folders.find(f => f.channels.includes(channelId)); + if (!parent) return; + // A collapsed child is not in rows, so expose it only to the move operation. + if (!rows.some(r => r.id === channelId)) rows.push({ id: channelId, parent }); + const layout = move(getLayout(), rows, channelId, parent.id, "after", ""); + layout.seen = getMessages(PrivateChannelSortStore.getPrivateChannelIds()); + save(layout); +} + +export function dissolveFolder(id: string) { + const layout = getLayout(); + const folder = layout.folders.find(f => f.id === id); + if (!folder) return; + const order = currentRows().filter(r => !r.parent).flatMap(r => r.id === id ? folder.channels : [r.id]); + save({ ...layout, folders: layout.folders.filter(f => f.id !== id), order, seen: getMessages(PrivateChannelSortStore.getPrivateChannelIds()) }); +} + +export function syncPinnedChats() { + const layout = getLayout(); + const next = withoutPinnedChannels(layout, getPinnedIds()); + if (next !== layout) save(next); +} + +export function resetOrder() { + save(resetLayoutOrder(getLayout())); +} + +export function start() { + if (!settings.store.persistence) { + // Reset every account, including ones that are not logged in yet. + settings.store.accounts = JSON.parse(JSON.stringify(Object.fromEntries( + Object.entries(settings.store.accounts).map(([id, layout]) => [id, resetLayoutOrder(layout)]) + ))); + } + SettingsStore.addChangeListener("plugins.PinDMs.userBasedCategoryList", syncPinnedChats); + syncPinnedChats(); +} + +export function stop() { + SettingsStore.removeChangeListener("plugins.PinDMs.userBasedCategoryList", syncPinnedChats); +} diff --git a/src/plugins/flexibleDMs/style.css b/src/plugins/flexibleDMs/style.css new file mode 100644 index 00000000000..b6d343c2443 --- /dev/null +++ b/src/plugins/flexibleDMs/style.css @@ -0,0 +1,252 @@ +.vc-dmf-row { + position: relative; + box-sizing: border-box; + flex-shrink: 0; +} + +.vc-dmf-folder { + display: flex; + align-items: center; + gap: 12px; + width: calc(100% - 16px); + height: calc(100% - 2px); + margin: 1px 8px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-secondary, #b5bac1); + font-family: var(--font-primary); + text-align: left; + cursor: pointer; +} + +.vc-dmf-folder:hover { + background: var(--background-modifier-hover, var(--background-mod-subtle, rgb(255 255 255 / 6%))); + color: var(--text-primary, #f2f3f5); +} + +.vc-dmf-folder:focus-visible { + outline: 2px solid var(--focus-primary); + outline-offset: -2px; +} + +.vc-dmf-selected { + background: var(--background-modifier-selected, var(--background-mod-subtle, rgb(255 255 255 / 8%))); +} + +.vc-dmf-unread::before { + content: ""; + position: absolute; + left: 2px; + width: 4px; + height: 8px; + border-radius: 4px; + background: var(--text-primary, #f2f3f5); +} + +.vc-dmf-icon { + display: grid; + place-items: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + border-radius: 10px; + background: color-mix(in srgb, var(--vc-dmf-color) 22%, transparent); + color: var(--vc-dmf-color); +} + +.vc-dmf-mosaic { + display: grid; + grid-template-columns: repeat(2, 12px); + grid-template-rows: repeat(2, 12px); + gap: 2px; +} + +.vc-dmf-mosaic img, +.vc-dmf-avatar-fallback { + width: 12px; + height: 12px; + border-radius: 50%; + object-fit: cover; +} + +.vc-dmf-avatar-fallback { + display: grid; + place-items: center; + background: var(--vc-dmf-color); + color: white; + font-size: 10px; +} + +.vc-dmf-nameplate { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + gap: 1px; +} + +.vc-dmf-name { + overflow: hidden; + font-size: 16px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vc-dmf-unread .vc-dmf-name { + color: var(--text-primary, #f2f3f5); + font-weight: 600; +} + +.vc-dmf-description { + color: var(--text-muted); + font-size: 12px; + line-height: 14px; +} + +.vc-dmf-chevron { + flex-shrink: 0; + transition: transform 150ms ease; +} + +.vc-dmf-chevron-open { + transform: rotate(90deg); +} + +.vc-dmf-badge { + border-radius: 8px; + padding: 1px 5px; + background: var(--status-danger); + color: white; + font-size: 12px; + font-weight: 600; +} + +.vc-dmf-child { + animation: vc-dmf-reveal 150ms ease-out; + margin-left: 16px; + border-left: 2px solid color-mix(in srgb, var(--vc-dmf-color) 50%, transparent); + background: color-mix(in srgb, var(--vc-dmf-color) 6%, transparent); +} + +.vc-dmf-row[data-dmf-drop="inside"] { + border-radius: 8px; + outline: 2px solid var(--brand-500); + outline-offset: -3px; + background: var(--background-modifier-selected, var(--background-mod-subtle, rgb(255 255 255 / 8%))); +} + +.vc-dmf-row[data-dmf-drop="before"]::before, +.vc-dmf-row[data-dmf-drop="after"]::after { + content: ""; + position: absolute; + z-index: 2; + right: 8px; + left: 8px; + height: 3px; + border-radius: 2px; + background: var(--brand-500); + pointer-events: none; +} + +.vc-dmf-row[data-dmf-drop="before"]::before { + top: 0; +} + +.vc-dmf-row[data-dmf-drop="after"]::after { + bottom: 0; +} + +.vc-dmf-settings { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 0; + color: var(--text-primary); +} + +@keyframes vc-dmf-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + + +@media (prefers-reduced-motion: reduce) { + .vc-dmf-child { + animation: none; + } + + .vc-dmf-chevron { + transition: none; + } +} + +.vc-dmf-colors { + display: flex; + flex-direction: column; + gap: 10px; +} + +.vc-dmf-color-buttons { + display: flex; + gap: 10px; +} + +.vc-dmf-color-large, +.vc-dmf-swatch { + display: grid; + place-items: center; + padding: 0; + border: 0; + color: white; + cursor: pointer; +} + +.vc-dmf-color-large { + width: 70px; + height: 50px; + border-radius: 8px; +} + +.vc-dmf-custom-color { + position: relative; +} + +.vc-dmf-custom-color input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + + +.vc-dmf-swatches { + display: grid; + grid-template-columns: repeat(10, 20px); + gap: 10px; +} + +.vc-dmf-swatch { + width: 20px; + height: 20px; + border-radius: 8px; +} + +.vc-dmf-custom-color:focus-within, +.vc-dmf-color-large:focus-visible, +.vc-dmf-swatch:focus-visible { + outline: 2px solid var(--focus-primary, #00a8fc); + outline-offset: 2px; +} diff --git a/src/plugins/pinDms/data.ts b/src/plugins/pinDms/data.ts index be268ab7297..bb0f134bbff 100644 --- a/src/plugins/pinDms/data.ts +++ b/src/plugins/pinDms/data.ts @@ -23,7 +23,9 @@ export async function init() { const userId = UserStore.getCurrentUser()?.id; if (userId == null) return; - currentUserCategories = settings.store.userBasedCategoryList[userId] ??= []; + settings.store.userBasedCategoryList[userId] ??= []; + // Read it back through the proxy so a new account gets settings notifications too. + currentUserCategories = settings.store.userBasedCategoryList[userId]; forceUpdateDms?.(); } diff --git a/src/plugins/pinDms/index.tsx b/src/plugins/pinDms/index.tsx index 624d9ded5b7..a5bd6a73f63 100644 --- a/src/plugins/pinDms/index.tsx +++ b/src/plugins/pinDms/index.tsx @@ -84,7 +84,7 @@ export default definePlugin({ }, { // Insert the pinned channels to sections - match: /(?<=renderRow:this\.renderRow,)sections:\[.+?1\)]/, + match: /(?<=,)sections:\[\i,Math\.max\(\i\.length,1\)\]/, replace: "...$self.makeProps(this,{$&})" }, @@ -142,7 +142,7 @@ export default definePlugin({ find: ".APPLICATION_STORE&&", replacement: { // channelIds = __OVERLAY__ ? stuff : [...getStaticPaths(),...channelIds)] - match: /(?<=\i=__OVERLAY__\?\i:\[\.\.\.\i\(\),\.\.\.)\i/, + match: /(?<=\[\.\.\.\i\(\),\.\.\.)\i/, // ....concat(pins).concat(toArray(channelIds).filter(c => !isPinned(c))) replace: "$self.getAllUncollapsedChannels().concat($&.filter(c=>!$self.isPinned(c)))" } @@ -152,7 +152,7 @@ export default definePlugin({ { find: "=()=>!1,ensureChatIsVisible:", replacement: { - match: /(?<=\i===\i\.ME\?)\i\.\i\.getPrivateChannelIds\(\)/, + match: /\i\.\i\.getPrivateChannelIds\(\)/, replace: "$self.getAllUncollapsedChannels().concat($&.filter(c=>!$self.isPinned(c)))" } }, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 024acfd3089..1243f41b996 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -666,6 +666,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "yuna0x0", id: 213656926414831616n }, + frisk: { + name: "frisk", + id: 337643611741224961n + }, Davri: { name: "Davri", id: 457579346282938368n From f666241127419d8f8265cabff2d497d87e868f0c Mon Sep 17 00:00:00 2001 From: b-lacksoul Date: Tue, 8 Sep 2026 10:45:41 +0700 Subject: [PATCH 2/2] fix(FlexibleDMs): preserve native pins and folder state --- src/plugins/flexibleDMs/animation.ts | 20 ++++++++ src/plugins/flexibleDMs/components.tsx | 30 +++++++----- src/plugins/flexibleDMs/index.tsx | 64 +++++++++++++++++++------- src/plugins/flexibleDMs/model.ts | 17 +++++-- src/plugins/flexibleDMs/pinDms.ts | 15 +++--- src/plugins/flexibleDMs/state.ts | 25 +++++++--- src/plugins/flexibleDMs/style.css | 7 ++- src/plugins/pinDms/index.tsx | 11 +++-- 8 files changed, 138 insertions(+), 51 deletions(-) create mode 100644 src/plugins/flexibleDMs/animation.ts diff --git a/src/plugins/flexibleDMs/animation.ts b/src/plugins/flexibleDMs/animation.ts new file mode 100644 index 00000000000..fc94e35c532 --- /dev/null +++ b/src/plugins/flexibleDMs/animation.ts @@ -0,0 +1,20 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +const opening = new Set(); +const timers = new Map>(); + +/** Mark an explicitly opened folder so only its newly mounted children animate. */ +export function markFolderOpening(id: string) { + opening.add(id); + clearTimeout(timers.get(id)); + timers.set(id, setTimeout(() => { + opening.delete(id); + timers.delete(id); + }, 200)); +} + +export const isFolderOpening = (id: string | undefined) => !!id && opening.has(id); diff --git a/src/plugins/flexibleDMs/components.tsx b/src/plugins/flexibleDMs/components.tsx index c87383c5ae8..e68eebbf894 100644 --- a/src/plugins/flexibleDMs/components.tsx +++ b/src/plugins/flexibleDMs/components.tsx @@ -10,8 +10,9 @@ import { classes } from "@utils/misc"; import { RenderModalProps } from "@vencord/discord-types"; import { ChannelStore, ContextMenuApi, FluxDispatcher, IconUtils, Menu, Modal, openModal, React, ReadStateStore, SelectedChannelStore, TextInput, UserStore, useState, useStateFromStores } from "@webpack/common"; -import { Folder, Placement, Row } from "./model"; -import { accountId, closeAll, dissolveFolder, drop, editFolder, PrivateChannelSortStore } from "./state"; +import { isFolderOpening, markFolderOpening } from "./animation"; +import { DEFAULT_FOLDER_COLOR, Folder, Placement, Row } from "./model"; +import { accountId, closeAll, dissolveFolder, drop, editFolder, getNativePinnedIds, PrivateChannelSortStore } from "./state"; const DRAG_TYPE = "application/x-vencord-dm-folder"; let dragging: { id: string; userId: string | undefined; folder: boolean; } | undefined; @@ -20,7 +21,7 @@ export function clearDrag() { dragging = undefined; } -const DEFAULT_COLOR = 0x5865f2; +const DEFAULT_COLOR = parseInt(DEFAULT_FOLDER_COLOR.slice(1), 16); const SWATCHES = [ 1752220, 3066993, 3447003, 10181046, 15277667, 15844367, 15105570, 15158332, 9807270, 6323595, 1146986, 2067276, 2123412, 7419530, 11342935, 12745742, 11027200, 10038562, 9936031, 5533306 @@ -39,7 +40,7 @@ function FolderColorPicker({ color, onChange }: { color: number; onChange(color: