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