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
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import "./styles.css";8
9
import { definePluginSettings } from "@api/Settings";10
import ErrorBoundary from "@components/ErrorBoundary";11
import { Devs } from "@utils/constants";12
import { classes } from "@utils/misc";13
import definePlugin, { OptionType, StartAt } from "@utils/types";14
import { Channel } from "@vencord/discord-types";15
import { findCssClassesLazy, findStoreLazy } from "@webpack";16
import { Clickable, ContextMenuApi, FluxDispatcher, Menu, React } from "@webpack/common";17
18
import { contextMenus } from "./components/contextMenu";19
import { openCategoryModal, requireSettingsModal } from "./components/CreateCategoryModal";20
import { DEFAULT_CHUNK_SIZE } from "./constants";21
import { canMoveCategory, canMoveCategoryInDirection, Category, categoryLen, collapseCategory, getAllUncollapsedChannels, getCategoryByIndex, getSections, init, isPinned, moveCategory, removeCategory, usePinnedDms } from "./data";22
23
interface ChannelComponentProps {24
children: React.ReactNode,25
channel: Channel,26
selected: boolean;27
}28
29
const headerClasses = findCssClassesLazy("privateChannelsHeaderContainer", "headerText");30
31
export const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; };32
33
export let instance: any;34
35
export const enum PinOrder {36
LastMessage,37
Custom38
}39
40
export 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: false54
},55
dmSectionCollapsed: {56
type: OptionType.BOOLEAN,57
displayName: "DM Section Collapsed",58
description: "Collapse DM section",59
default: false,60
hidden: true61
},62
userBasedCategoryList: {63
type: OptionType.CUSTOM,64
default: {} as Record<string, Category[]>65
}66
});67
68
export 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 list82
match: /(?<=channels:\i,)privateChannelIds:(\i)(?=,listRef:)/,83
replace: "privateChannelIds:$1.filter(c=>!$self.isPinned(c))"84
},85
{86
// Insert the pinned channels to sections87
match: /(?<=renderRow:this\.renderRow,)sections:\[.+?1\)]/,88
replace: "...$self.makeProps(this,{$&})"89
},90
91
// Rendering92
{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 Height106
{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 ScrollTo116
{117
// Override scrollToChannel to properly account for pinned channels118
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 moment131
// https://regex101.com/r/kDN9fO/1132
{133
find: ".FRIENDS},\"friends\"",134
replacement: {135
match: /let{showLibrary:\i,/,136
replace: "$self.usePinnedDms();$&"137
}138
},139
140
// Fix Alt Up/Down navigation141
{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/down152
{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 header189
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 once209
// also if the chunk size is 0 it will render everything at once210
211
const sections = this.getSections();212
const sectionHeaderSizePx = sections.length * 40;213
// (header heights + DM heights + DEFAULT_CHUNK_SIZE) * 1.5214
// we multiply everything by 1.5 so it only gets unmounted after the entire list is off screen215
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">// header253
+ rowHeight * this.getAllUncollapsedChannels().length class="ts-cmt">// pins254
+ originalOffset class="ts-cmt">// original pin offset minus pins255
);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
<Clickable266
onClick={() => collapseCategory(category.id, !category.collapsed)}267
onContextMenu={e => {268
ContextMenuApi.openContextMenu(e, () => (269
<Menu.Menu270
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.MenuItem276
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.MenuItem286
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.MenuItem293
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.MenuItem305
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
<h2317
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
<ChannelComponent340
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