Plugin

Gif Collection

Allows you to have collections of gifs

index.tsx
Download

Source

src/plugins/gifCollections/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
7// Plugin idea by brainfreeze (668137937333911553) ๐Ÿ˜Ž
8
9import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
10import { definePluginSettings } from "@api/Settings";
11import { Devs } from "@utils/constants";
12import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
13import definePlugin, { OptionType } from "@utils/types";
14import { Alerts, Button, ContextMenuApi, FluxDispatcher, Forms, Menu, React, TextInput, useCallback, useState } from "@webpack/common";
15
16import * as CollectionManager from "./CollectionManager";
17import { GIF_COLLECTION_PREFIX, GIF_ITEM_PREFIX } from "./constants";
18import { Category, Collection, Gif, Props } from "./types";
19import { getFormat } from "./utils/getFormat";
20import { getGif } from "./utils/getGif";
21import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
22import { uuidv4 } from "./utils/uuidv4";
23
24export const settings = definePluginSettings({
25 defaultEmptyCollectionImage: {
26 description: "The image / gif that will be shown when a collection has no images / gifs",
27 type: OptionType.STRING,
28 default: "https:class="ts-cmt">//i.imgur.com/TFatP8r.png"
29 },
30 importGifs: {
31 type: OptionType.COMPONENT,
32 description: "Import Collections",
33 component: () =>
34 <Button onClick={async () =>
35 // if they have collections show the warning
36 (await CollectionManager.getCollections()).length ? Alerts.show({
37 title: "Are you sure?",
38 body: "Importing collections will overwrite your current collections.",
39 confirmText: "Import",
40 // wow this works?
41 confirmColor: Button.Colors.RED,
42 cancelText: "Nevermind",
43 onConfirm: async () => uploadGifCollections()
44
45 }) : uploadGifCollections()}>
46 Import Collections
47 </Button>,
48 },
49 exportGifs: {
50 type: OptionType.COMPONENT,
51 description: "Export Collections",
52 component: () =>
53 <Button onClick={downloadCollections}>
54 Export Collections
55 </Button>
56 }
57});
58
59
60const addCollectionContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
61 if (!props) return;
62
63 const { message, itemSrc, itemHref, target } = props;
64
65 const gif = getGif(message, itemSrc ?? itemHref, target);
66
67 if (!gif) return;
68
69 const group = findGroupChildrenByChildId("open-native-link", children) ?? findGroupChildrenByChildId("copy-link", children);
70 if (group && !group.some(child => child?.props?.id === "add-to-collection")) {
71 group.push(
72 // if i do it the normal way i get a invalid context menu thingy error -> Menu API only allows Items and groups of Items as children.
73 MenuThingy({ gif })
74 );
75 }
76};
77
78
79export default definePlugin({
80 name: "Gif Collection",
81 // need better description eh
82 description: "Allows you to have collections of gifs",
83 authors: [Devs.FiveCord],
84 contextMenus: {
85 "message": addCollectionContextMenuPatch
86 },
87 patches: [
88 {
89 find: "renderCategoryExtras",
90 replacement: [
91 // This patch adds the collections to the gif part yk
92 {
93 match: /(render\(\){)(.{1,50}getItemGrid)/,
94 replace: "$1;$self.insertCollections(this);$2"
95 },
96 // Hides the gc: from the name gc:monkeh -> monkeh
97 // https://regex101.com/r/uEjLFq/1
98 {
99 match: /(className:\w\.categoryName,children:)(\i)/,
100 replace: "$1$self.hidePrefix($2),"
101 },
102 ]
103 },
104 {
105 find: "renderEmptyFavorite",
106 replacement: {
107 match: /render\(\){.{1,500}onClick:this\.handleClick,/,
108 replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),"
109 }
110 },
111 {
112 find: "renderHeaderContent()",
113 replacement: [
114 // Replaces this.props.resultItems with the collection.gifs
115 {
116 match: /(renderContent\(\){)(.{1,50}resultItems)/,
117 replace: "$1$self.renderContent(this);$2"
118 },
119 ]
120 },
121 /*
122 problem:
123 when you click your collection in the gifs picker discord enters the collection name into the search bar
124 which causes discord to fetch the gifs from their api. This causes a tiny flash when the gifs have fetched successfully
125 solution:
126 if query starts with gc: and collection is not null then return early and prevent the fetch
127 */
128 {
129 find: "type:\"GIF_PICKER_QUERY\"",
130 replacement: {
131 match: /(function \i\(.{1,10}\){)(.{1,100}.GIFS_SEARCH,query:)/,
132 replace:
133 "$1if($self.shouldStopFetch(arguments[0])) return;$2"
134 }
135 },
136 ],
137
138 settings,
139
140
141 start() {
142 CollectionManager.refreshCacheCollection();
143 },
144
145 CollectionManager,
146
147 oldTrendingCat: null as Category[] | null,
148 sillyInstance: null as any,
149 sillyContentInstance: null as any,
150
151 get collections(): Collection[] {
152 CollectionManager.refreshCacheCollection();
153 return CollectionManager.cache_collections;
154 },
155
156 renderContent(instance) {
157 if (instance.props.query.startsWith(GIF_COLLECTION_PREFIX)) {
158 this.sillyContentInstance = instance;
159 const collection = this.collections.find(c => c.name === instance.props.query);
160 if (!collection) return;
161 instance.props.resultItems = collection.gifs.map(g => ({
162 id: g.id,
163 format: getFormat(g.src),
164 src: g.src,
165 url: g.url,
166 width: g.width,
167 height: g.height
168 })).reverse();
169 }
170
171 },
172
173 hidePrefix(name: string) {
174 return name.split(":").length > 1 ? name.replace(/.+?:/, "") : name;
175 },
176
177 insertCollections(instance: { props: Props; }) {
178 try {
179 this.sillyInstance = instance;
180 if (instance.props.trendingCategories.length && instance.props.trendingCategories[0].type === "Trending")
181 this.oldTrendingCat = instance.props.trendingCategories;
182
183
184 if (this.oldTrendingCat != null)
185 instance.props.trendingCategories = this.collections.reverse().concat(this.oldTrendingCat as Collection[]);
186
187 } catch (err) {
188 console.error(err);
189 }
190 },
191
192 shouldStopFetch(query: string) {
193 if (query.startsWith(GIF_COLLECTION_PREFIX)) {
194 const collection = this.collections.find(c => c.name === query);
195 if (collection != null) return true;
196 }
197 return false;
198 },
199
200 collectionContextMenu(e: React.UIEvent, instance) {
201 const { item } = instance.props;
202 if (item?.name?.startsWith(GIF_COLLECTION_PREFIX))
203 return ContextMenuApi.openContextMenu(e, () =>
204 <RemoveItemContextMenu
205 type="collection"
206 onConfirm={() => { this.sillyInstance && this.sillyInstance.forceUpdate(); }}
207 nameOrId={instance.props.item.name} />
208 );
209 if (item?.id?.startsWith(GIF_ITEM_PREFIX)) {
210 ContextMenuApi.openContextMenu(e, () =>
211 <RemoveItemContextMenu
212 type="gif"
213 onConfirm={() => { this.sillyContentInstance && this.sillyContentInstance.forceUpdate(); }}
214 nameOrId={instance.props.item.id}
215 />);
216 instance.props.focused = false;
217 instance.forceUpdate();
218 this.sillyContentInstance && this.sillyContentInstance.forceUpdate();
219 return;
220 }
221
222 const { src, url, height, width } = item;
223 if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX))
224 return ContextMenuApi.openContextMenu(e, () =>
225 <Menu.Menu
226 navId="gif-collection-id"
227 onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
228 aria-label="Gif Collections"
229 >
230
231 {/* if i do it the normal way i get a invalid context menu thingy error -> Menu API only allows Items and groups of Items as children.*/}
232 {MenuThingy({ gif: { ...item, id: uuidv4() } })}
233
234
235 </Menu.Menu>
236 );
237 return null;
238 },
239});
240
241
242
243// stolen from spotify controls
244const RemoveItemContextMenu = ({ type, nameOrId, onConfirm }: { type: "gif" | "collection", nameOrId: string, onConfirm: () => void; }) => (
245 <Menu.Menu
246 navId="gif-collection-id"
247 onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
248 aria-label={type === "collection" ? "Delete Collection" : "Remove"}
249 >
250 <Menu.MenuItem
251 key="delete-collection"
252 id="delete-collection"
253 label={type === "collection" ? "Delete Collection" : "Remove"}
254 action={() =>
255 // Stolen from Review components
256 type === "collection" ? Alerts.show({
257 title: "Are you sure?",
258 body: "Do you really want to delete this collection?",
259 confirmText: "Delete",
260 confirmColor: Button.Colors.RED,
261 cancelText: "Nevermind",
262 onConfirm: async () => {
263 await CollectionManager.deleteCollection(nameOrId);
264 onConfirm();
265 }
266 }) : CollectionManager.removeFromCollection(nameOrId).then(() => onConfirm())}
267 >
268
269 </Menu.MenuItem>
270 </Menu.Menu>
271);
272
273
274
275const MenuThingy: React.FC<{ gif: Gif; }> = ({ gif }) => {
276 CollectionManager.refreshCacheCollection();
277 const collections = CollectionManager.cache_collections;
278
279 return (
280 <Menu.MenuItem
281 label="Add To Collection"
282 key="add-to-collection"
283 id="add-to-collection"
284 >
285 {collections.map(col => (
286 <Menu.MenuItem
287 key={col.name}
288 id={col.name}
289 label={col.name.replace(/.+?:/, "")}
290 action={() => CollectionManager.addToCollection(col.name, gif)}
291 />
292 ))}
293
294 <Menu.MenuSeparator />
295 <Menu.MenuItem
296 key="create-collection"
297 id="create-collection"
298 label="Create Collection"
299 action={() => {
300 openModal(modalProps => (
301 <CreateCollectionModal onClose={modalProps.onClose} gif={gif} modalProps={modalProps} />
302 ));
303 }}
304 />
305 </Menu.MenuItem>
306 );
307};
308
309interface CreateCollectionModalProps {
310 gif: Gif;
311 onClose: () => void,
312 modalProps: ModalProps;
313}
314
315function CreateCollectionModal({ gif, onClose, modalProps }: CreateCollectionModalProps) {
316
317 const [name, setName] = useState("");
318
319 const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
320 e.preventDefault();
321 if (!name.length) return;
322 CollectionManager.createCollection(name, [gif]);
323 onClose();
324 }, [name]);
325
326 return (
327 <ModalRoot {...modalProps}>
328 <form onSubmit={onSubmit}>
329 <ModalHeader>
330 <Forms.FormText>Create Collection</Forms.FormText>
331 </ModalHeader>
332 <ModalContent>
333 <Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Collection Name</Forms.FormTitle>
334 <TextInput onChange={(e: string) => setName(e)} />
335 </ModalContent>
336 <div style={{ marginTop: "1rem" }}>
337 <ModalFooter>
338 <Button
339 type="submit"
340 color={Button.Colors.GREEN}
341 disabled={!name.length}
342 onClick={onSubmit}
343 >
344 Create
345 </Button>
346 </ModalFooter>
347 </div>
348 </form>
349 </ModalRoot>
350 );
351}
352
353
354