diff --git a/src/plugins/consoleShortcuts.ts b/src/plugins/consoleShortcuts.ts index 0489c2c..24ab740 100644 --- a/src/plugins/consoleShortcuts.ts +++ b/src/plugins/consoleShortcuts.ts @@ -21,7 +21,8 @@ import { relaunch } from "@utils/native"; import definePlugin from "@utils/types"; import * as Webpack from "@webpack"; import { extract, filters, findAll, search } from "@webpack"; -import { React } from "@webpack/common"; +import { React, ReactDOM } from "@webpack/common"; +import type { ComponentType } from "react"; const WEB_ONLY = (f: string) => () => { throw new Error(`'${f}' is Discord Desktop only.`); @@ -59,6 +60,7 @@ export default definePlugin({ }; } + let fakeRenderWin: WeakRef | undefined; return { wp: Vencord.Webpack, wpc: Webpack.wreq.c, @@ -79,7 +81,15 @@ export default definePlugin({ Settings: Vencord.Settings, Api: Vencord.Api, reload: () => location.reload(), - restart: IS_WEB ? WEB_ONLY("restart") : relaunch + restart: IS_WEB ? WEB_ONLY("restart") : relaunch, + fakeRender: (component: ComponentType, props: any) => { + const prevWin = fakeRenderWin?.deref(); + const win = prevWin?.closed === false ? prevWin : window.open("about:blank", "Fake Render", "popup,width=500,height=500")!; + fakeRenderWin = new WeakRef(win); + win.focus(); + + ReactDOM.render(React.createElement(component, props), win.document.body); + } }; }, diff --git a/src/plugins/pinDms/contextMenus.tsx b/src/plugins/pinDms/contextMenus.tsx new file mode 100644 index 0000000..b04ba8c --- /dev/null +++ b/src/plugins/pinDms/contextMenus.tsx @@ -0,0 +1,74 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 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 . +*/ + +import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { Menu } from "@webpack/common"; + +import { isPinned, movePin, snapshotArray, togglePin } from "./settings"; + +function PinMenuItem(channelId: string) { + const pinned = isPinned(channelId); + + return ( + <> + togglePin(channelId)} + /> + {pinned && snapshotArray[0] !== channelId && ( + movePin(channelId, -1)} + /> + )} + {pinned && snapshotArray[snapshotArray.length - 1] !== channelId && ( + movePin(channelId, +1)} + /> + )} + + ); +} + +const GroupDMContext: NavContextMenuPatchCallback = (children, props) => { + const container = findGroupChildrenByChildId("leave-channel", children); + if (container) + container.unshift(PinMenuItem(props.channel.id)); +}; + +const UserContext: NavContextMenuPatchCallback = (children, props) => { + const container = findGroupChildrenByChildId("close-dm", children); + if (container) { + const idx = container.findIndex(c => c?.props?.id === "close-dm"); + container.splice(idx, 0, PinMenuItem(props.channel.id)); + } +}; + +export function addContextMenus() { + addContextMenuPatch("gdm-context", GroupDMContext); + addContextMenuPatch("user-context", UserContext); +} + +export function removeContextMenus() { + removeContextMenuPatch("gdm-context", GroupDMContext); + removeContextMenuPatch("user-context", UserContext); +} diff --git a/src/plugins/pinDms/index.tsx b/src/plugins/pinDms/index.tsx new file mode 100644 index 0000000..1cc6289 --- /dev/null +++ b/src/plugins/pinDms/index.tsx @@ -0,0 +1,127 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 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 . +*/ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { Channel } from "discord-types/general"; + +import { addContextMenus, removeContextMenus } from "./contextMenus"; +import { getPinAt, isPinned, snapshotArray, usePinnedDms } from "./settings"; + +export default definePlugin({ + name: "PinDMs", + description: "Allows you to pin private channels to the top of your DM list. To pin/unpin or reorder pins, right click DMs", + authors: [Devs.Ven, Devs.Strencher], + + dependencies: ["ContextMenuAPI"], + + start: addContextMenus, + stop: removeContextMenus, + + usePinCount(channelIds: string[]) { + const pinnedDms = usePinnedDms(); + // See comment on 2nd patch for reasoning + return channelIds.length ? [pinnedDms.size] : []; + }, + + getChannel(channels: Record, idx: number) { + return channels[getPinAt(idx)]; + }, + + isPinned, + getSnapshot: () => snapshotArray, + + getScrollOffset(channelId: string, rowHeight: number, padding: number, preRenderedChildren: number, originalOffset: number) { + if (!isPinned(channelId)) + return ( + (rowHeight + padding) * 2 // header + + rowHeight * snapshotArray.length // pins + + originalOffset // original pin offset minus pins + ); + + return rowHeight * (snapshotArray.indexOf(channelId) + preRenderedChildren) + padding; + }, + + patches: [ + // Patch DM list + { + find: ".privateChannelsHeaderContainer,", + replacement: [ + { + // filter Discord's privateChannelIds list to remove pins, and pass + // pinCount as prop. This needs to be here so that the entire DM list receives + // updates on pin/unpin + match: /privateChannelIds:(\i),/, + replace: "privateChannelIds:$1.filter(c=>!$self.isPinned(c)),pinCount:$self.usePinCount($1)," + }, + { + // sections is an array of numbers, where each element is a section and + // the number is the amount of rows. Add our pinCount in second place + // - Section 1: buttons for pages like Friends & Library + // - Section 2: our pinned dms + // - Section 3: the normal dm list + match: /(?<=renderRow:(\i)\.renderRow,)sections:\[\i,/, + // For some reason, adding our sections when no private channels are ready yet + // makes DMs infinitely load. Thus usePinCount returns either a single element + // array with the count, or an empty array. Due to spreading, only in the former + // case will an element be added to the outer array + // Thanks for the fix, Strencher! + replace: "$&...$1.props.pinCount," + }, + { + // Patch renderSection (renders the header) to set the text to "Pinned DMs" instead of "Direct Messages" + // lookbehind is used to lookup parameter name. We could use arguments[0], but + // if children ever is wrapped in an iife, it will break + match: /children:(\i\.\i\.Messages.DIRECT_MESSAGES)(?<=renderSection=function\((\i)\).+?)/, + replace: "children:$2.section===1?'Pinned DMs':$1" + }, + { + // Patch channel lookup inside renderDM + // channel=channels[channelIds[row]]; + match: /(?<=preRenderedChildren,(\i)=)((\i)\[\i\[\i\]\]);/, + // section 1 is us, manually get our own channel + // section === 1 ? getChannel(channels, row) : channels[channelIds[row]]; + replace: "arguments[0]===1?$self.getChannel($3,arguments[1]):$2;" + }, + { + // Fix getRowHeight's check for whether this is the DMs section + // section === DMS + match: /===\i.DMS&&0/, + // section -1 === DMS + replace: "-1$&" + }, + { + // Override scrollToChannel to properly account for pinned channels + match: /(?<=else\{\i\+=)(\i)\*\(.+?(?=;)/, + replace: "$self.getScrollOffset(arguments[0],$1,this.props.padding,this.state.preRenderedChildren,$&)" + } + ] + }, + + // Fix Alt Up/Down navigation + { + find: '"mod+alt+right"', + replacement: { + // channelIds = __OVERLAY__ ? stuff : toArray(getStaticPaths()).concat(toArray(channelIds)) + match: /(?<=(\i)=__OVERLAY__\?\i:.{0,10})\.concat\((.{0,10})\)/, + // ....concat(pins).concat(toArray(channelIds).filter(c => !isPinned(c))) + replace: ".concat($self.getSnapshot()).concat($2.filter(c=>!$self.isPinned(c)))" + } + } + ] +}); diff --git a/src/plugins/pinDms/settings.ts b/src/plugins/pinDms/settings.ts new file mode 100644 index 0000000..2526c69 --- /dev/null +++ b/src/plugins/pinDms/settings.ts @@ -0,0 +1,67 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 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 . +*/ + +import { Settings, useSettings } from "@api/settings"; + +export let snapshotArray: string[]; +let snapshot: Set | undefined; + +const getArray = () => (Settings.plugins.PinDMs.pinnedDMs || void 0)?.split(",") as string[] | undefined; +const save = (pins: string[]) => { + snapshot = void 0; + Settings.plugins.PinDMs.pinnedDMs = pins.join(","); +}; +const takeSnapshot = () => { + snapshotArray = getArray() ?? []; + return snapshot = new Set(snapshotArray); +}; +const requireSnapshot = () => snapshot ?? takeSnapshot(); + +export function usePinnedDms() { + useSettings(["plugins.PinDMs.pinnedDMs"]); + + return requireSnapshot(); +} + +export function isPinned(id: string) { + return requireSnapshot().has(id); +} + +export function togglePin(id: string) { + const snapshot = requireSnapshot(); + if (!snapshot.delete(id)) { + snapshot.add(id); + } + + save([...snapshot]); +} + +export function getPinAt(idx: number) { + requireSnapshot(); + return snapshotArray[idx]; +} + +export function movePin(id: string, direction: -1 | 1) { + const pins = getArray()!; + const a = pins.indexOf(id); + const b = a + direction; + + [pins[a], pins[b]] = [pins[b], pins[a]]; + + save(pins); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a594993..99da615 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -257,5 +257,9 @@ export const Devs = /* #__PURE__*/ Object.freeze({ pylix: { name: "pylix", id: 492949202121261067n + }, + Strencher: { + name: "Strencher", + id: 415849376598982656n } });