Plugin
Gif Collection
Allows you to have collections of gifs
1
/*2
* FiveCord โ a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
// Plugin idea by brainfreeze (668137937333911553) ๐8
9
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";10
import { definePluginSettings } from "@api/Settings";11
import { Devs } from "@utils/constants";12
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";13
import definePlugin, { OptionType } from "@utils/types";14
import { Alerts, Button, ContextMenuApi, FluxDispatcher, Forms, Menu, React, TextInput, useCallback, useState } from "@webpack/common";15
16
import * as CollectionManager from "./CollectionManager";17
import { GIF_COLLECTION_PREFIX, GIF_ITEM_PREFIX } from "./constants";18
import { Category, Collection, Gif, Props } from "./types";19
import { getFormat } from "./utils/getFormat";20
import { getGif } from "./utils/getGif";21
import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";22
import { uuidv4 } from "./utils/uuidv4";23
24
export 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 warning36
(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 Collections47
</Button>,48
},49
exportGifs: {50
type: OptionType.COMPONENT,51
description: "Export Collections",52
component: () =>53
<Button onClick={downloadCollections}>54
Export Collections55
</Button>56
}57
});58
59
60
const 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
79
export default definePlugin({80
name: "Gif Collection",81
// need better description eh82
description: "Allows you to have collections of gifs",83
authors: [Devs.FiveCord],84
contextMenus: {85
"message": addCollectionContextMenuPatch86
},87
patches: [88
{89
find: "renderCategoryExtras",90
replacement: [91
// This patch adds the collections to the gif part yk92
{93
match: /(render\(\){)(.{1,50}getItemGrid)/,94
replace: "$1;$self.insertCollections(this);$2"95
},96
// Hides the gc: from the name gc:monkeh -> monkeh97
// https://regex101.com/r/uEjLFq/198
{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.gifs115
{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 bar124
which causes discord to fetch the gifs from their api. This causes a tiny flash when the gifs have fetched successfully125
solution:126
if query starts with gc: and collection is not null then return early and prevent the fetch127
*/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.height168
})).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
<RemoveItemContextMenu205
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
<RemoveItemContextMenu212
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.Menu226
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 controls244
const RemoveItemContextMenu = ({ type, nameOrId, onConfirm }: { type: "gif" | "collection", nameOrId: string, onConfirm: () => void; }) => (245
<Menu.Menu246
navId="gif-collection-id"247
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}248
aria-label={type === "collection" ? "Delete Collection" : "Remove"}249
>250
<Menu.MenuItem251
key="delete-collection"252
id="delete-collection"253
label={type === "collection" ? "Delete Collection" : "Remove"}254
action={() =>255
// Stolen from Review components256
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
275
const MenuThingy: React.FC<{ gif: Gif; }> = ({ gif }) => {276
CollectionManager.refreshCacheCollection();277
const collections = CollectionManager.cache_collections;278
279
return (280
<Menu.MenuItem281
label="Add To Collection"282
key="add-to-collection"283
id="add-to-collection"284
>285
{collections.map(col => (286
<Menu.MenuItem287
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.MenuItem296
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
309
interface CreateCollectionModalProps {310
gif: Gif;311
onClose: () => void,312
modalProps: ModalProps;313
}314
315
function 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
<Button339
type="submit"340
color={Button.Colors.GREEN}341
disabled={!name.length}342
onClick={onSubmit}343
>344
Create345
</Button>346
</ModalFooter>347
</div>348
</form>349
</ModalRoot>350
);351
}352
353
354