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
1import "./styles.css";
2
3import { definePluginSettings } from "@api/Settings";
4import ErrorBoundary from "@components/ErrorBoundary";
5import { Devs } from "@utils/constants";
6import { classes } from "@utils/misc";
7import definePlugin, { OptionType, StartAt } from "@utils/types";
8import { Channel } from "@vencord/discord-types";
9import { findCssClassesLazy, findStoreLazy } from "@webpack";
10import { Clickable, ContextMenuApi, FluxDispatcher, Menu, React } from "@webpack/common";
11
12import { contextMenus } from "./components/contextMenu";
13import { openCategoryModal, requireSettingsModal } from "./components/CreateCategoryModal";
14import { DEFAULT_CHUNK_SIZE } from "./constants";
15import { canMoveCategory, canMoveCategoryInDirection, Category, categoryLen, collapseCategory, getAllUncollapsedChannels, getCategoryByIndex, getSections, init, isPinned, moveCategory, removeCategory, usePinnedDms } from "./data";
16
17interface ChannelComponentProps {
18 children: React.ReactNode,
19 channel: Channel,
20 selected: boolean;
21}
22
23const headerClasses = findCssClassesLazy("privateChannelsHeaderContainer", "headerText");
24
25export const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; };
26
27export let instance: any;
28
29export const enum PinOrder {
30 LastMessage,
31 Custom
32}
33
34export 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: false
48 },
49 dmSectionCollapsed: {
50 type: OptionType.BOOLEAN,
51 displayName: "DM Section Collapsed",
52 description: "Collapse DM section",
53 default: false,
54 hidden: true
55 },
56 userBasedCategoryList: {
57 type: OptionType.CUSTOM,
58 default: {} as Record<string, Category[]>
59 }
60});
61
62export 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 list
76 match: /(?<=channels:\i,)privateChannelIds:(\i)(?=,listRef:)/,
77 replace: "privateChannelIds:$1.filter(c=>!$self.isPinned(c))"
78 },
79 {
80 // Insert the pinned channels to sections
81 match: /(?<=renderRow:this\.renderRow,)sections:\[.+?1\)]/,
82 replace: "...$self.makeProps(this,{$&})"
83 },
84
85 // Rendering
86 {
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 Height
100 {
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 ScrollTo
110 {
111 // Override scrollToChannel to properly account for pinned channels
112 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 moment
125 // https://regex101.com/r/kDN9fO/1
126 {
127 find: ".FRIENDS},\"friends\"",
128 replacement: {
129 match: /let{showLibrary:\i,/,
130 replace: "$self.usePinnedDms();$&"
131 }
132 },
133
134 // Fix Alt Up/Down navigation
135 {
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/down
146 {
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 header
183 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 once
203 // also if the chunk size is 0 it will render everything at once
204
205 const sections = this.getSections();
206 const sectionHeaderSizePx = sections.length * 40;
207 // (header heights + DM heights + DEFAULT_CHUNK_SIZE) * 1.5
208 // we multiply everything by 1.5 so it only gets unmounted after the entire list is off screen
209 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">// header
247 + rowHeight * this.getAllUncollapsedChannels().length class="ts-cmt">// pins
248 + originalOffset class="ts-cmt">// original pin offset minus pins
249 );
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 <Clickable
260 onClick={() => collapseCategory(category.id, !category.collapsed)}
261 onContextMenu={e => {
262 ContextMenuApi.openContextMenu(e, () => (
263 <Menu.Menu
264 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.MenuItem
270 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.MenuItem
280 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.MenuItem
287 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.MenuItem
299 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 <h2
311 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 <ChannelComponent
334 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