Plugin

PinDMs

Allows you to pin private channels to the top of your DM list. To pin/unpin or re-order pins, right click DMs

Friends Organisation
index.tsx
Download

Source

src/plugins/pinDms/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import "./styles.css";
8
9import { definePluginSettings } from "@api/Settings";
10import ErrorBoundary from "@components/ErrorBoundary";
11import { Devs } from "@utils/constants";
12import { classes } from "@utils/misc";
13import definePlugin, { OptionType, StartAt } from "@utils/types";
14import { Channel } from "@vencord/discord-types";
15import { findCssClassesLazy, findStoreLazy } from "@webpack";
16import { Clickable, ContextMenuApi, FluxDispatcher, Menu, React } from "@webpack/common";
17
18import { contextMenus } from "./components/contextMenu";
19import { openCategoryModal, requireSettingsModal } from "./components/CreateCategoryModal";
20import { DEFAULT_CHUNK_SIZE } from "./constants";
21import { canMoveCategory, canMoveCategoryInDirection, Category, categoryLen, collapseCategory, getAllUncollapsedChannels, getCategoryByIndex, getSections, init, isPinned, moveCategory, removeCategory, usePinnedDms } from "./data";
22
23interface ChannelComponentProps {
24 children: React.ReactNode,
25 channel: Channel,
26 selected: boolean;
27}
28
29const headerClasses = findCssClassesLazy("privateChannelsHeaderContainer", "headerText");
30
31export const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; };
32
33export let instance: any;
34
35export const enum PinOrder {
36 LastMessage,
37 Custom
38}
39
40export const settings = definePluginSettings({
41 pinOrder: {
42 type: OptionType.SELECT,
43 description: "Which order should pinned DMs be displayed in?",
44 options: [
45 { label: "Most recent message", value: PinOrder.LastMessage, default: true },
46 { label: "Custom (right click channels to reorder)", value: PinOrder.Custom }
47 ]
48 },
49 canCollapseDmSection: {
50 type: OptionType.BOOLEAN,
51 displayName: "Can Collapse DM Section",
52 description: "Allow uncategorised DMs section to be collapsable",
53 default: false
54 },
55 dmSectionCollapsed: {
56 type: OptionType.BOOLEAN,
57 displayName: "DM Section Collapsed",
58 description: "Collapse DM section",
59 default: false,
60 hidden: true
61 },
62 userBasedCategoryList: {
63 type: OptionType.CUSTOM,
64 default: {} as Record<string, Category[]>
65 }
66});
67
68export default definePlugin({
69 name: "PinDMs",
70 description: "Allows you to pin private channels to the top of your DM list. To pin/unpin or re-order pins, right click DMs",
71 tags: ["Friends", "Organisation"],
72 authors: [Devs.Ven, Devs.Aria],
73 settings,
74 contextMenus,
75
76 patches: [
77 {
78 find: &#039;"dm-quick-launcher"===&#039;,
79 replacement: [
80 {
81 // Filter out pinned channels from the private channel list
82 match: /(?<=channels:\i,)privateChannelIds:(\i)(?=,listRef:)/,
83 replace: "privateChannelIds:$1.filter(c=>!$self.isPinned(c))"
84 },
85 {
86 // Insert the pinned channels to sections
87 match: /(?<=renderRow:this\.renderRow,)sections:\[.+?1\)]/,
88 replace: "...$self.makeProps(this,{$&})"
89 },
90
91 // Rendering
92 {
93 match: /renderRow(?:",|=)(\i)=>{(?<=renderDM(?:",|=).+?(\i\.\i),\{channel:.+?)/,
94 replace: "$&if($self.isChannelIndex($1.section, $1.row))return $self.renderChannel($1.section,$1.row,$2)();"
95 },
96 {
97 match: /renderSection(?:",|=)(\i)=>{/,
98 replace: "$&if($self.isCategoryIndex($1.section))return $self.renderCategory($1);"
99 },
100 {
101 match: /renderSection(?:",|=).{0,300}?"span",{/,
102 replace: "$&...$self.makeSpanProps(),"
103 },
104
105 // Fix Row Height
106 {
107 match: /(\.startsWith\("section-divider"\).+?return 1===)(\i)/,
108 replace: "$1($2-$self.categoryLen())"
109 },
110 {
111 match: /getRowHeight(?:",|=)\((\i),(\i)\)=>{/,
112 replace: "$&if($self.isChannelHidden($1,$2))return 0;"
113 },
114
115 // Fix ScrollTo
116 {
117 // Override scrollToChannel to properly account for pinned channels
118 match: /(?<=scrollTo\(\{to:\i\}\):\(\i\+=)(\d+)\*\(.+?(?=,)/,
119 replace: "$self.getScrollOffset(arguments[0],$1,this?.props?.padding,this?.state?.preRenderedChildren,$&)"
120 },
121 {
122 match: /(scrollToChannel\(\i\){.{1,300})(this\.props\.privateChannelIds)/,
123 replace: "$1[...$2,...$self.getAllUncollapsedChannels()]"
124 },
125
126 ]
127 },
128
129
130 // forceUpdate moment
131 // https://regex101.com/r/kDN9fO/1
132 {
133 find: ".FRIENDS},\"friends\"",
134 replacement: {
135 match: /let{showLibrary:\i,/,
136 replace: "$self.usePinnedDms();$&"
137 }
138 },
139
140 // Fix Alt Up/Down navigation
141 {
142 find: ".APPLICATION_STORE&&",
143 replacement: {
144 // channelIds = __OVERLAY__ ? stuff : [...getStaticPaths(),...channelIds)]
145 match: /(?<=\i=__OVERLAY__\?\i:\[\.\.\.\i\(\),\.\.\.)\i/,
146 // ....concat(pins).concat(toArray(channelIds).filter(c => !isPinned(c)))
147 replace: "$self.getAllUncollapsedChannels().concat($&.filter(c=>!$self.isPinned(c)))"
148 }
149 },
150
151 // fix alt+shift+up/down
152 {
153 find: "=()=>!1,ensureChatIsVisible:",
154 replacement: {
155 match: /(?<=\i===\i\.ME\?)\i\.\i\.getPrivateChannelIds\(\)/,
156 replace: "$self.getAllUncollapsedChannels().concat($&.filter(c=>!$self.isPinned(c)))"
157 }
158 },
159 ],
160
161 sections: null as number[] | null,
162
163 set _instance(i: any) {
164 this.instance = i;
165 instance = i;
166 },
167
168 startAt: StartAt.WebpackReady,
169 start: init,
170 flux: {
171 CONNECTION_OPEN: init,
172 },
173
174 usePinnedDms,
175 isPinned,
176 categoryLen,
177 getSections,
178 getAllUncollapsedChannels,
179 requireSettingsMenu: requireSettingsModal,
180
181 makeProps(instance, { sections }: { sections: number[]; }) {
182 this._instance = instance;
183 this.sections = sections;
184
185 this.sections.splice(1, 0, ...this.getSections());
186
187 if (this.instance?.props?.privateChannelIds?.length === 0) {
188 // dont render direct messages header
189 this.sections[this.sections.length - 1] = 0;
190 }
191
192 return {
193 sections: this.sections,
194 chunkSize: this.getChunkSize(),
195 };
196 },
197
198 makeSpanProps() {
199 return settings.store.canCollapseDmSection ? {
200 onClick: () => this.collapseDMList(),
201 role: "button",
202 style: { cursor: "pointer" }
203 } : undefined;
204 },
205
206 getChunkSize() {
207 // the chunk size is the amount of rows (measured in pixels) that are rendered at once (probably)
208 // the higher the chunk size, the more rows are rendered at once
209 // also if the chunk size is 0 it will render everything at once
210
211 const sections = this.getSections();
212 const sectionHeaderSizePx = sections.length * 40;
213 // (header heights + DM heights + DEFAULT_CHUNK_SIZE) * 1.5
214 // we multiply everything by 1.5 so it only gets unmounted after the entire list is off screen
215 return (sectionHeaderSizePx + sections.reduce((acc, v) => acc += v + 44, 0) + DEFAULT_CHUNK_SIZE) * 1.5;
216 },
217
218 isCategoryIndex(sectionIndex: number) {
219 return this.sections && sectionIndex > 0 && sectionIndex < this.sections.length - 1;
220 },
221
222 isChannelIndex(sectionIndex: number, channelIndex: number) {
223 if (settings.store.canCollapseDmSection && settings.store.dmSectionCollapsed && sectionIndex !== 0) {
224 return true;
225 }
226
227 const category = getCategoryByIndex(sectionIndex - 1);
228 return this.isCategoryIndex(sectionIndex) && (category?.channels?.length === 0 || category?.channels[channelIndex]);
229 },
230
231 collapseDMList() {
232 settings.store.dmSectionCollapsed = !settings.store.dmSectionCollapsed;
233 },
234
235 isChannelHidden(categoryIndex: number, channelIndex: number) {
236 if (categoryIndex === 0) return false;
237
238 if (settings.store.canCollapseDmSection && settings.store.dmSectionCollapsed && this.getSections().length + 1 === categoryIndex)
239 return true;
240
241 if (!this.instance || !this.isChannelIndex(categoryIndex, channelIndex)) return false;
242
243 const category = getCategoryByIndex(categoryIndex - 1);
244 if (!category) return false;
245
246 return category.collapsed && this.instance.props.selectedChannelId !== this.getCategoryChannels(category)[channelIndex];
247 },
248
249 getScrollOffset(channelId: string, rowHeight: number, padding: number, preRenderedChildren: number, originalOffset: number) {
250 if (!isPinned(channelId))
251 return (
252 (rowHeight + padding) * 2 class="ts-cmt">// header
253 + rowHeight * this.getAllUncollapsedChannels().length class="ts-cmt">// pins
254 + originalOffset class="ts-cmt">// original pin offset minus pins
255 );
256
257 return rowHeight * (this.getAllUncollapsedChannels().indexOf(channelId) + preRenderedChildren) + padding;
258 },
259
260 renderCategory: ErrorBoundary.wrap(({ section }: { section: number; }) => {
261 const category = getCategoryByIndex(section - 1);
262 if (!category) return null;
263
264 return (
265 <Clickable
266 onClick={() => collapseCategory(category.id, !category.collapsed)}
267 onContextMenu={e => {
268 ContextMenuApi.openContextMenu(e, () => (
269 <Menu.Menu
270 navId="vc-pindms-header-menu"
271 onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
272 color="danger"
273 aria-label="Pin DMs Category Menu"
274 >
275 <Menu.MenuItem
276 id="vc-pindms-edit-category"
277 label="Edit Category"
278 action={() => openCategoryModal(category.id, null)}
279 />
280
281 {
282 canMoveCategory(category.id) && (
283 <>
284 {
285 canMoveCategoryInDirection(category.id, -1) && <Menu.MenuItem
286 id="vc-pindms-move-category-up"
287 label="Move Up"
288 action={() => moveCategory(category.id, -1)}
289 />
290 }
291 {
292 canMoveCategoryInDirection(category.id, 1) && <Menu.MenuItem
293 id="vc-pindms-move-category-down"
294 label="Move Down"
295 action={() => moveCategory(category.id, 1)}
296 />
297 }
298 </>
299
300 )
301 }
302
303 <Menu.MenuSeparator />
304 <Menu.MenuItem
305 id="vc-pindms-delete-category"
306 color="danger"
307 label="Delete Category"
308 action={() => removeCategory(category.id)}
309 />
310
311
312 </Menu.Menu>
313 ));
314 }}
315 >
316 <h2
317 className={classes(headerClasses.privateChannelsHeaderContainer, "vc-pindms-section-container", category.collapsed ? "vc-pindms-collapsed" : "")}
318 style={{ color: `#${category.color.toString(16).padStart(6, "0")}` }}
319 >
320 <span className={headerClasses.headerText}>
321 {category?.name ?? "uh oh"}
322 </span>
323 <svg className="vc-pindms-collapse-icon" aria-hidden="true" role="img" xmlns="http:class="ts-cmt">//www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
324 <path fill="currentColor" d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z"></path>
325 </svg>
326 </h2>
327 </Clickable>
328 );
329 }, { noop: true }),
330
331 renderChannel(sectionIndex: number, index: number, ChannelComponent: React.ComponentType<ChannelComponentProps>) {
332 return ErrorBoundary.wrap(() => {
333 const { channel, category } = this.getChannel(sectionIndex, index, this.instance.props.channels);
334
335 if (!channel || !category) return null;
336 if (this.isChannelHidden(sectionIndex, index)) return null;
337
338 return (
339 <ChannelComponent
340 channel={channel}
341 selected={this.instance.props.selectedChannelId === channel.id}
342 >
343 {channel.id}
344 </ChannelComponent>
345 );
346 }, { noop: true });
347 },
348
349 getChannel(sectionIndex: number, index: number, channels: Record<string, Channel>) {
350 const category = getCategoryByIndex(sectionIndex - 1);
351 if (!category) return { channel: null, category: null };
352
353 const channelId = this.getCategoryChannels(category)[index];
354
355 return { channel: channels[channelId], category };
356 },
357
358 getCategoryChannels(category: Category) {
359 if (category.channels.length === 0) return [];
360
361 if (settings.store.pinOrder === PinOrder.LastMessage) {
362 return PrivateChannelSortStore.getPrivateChannelIds().filter(c => category.channels.includes(c));
363 }
364
365 return category?.channels ?? [];
366 }
367});
368