diff --git a/src/equicordplugins/gifCollections/index.tsx b/src/equicordplugins/gifCollections/index.tsx
new file mode 100644
index 00000000..271261cd
--- /dev/null
+++ b/src/equicordplugins/gifCollections/index.tsx
@@ -0,0 +1,742 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import "./style.css";
+
+import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
+import { DataStore } from "@api/index";
+import { definePluginSettings } from "@api/Settings";
+import { Flex } from "@components/Flex";
+import { Devs, EquicordDevs } from "@utils/constants";
+import { ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
+import definePlugin, { OptionType } from "@utils/types";
+import { Alerts, Button, Clipboard, ContextMenuApi, FluxDispatcher, Forms, Menu, React, showToast, TextInput, Toasts, useCallback, useState } from "@webpack/common";
+
+import { addToCollection, cache_collections, createCollection, DATA_COLLECTION_NAME, deleteCollection, fixPrefix, getCollections, getGifById, getItemCollectionNameFromId, moveGifToCollection, refreshCacheCollection, removeFromCollection, renameCollection } from "./utils/collectionManager";
+import { getFormat } from "./utils/getFormat";
+import { getGif } from "./utils/getGif";
+import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
+import { uuidv4 } from "./utils/uuidv4";
+
+let GIF_COLLECTION_PREFIX: string;
+let GIF_ITEM_PREFIX: string;
+
+export const SortingOptions = {
+ NAME: 1,
+ CREATION_DATE: 2,
+ MODIFIED_DATE: 3
+};
+
+const addCollectionContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
+ if (!props) return;
+ const { message, itemSrc, itemHref, target } = props;
+ const gif = getGif(message, itemSrc ?? itemHref, target);
+ if (!gif) return;
+
+ const group = findGroupChildrenByChildId("open-native-link", children) ?? findGroupChildrenByChildId("copy-link", children);
+ if (group && !group.some(child => child?.props?.id === "add-to-collection")) {
+ const collections = cache_collections;
+
+ group.push(
+
+ {collections.length > 0 && collections.map(col => (
+ addToCollection(col.name, gif)}
+ />
+ ))}
+
+ {collections.length > 0 && }
+
+ {
+ openModal(modalProps => (
+
+ ));
+ }}
+ />
+
+ );
+ }
+};
+
+
+export const settings = definePluginSettings({
+ itemPrefix: {
+ description: "The prefix for gif items",
+ type: OptionType.STRING,
+ default: "gc-item:",
+ onChange: value => {
+ const normalizedValue = value.replace(/:+$/, "") + ":";
+ if (normalizedValue === GIF_ITEM_PREFIX) return;
+ GIF_ITEM_PREFIX = normalizedValue;
+ settings.store.itemPrefix = normalizedValue;
+ fixPrefix(normalizedValue);
+ },
+ restartNeeded: true
+ },
+ collectionPrefix: {
+ description: "The prefix for collections",
+ type: OptionType.STRING,
+ default: "gc:",
+ onChange: value => {
+ const normalizedValue = value.replace(/:+$/, "") + ":";
+ if (normalizedValue === GIF_COLLECTION_PREFIX) return;
+ GIF_COLLECTION_PREFIX = normalizedValue;
+ settings.store.collectionPrefix = normalizedValue;
+ fixPrefix(normalizedValue);
+ },
+ restartNeeded: true
+ },
+ onlyShowCollections: {
+ description: "Only show collections",
+ type: OptionType.BOOLEAN,
+ default: false,
+ restartNeeded: true
+ },
+ stopWarnings: {
+ description: "Stop deletion warnings",
+ type: OptionType.BOOLEAN,
+ default: false,
+ },
+ defaultEmptyCollectionImage: {
+ description: "The image / gif that will be shown when a collection has no images / gifs",
+ type: OptionType.STRING,
+ default: "https://c.tenor.com/YEG33HsLEaIAAAAC/parksandrec-oops.gif"
+ },
+ collectionsSortType: {
+ description: "The type of sorting for collections",
+ type: OptionType.NUMBER,
+ default: SortingOptions.NAME,
+ hidden: true
+ },
+ collectionsSortOrder: {
+ description: "The order of sorting for collections",
+ type: OptionType.STRING,
+ default: "asc",
+ hidden: true
+ },
+ collectionsSort: {
+ type: OptionType.COMPONENT,
+ description: "Decide how to sort collections",
+ component: () => {
+ const [sortType, setSortType] = useState(settings.store.collectionsSortType || SortingOptions.NAME);
+ const [sortOrder, setSortOrder] = useState(settings.store.collectionsSortOrder || "asc");
+
+ const handleSortTypeChange = value => {
+ setSortType(value);
+ settings.store.collectionsSortType = value;
+ };
+
+ const handleSortOrderChange = value => {
+ setSortOrder(value);
+ settings.store.collectionsSortOrder = value;
+ };
+
+ return (
+
+
Sort Collections
+
+
+ Choose a sorting criteria for your collections
+
+
+
+
+
+
Order
+
+
+
+
+
+
+
+
+ );
+ }
+ },
+ importGifs: {
+ type: OptionType.COMPONENT,
+ description: "Import Collections",
+ component: () =>
+ ,
+ },
+ exportGifs: {
+ type: OptionType.COMPONENT,
+ description: "Export Collections",
+ component: () =>
+
+ },
+ resetCollections: {
+ type: OptionType.COMPONENT,
+ description: "Reset Collections",
+ component: () =>
+
+ }
+});
+
+export default definePlugin({
+ name: "GifCollections",
+ description: "Allows you to create collections of gifs",
+ authors: [Devs.Aria, EquicordDevs.creations],
+ patches: [
+ {
+ find: "renderCategoryExtras",
+ replacement: [
+ {
+ match: /(render\(\){)(.{1,50}getItemGrid)/,
+ replace: "$1;$self.insertCollections(this);$2"
+ },
+ {
+ match: /(className:\w\.categoryName,children:)(\i)/,
+ replace: "$1$self.hidePrefix($2),"
+ },
+ ]
+ },
+ {
+ find: "renderEmptyFavorite",
+ replacement: {
+ match: /render\(\){.{1,500}onClick:this\.handleClick,/,
+ replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),"
+ }
+ },
+ {
+ find: "renderHeaderContent()",
+ replacement: [
+ {
+ match: /(renderContent\(\){)(.{1,50}resultItems)/,
+ replace: "$1$self.renderContent(this);$2"
+ },
+ ]
+ },
+ {
+ find: "type:\"GIF_PICKER_QUERY\"",
+ replacement: {
+ match: /(function \i\(.{1,10}\){)(.{1,100}.GIFS_SEARCH,query:)/,
+ replace: "$1if($self.shouldStopFetch(arguments[0])) return;$2"
+ }
+ },
+ ],
+ settings,
+ start() {
+ refreshCacheCollection();
+ addContextMenuPatch("message", addCollectionContextMenuPatch);
+ GIF_COLLECTION_PREFIX = settings.store.collectionPrefix;
+ GIF_ITEM_PREFIX = settings.store.itemPrefix;
+ },
+ stop() {
+ removeContextMenuPatch("message", addCollectionContextMenuPatch);
+ },
+ get collections() {
+ refreshCacheCollection();
+ return this.sortedCollections();
+ },
+ sortedCollections() {
+ return cache_collections.sort((a, b) => {
+ const sortType = settings.store.collectionsSortType;
+ const sortOrder = settings.store.collectionsSortOrder === "asc" ? 1 : -1;
+ switch (sortType) {
+ case SortingOptions.NAME:
+ return a.name.localeCompare(b.name) * sortOrder;
+ case SortingOptions.CREATION_DATE:
+ return ((a.createdAt ?? 0) - (b.createdAt ?? 0)) * sortOrder;
+ case SortingOptions.MODIFIED_DATE:
+ return ((a.lastUpdated ?? 0) - (b.lastUpdated ?? 0)) * sortOrder;
+ default:
+ return 0;
+ }
+ });
+ },
+ renderContent(instance) {
+ if (instance.props.query.startsWith(GIF_COLLECTION_PREFIX)) {
+ const collection = this.collections.find(c => c.name === instance.props.query);
+ if (collection) {
+ instance.props.resultItems = collection.gifs.map(g => ({
+ id: g.id,
+ format: getFormat(g.src),
+ src: g.src,
+ url: g.url,
+ width: g.width,
+ height: g.height
+ })).reverse();
+ }
+ }
+ },
+ hidePrefix(name) {
+ return name.split(":".length > 1) ? name.replace(/.+?:/, "") : name;
+ },
+ insertCollections(instance) {
+ const shouldRemoveAll = settings.store.onlyShowCollections;
+ try {
+ if (instance.props.trendingCategories.length && instance.props.trendingCategories[0].type === "Trending") {
+ this.oldTrendingCat = instance.props.trendingCategories;
+ }
+ if (shouldRemoveAll) {
+ instance.props.trendingCategories = this.sortedCollections().concat(instance.props.favorites);
+ } else if (this.oldTrendingCat != null) {
+ instance.props.trendingCategories = this.sortedCollections().concat(this.oldTrendingCat);
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ },
+ shouldStopFetch(query) {
+ return query.startsWith(GIF_COLLECTION_PREFIX) && this.collections.find(c => c.name === query) != null;
+ },
+ collectionContextMenu(e, instance) {
+ const { item } = instance.props;
+ if (item?.name?.startsWith(GIF_COLLECTION_PREFIX)) {
+ return ContextMenuApi.openContextMenu(e, () =>
+
+ );
+ }
+ if (item?.id?.startsWith(GIF_ITEM_PREFIX)) {
+ return ContextMenuApi.openContextMenu(e, () =>
+
+ );
+ }
+ const { src, url, height, width } = item;
+ if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX)) {
+ return ContextMenuApi.openContextMenu(e, () =>
+ FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label="Gif Collections"
+ >
+ {MenuThingy({ gif: { ...item, id: uuidv4(GIF_ITEM_PREFIX) } })}
+
+ );
+ }
+ return null;
+ },
+});
+
+const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
+ FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label={type === "collection" ? "Delete Collection" : "Remove"}
+ >
+ {type === "collection" && (
+ <>
+ {
+ const collection = cache_collections.find(c => c.name === nameOrId);
+ if (!collection) return;
+ openModal(modalProps => (
+
+
+ Collection Information
+
+
+
+
+ Name
+ {collection.name.replace(/.+?:/, "")}
+
+
+ Gifs
+ {collection.gifs.length}
+
+
+ Created At
+ {collection.createdAt ? new Date(collection.createdAt).toLocaleString() : "Unknown"}
+
+
+ Last Updated
+ {collection.lastUpdated ? new Date(collection.lastUpdated).toLocaleString() : "Unknown"}
+
+
+
+
+
+
+
+ ));
+ }}
+ />
+
+ openModal(modalProps => (
+
+ ))}
+ />
+ >
+ )}
+ {type === "gif" && (
+ <>
+ {
+ const gifInfo = getGifById(nameOrId);
+ if (!gifInfo) return;
+ openModal(modalProps => (
+
+
+ Information
+
+
+
+
+ Added At
+ {gifInfo.addedAt ? new Date(gifInfo.addedAt).toLocaleString() : "Unknown"}
+
+
+ Width
+ {gifInfo.width}
+
+
+ Height
+ {gifInfo.height}
+
+
+
+
+
+
+
+ ));
+ }}
+ />
+
+ {
+ const gifInfo = getGifById(nameOrId);
+ if (!gifInfo) return;
+ Clipboard.copy(gifInfo.url);
+ showToast("URL copied to clipboard", Toasts.Type.SUCCESS);
+ }}
+ />
+ {
+ openModal(modalProps => (
+
+
+ Move To Collection
+
+
+
+ Select a collection to move the item to
+
+
+ {cache_collections
+ .filter(col => col.name !== getItemCollectionNameFromId(nameOrId))
+ .map(col => (
+
+ ))}
+
+
+
+
+
+
+ ));
+ }}
+ />
+
+ >
+ )}
+ {
+ if (settings.store.stopWarnings) {
+ const collectionName = getItemCollectionNameFromId(nameOrId);
+ if (type === "collection") {
+ deleteCollection(nameOrId);
+ instance.forceUpdate();
+ } else {
+ await removeFromCollection(nameOrId);
+ FluxDispatcher.dispatch({
+ type: "GIF_PICKER_QUERY",
+ query: `${collectionName} `
+ });
+ FluxDispatcher.dispatch({
+ type: "GIF_PICKER_QUERY",
+ query: `${collectionName}`
+ });
+ }
+ return;
+ }
+ Alerts.show({
+ title: "Are you sure?",
+ body: `Do you really want to ${type === "collection" ? "delete this collection" : "remove this item"}?`,
+ confirmText: type === "collection" ? "Delete" : "Remove",
+ confirmColor: Button.Colors.RED,
+ cancelText: "Nevermind",
+ onConfirm: async () => {
+ const collectionName = type === "collection" ? nameOrId : getItemCollectionNameFromId(nameOrId);
+ if (type === "collection") {
+ deleteCollection(nameOrId);
+ instance.forceUpdate();
+ } else {
+ await removeFromCollection(nameOrId);
+ FluxDispatcher.dispatch({
+ type: "GIF_PICKER_QUERY",
+ query: `${collectionName} `
+ });
+ FluxDispatcher.dispatch({
+ type: "GIF_PICKER_QUERY",
+ query: `${collectionName}`
+ });
+ }
+ }
+ });
+ }}
+ />
+
+);
+
+const MenuThingy = ({ gif }) => {
+ const collections = cache_collections;
+ return (
+
+ {collections.map(col => (
+ addToCollection(col.name, gif)}
+ />
+ ))}
+ {collections.length > 0 && }
+ openModal(modalProps => (
+
+ ))}
+ />
+
+ );
+};
+
+function CreateCollectionModal({ gif, onClose, modalProps }) {
+ const [name, setName] = useState("");
+ const onSubmit = useCallback(e => {
+ e.preventDefault();
+ if (!name.length) return;
+ createCollection(name, [gif]);
+ onClose();
+ }, [name]);
+
+ return (
+
+
+
+ );
+}
+
+function RenameCollectionModal({ name, onClose, modalProps }) {
+ const prefix = settings.store.collectionPrefix;
+ const strippedName = name.startsWith(prefix) ? name.slice(prefix.length) : name;
+ const [newName, setNewName] = useState(strippedName);
+
+ const onSubmit = useCallback(async e => {
+ e.preventDefault();
+ if (!newName.length || newName.length >= 25) return;
+ await renameCollection(name, newName);
+ onClose();
+ }, [newName, name, onClose]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/equicordplugins/gifCollections/style.css b/src/equicordplugins/gifCollections/style.css
new file mode 100644
index 00000000..04191194
--- /dev/null
+++ b/src/equicordplugins/gifCollections/style.css
@@ -0,0 +1,188 @@
+.collections-sort-container {
+ padding: 12px;
+ background-color: var(--background-secondary);
+ border-radius: 8px;
+ margin-bottom: 16px;
+}
+
+.collections-sort-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--header-primary);
+ margin-bottom: 8px;
+}
+
+.collections-sort-divider {
+ margin: 10px 0;
+ border-top: 1px solid var(--background-modifier-accent);
+}
+
+.collections-sort-description,
+.collections-sort-section-title {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-normal);
+ margin-bottom: 8px;
+}
+
+.collections-sort-section {
+ margin-top: 12px;
+}
+
+.collections-sort-option {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.collections-sort-label {
+ font-size: 14px;
+ font-weight: 400;
+ color: var(--text-normal);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+}
+
+.collections-sort-input {
+ margin-right: 10px;
+ cursor: pointer;
+ accent-color: var(--brand-experiment);
+}
+
+.collections-sort-label:hover {
+ color: var(--header-primary);
+}
+
+.custom-modal {
+ background-color: var(--background-primary);
+ border-radius: 8px;
+ box-shadow: 0 8px 16px rgb(0 0 0 / 24%), 0 16px 32px rgb(0 0 0 / 24%);
+}
+
+.custom-modal-header {
+ padding: 16px;
+ background-color: var(--background-secondary);
+ border-bottom: 1px solid var(--background-tertiary);
+}
+
+.custom-modal-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--header-primary);
+}
+
+.custom-modal-content {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.gif-info,
+.collection-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ padding: 8px 0;
+}
+
+.collection-info:last-child {
+ border-bottom: none;
+}
+
+.collection-info-title {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-normal);
+}
+
+.collection-info-text {
+ font-size: 14px;
+ color: var(--text-muted);
+ max-width: 70%;
+ white-space: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.custom-modal-footer {
+ padding: 16px;
+ background-color: var(--background-secondary);
+ display: flex;
+ justify-content: flex-end;
+}
+
+.custom-modal-footer button {
+ margin-left: auto;
+}
+
+.custom-modal-button {
+ background-color: var(--brand-experiment);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.custom-modal-button:hover {
+ background-color: var(--brand-experiment-560);
+}
+
+
+.rename-collection-text {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-normal);
+ margin: 16px 0 2px;
+}
+
+.rename-collection-input {
+ outline: 0 solid transparent;
+ transition: outline 0.2s;
+ border-radius: 4px;
+}
+
+.input-warning {
+ outline: 1px solid var(--text-danger);
+}
+
+.warning-text {
+ margin-top: 16px;
+ font-size: 14px;
+ color: var(--text-danger);
+}
+
+.collection-buttons {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 8px;
+ max-height: 200px;
+}
+
+/* e */
+.search-bar {
+ width: 100%;
+ margin-bottom: 12px;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background-color: var(--background-secondary);
+ color: var(--text-normal);
+ outline: none;
+}
+
+.collection-button {
+ text-align: center;
+ padding: 8px;
+ background-color: var(--background-tertiary);
+ color: var(--text-normal);
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
\ No newline at end of file
diff --git a/src/equicordplugins/gifCollections/types.ts b/src/equicordplugins/gifCollections/types.ts
new file mode 100644
index 00000000..9f4199bf
--- /dev/null
+++ b/src/equicordplugins/gifCollections/types.ts
@@ -0,0 +1,35 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export enum Format { NONE = 0, IMAGE = 1, VIDEO = 2 }
+
+export interface Category {
+ type: "Trending" | "Category";
+ name: string;
+ src: string;
+ format: Format;
+ gifs?: Gif[];
+ createdAt?: number;
+ lastUpdated?: number;
+}
+
+export interface Gif {
+ id: string,
+ src: string;
+ url: string;
+ height: number,
+ width: number;
+ addedAt?: number;
+}
+
+export interface Props {
+ favorites: { [src: string]: any; };
+ trendingCategories: Category[];
+}
+
+type WithRequired = T & { [P in K]-?: T[P] };
+
+export type Collection = WithRequired;
diff --git a/src/equicordplugins/gifCollections/utils/cleanUrl.ts b/src/equicordplugins/gifCollections/utils/cleanUrl.ts
new file mode 100644
index 00000000..41559848
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/cleanUrl.ts
@@ -0,0 +1,11 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export const cleanUrl = (url: string) => {
+ const urlObject = new URL(url);
+ urlObject.search = "";
+ return urlObject.href;
+};
diff --git a/src/equicordplugins/gifCollections/utils/collectionManager.ts b/src/equicordplugins/gifCollections/utils/collectionManager.ts
new file mode 100644
index 00000000..5e83895a
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/collectionManager.ts
@@ -0,0 +1,166 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { DataStore } from "@api/index";
+import { Toasts } from "@webpack/common";
+
+import { settings } from "../index";
+import { Collection, Gif } from "../types";
+import { getFormat } from "./getFormat";
+
+export const DATA_COLLECTION_NAME = "gif-collections-collections";
+
+// this is here bec async + react class component dont play nice and stutters happen. IF theres a better way of doing it pls let me know
+export let cache_collections: Collection[] = [];
+
+export const getCollections = async (): Promise => (await DataStore.get(DATA_COLLECTION_NAME)) ?? [];
+
+export const getCollection = async (name: string): Promise => {
+ const collections = await getCollections();
+ return collections.find(c => c.name === name);
+};
+
+export const getCachedCollection = (name: string): Collection | undefined => cache_collections.find(c => c.name === name);
+
+export const createCollection = async (name: string, gifs: Gif[]): Promise => {
+ const collections = await getCollections();
+ const duplicateCollection = collections.find(c => c.name === `${settings.store.collectionPrefix}${name}`);
+ if (duplicateCollection)
+ return Toasts.show({
+ message: "That collection already exists",
+ type: Toasts.Type.FAILURE,
+ id: Toasts.genId(),
+ options: {
+ duration: 3000,
+ position: Toasts.Position.BOTTOM
+ }
+ });
+
+ // gifs shouldnt be empty because to create a collection you need to right click an image / gif and then create it yk. but cant hurt to have a null-conditional check RIGHT?
+ const latestGifSrc = gifs[gifs.length - 1]?.src ?? settings.store.defaultEmptyCollectionImage;
+ const collection: Collection = {
+ name: `${settings.store.collectionPrefix}${name}`,
+ src: latestGifSrc,
+ format: getFormat(latestGifSrc),
+ type: "Category",
+ gifs,
+ createdAt: Date.now(),
+ lastUpdated: Date.now()
+ };
+
+ await DataStore.set(DATA_COLLECTION_NAME, [...collections, collection]);
+ return await refreshCacheCollection();
+};
+
+export const addToCollection = async (name: string, gif: Gif): Promise => {
+ const collections = await getCollections();
+ const collectionIndex = collections.findIndex(c => c.name === name);
+ if (collectionIndex === -1) return console.warn("collection not found");
+
+ collections[collectionIndex].gifs.push(gif);
+ collections[collectionIndex].src = gif.src;
+ collections[collectionIndex].format = getFormat(gif.src);
+ collections[collectionIndex].lastUpdated = Date.now();
+
+ gif.addedAt = Date.now();
+
+ await DataStore.set(DATA_COLLECTION_NAME, collections);
+ return await refreshCacheCollection();
+};
+
+export const renameCollection = async (oldName: string, newName: string): Promise => {
+ const collections = await getCollections();
+ const collectionIndex = collections.findIndex(c => c.name === oldName);
+ if (collectionIndex === -1) return console.warn("collection not found");
+
+ collections[collectionIndex].name = `${settings.store.collectionPrefix}${newName}`;
+ collections[collectionIndex].lastUpdated = Date.now();
+
+ await DataStore.set(DATA_COLLECTION_NAME, collections);
+ return await refreshCacheCollection();
+};
+
+export const removeFromCollection = async (id: string): Promise => {
+ const collections = await getCollections();
+ const collectionIndex = collections.findIndex(c => c.gifs.some(g => g.id === id));
+ if (collectionIndex === -1) return console.warn("collection not found");
+
+ collections[collectionIndex].gifs = collections[collectionIndex].gifs.filter(g => g.id !== id);
+
+ const collection = collections[collectionIndex];
+ const latestGifSrc = collection.gifs.length ? collection.gifs[collection.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage;
+ collections[collectionIndex].src = latestGifSrc;
+ collections[collectionIndex].format = getFormat(latestGifSrc);
+ collections[collectionIndex].lastUpdated = Date.now();
+
+ await DataStore.set(DATA_COLLECTION_NAME, collections);
+ return await refreshCacheCollection();
+};
+
+export const deleteCollection = async (name: string): Promise => {
+ const collections = await getCollections();
+ const col = collections.filter(c => c.name !== name);
+ await DataStore.set(DATA_COLLECTION_NAME, col);
+ await refreshCacheCollection();
+};
+
+export const refreshCacheCollection = async (): Promise => {
+ cache_collections = await getCollections();
+};
+
+export const fixPrefix = async (newPrefix: string): Promise => {
+ const normalizedPrefix = newPrefix.replace(/:+$/, "") + ":";
+
+ const collections = await getCollections();
+
+ collections.forEach(c => {
+ const nameParts = c.name.split(":");
+ c.name = `${normalizedPrefix}${nameParts[nameParts.length - 1]}`;
+ });
+
+ await DataStore.set(DATA_COLLECTION_NAME, collections);
+ await refreshCacheCollection();
+};
+
+export const getItemCollectionNameFromId = (id: string): string | undefined => {
+ const collections = cache_collections;
+ const collection = collections.find(c => c.gifs.some(g => g.id === id));
+ return collection?.name;
+};
+
+export const getGifById = (id: string): Gif | undefined => {
+ const collections = cache_collections;
+ const gif = collections.flatMap(c => c.gifs).find(g => g.id === id);
+ return gif;
+};
+
+export const moveGifToCollection = async (gifId: string, fromCollection: string, toCollection: string): Promise => {
+ const collections = await getCollections();
+ const fromCollectionIndex = collections.findIndex(c => c.name === fromCollection);
+ const toCollectionIndex = collections.findIndex(c => c.name === toCollection);
+ if (fromCollectionIndex === -1 || toCollectionIndex === -1) return console.warn("collection not found");
+
+ const gifIndex = collections[fromCollectionIndex].gifs.findIndex(g => g.id === gifId);
+ if (gifIndex === -1) return console.warn("gif not found");
+
+ const gif = collections[fromCollectionIndex].gifs[gifIndex];
+ gif.addedAt = Date.now();
+ collections[fromCollectionIndex].gifs.splice(gifIndex, 1);
+ collections[toCollectionIndex].gifs.push(gif);
+
+ const fromCollectionLatestGifSrc = collections[fromCollectionIndex].gifs.length ? collections[fromCollectionIndex].gifs[collections[fromCollectionIndex].gifs.length - 1].src : settings.store.defaultEmptyCollectionImage;
+ collections[fromCollectionIndex].src = fromCollectionLatestGifSrc;
+ collections[fromCollectionIndex].format = getFormat(fromCollectionLatestGifSrc);
+ collections[fromCollectionIndex].lastUpdated = Date.now();
+
+ const toCollectionLatestGifSrc = collections[toCollectionIndex].gifs.length ? collections[toCollectionIndex].gifs[collections[toCollectionIndex].gifs.length - 1].src : settings.store.defaultEmptyCollectionImage;
+ collections[toCollectionIndex].src = toCollectionLatestGifSrc;
+ collections[toCollectionIndex].format = getFormat(toCollectionLatestGifSrc);
+ collections[toCollectionIndex].lastUpdated = Date.now();
+
+ await DataStore.set(DATA_COLLECTION_NAME, collections);
+ return await refreshCacheCollection();
+};
diff --git a/src/equicordplugins/gifCollections/utils/getFormat.ts b/src/equicordplugins/gifCollections/utils/getFormat.ts
new file mode 100644
index 00000000..238a2d78
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/getFormat.ts
@@ -0,0 +1,15 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { Format } from "../types";
+import { getUrlExtension } from "./getUrlExtension";
+
+const videoExtensions = ["mp4", "ogg", "webm", "avi", "wmv", "flv", "mov", "mkv", "m4v"];
+
+export function getFormat(url: string) {
+ const extension = getUrlExtension(url);
+ return url.startsWith("https://media.tenor") || extension == null || videoExtensions.includes(extension) ? Format.VIDEO : Format.IMAGE;
+}
diff --git a/src/equicordplugins/gifCollections/utils/getGif.ts b/src/equicordplugins/gifCollections/utils/getGif.ts
new file mode 100644
index 00000000..1a6726a9
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/getGif.ts
@@ -0,0 +1,126 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { MessageStore, SnowflakeUtils } from "@webpack/common";
+import { Message } from "discord-types/general";
+
+import { settings } from "../index";
+import { Gif } from "../types";
+import { cleanUrl } from "./cleanUrl";
+import { isAudio } from "./isAudio";
+import { uuidv4 } from "./uuidv4";
+
+export function getGifByTarget(url: string, target?: HTMLDivElement | null): Gif | null {
+ const liElement = target?.closest("li");
+ if (!target || !liElement || !liElement.id) return null;
+
+ const [channelId, messageId] = liElement.id.split("-").slice(2);
+ // the isValidSnowFlake part may not be nessesery cuse either way (valid or not) message will be undefined if it doenst find a message /shrug
+ if (!channelId || !messageId || !isValidSnowFlake(channelId) || !isValidSnowFlake(messageId)) return null;
+
+ const message = MessageStore.getMessage(channelId, messageId);
+ if (!message || !message.embeds.length && !message.attachments.length) return null;
+
+ return getGifByMessageAndUrl(url, message);
+}
+
+
+export function getGifByMessageAndTarget(target: HTMLDivElement, message: Message) {
+ const url = target.closest('[class^="imageWrapper"]')?.querySelector("video")?.src ?? target.closest('[class^="imageWrapper"]')?.querySelector("img")?.src;
+
+ if (!url) return null;
+
+ return getGifByMessageAndUrl(url, message);
+}
+
+export function getGifByMessageAndUrl(url: string, message: Message): Gif | null {
+ if (!message.embeds.length && !message.attachments.length || isAudio(url))
+ return null;
+
+ const cleanedUrl = cleanUrl(url);
+
+ // find embed with matching url or image/thumbnail url
+ const embed = message.embeds.find(e => {
+ const hasMatchingUrl = e.url && cleanUrl(e.url) === cleanedUrl;
+ const hasMatchingImage = e.image && cleanUrl(e.image.url) === cleanedUrl;
+ const hasMatchingImageProxy = e.image?.proxyURL === cleanedUrl;
+ const hasMatchingVideoProxy = e.video?.proxyURL === cleanedUrl;
+ const hasMatchingThumbnailProxy = e.thumbnail?.proxyURL === cleanedUrl;
+
+ return (
+ hasMatchingUrl ||
+ hasMatchingImage ||
+ hasMatchingImageProxy ||
+ hasMatchingVideoProxy ||
+ hasMatchingThumbnailProxy
+ );
+ });
+ if (embed) {
+ if (embed.image)
+ return {
+ id: uuidv4(settings.store.itemPrefix),
+ height: embed.image.height,
+ width: embed.image.width,
+ src: embed.image.proxyURL,
+ url: embed.image.url,
+ };
+ // Tennor
+ if (embed.video && embed.video.proxyURL) return {
+ id: uuidv4(settings.store.itemPrefix),
+ height: embed.video.height,
+ width: embed.video.width,
+ src: embed.video.proxyURL,
+ url: embed.provider?.name === "Tenor" ? embed.url ?? embed.video.url : embed.video.url,
+ };
+
+ // Youtube thumbnails and other stuff idk
+ if (embed.thumbnail && embed.thumbnail.proxyURL) return {
+ id: uuidv4(settings.store.itemPrefix),
+ height: embed.thumbnail.height,
+ width: embed.thumbnail.width,
+ src: embed.thumbnail.proxyURL,
+ url: embed.thumbnail.url,
+ };
+ }
+
+
+ const attachment = message.attachments.find(a => cleanUrl(a.url) === cleanedUrl || a.proxy_url === cleanedUrl);
+ if (attachment) return {
+ id: uuidv4(settings.store.itemPrefix),
+ height: attachment.height ?? 50,
+ width: attachment.width ?? 50,
+ src: attachment.proxy_url,
+ url: attachment.url
+ };
+
+ return null;
+}
+
+export const getGif = (message: Message | null, url: string | null, target: HTMLDivElement | null) => {
+ if (message && url) {
+ const gif = getGifByMessageAndUrl(url, message);
+ if (!gif) return null;
+
+ return gif;
+ }
+ if (message && target && !url) {
+ const gif = getGifByMessageAndTarget(target, message);
+ if (!gif) return null;
+
+ return gif;
+ }
+ if (url && target && !message) {
+ // youtube thumbnail url is message link for some reason eh
+ const gif = getGifByTarget(url.startsWith("https://discord.com/") ? target.parentElement?.querySelector("img")?.src ?? url : url, target);
+ if (!gif) return null;
+
+ return gif;
+ }
+};
+
+function isValidSnowFlake(snowflake: string) {
+ return !Number.isNaN(SnowflakeUtils.extractTimestamp(snowflake));
+}
diff --git a/src/equicordplugins/gifCollections/utils/getUrlExtension.ts b/src/equicordplugins/gifCollections/utils/getUrlExtension.ts
new file mode 100644
index 00000000..05ca0716
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/getUrlExtension.ts
@@ -0,0 +1,11 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export function getUrlExtension(url: string) {
+ // tennor stuff is like //media.tenor/blah/blah
+ if (!url.startsWith("https:")) url = "https:" + url;
+ return new URL(url).pathname.split(".").pop();
+}
diff --git a/src/equicordplugins/gifCollections/utils/isAudio.ts b/src/equicordplugins/gifCollections/utils/isAudio.ts
new file mode 100644
index 00000000..5e968adf
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/isAudio.ts
@@ -0,0 +1,14 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { getUrlExtension } from "./getUrlExtension";
+
+const audioExtensions = ["mp3", "wav", "ogg", "aac", "m4a", "wma", "flac"];
+
+export function isAudio(url: string) {
+ const extension = getUrlExtension(url);
+ return extension && audioExtensions.includes(extension);
+}
diff --git a/src/equicordplugins/gifCollections/utils/settingsUtils.ts b/src/equicordplugins/gifCollections/utils/settingsUtils.ts
new file mode 100644
index 00000000..4b23d191
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/settingsUtils.ts
@@ -0,0 +1,119 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { DataStore } from "@api/index";
+import { Toasts } from "@webpack/common";
+
+import { DATA_COLLECTION_NAME, getCollections, refreshCacheCollection } from "./collectionManager";
+
+// 99% of this is coppied from src\utils\settingsSync.ts
+
+export async function downloadCollections() {
+ const filename = "gif-collections.json";
+ const exportData = await exportCollections();
+ const data = new TextEncoder().encode(exportData);
+
+ if (IS_WEB) {
+ const file = new File([data], filename, { type: "application/json" });
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(file);
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ setImmediate(() => {
+ URL.revokeObjectURL(a.href);
+ document.body.removeChild(a);
+ });
+ } else {
+ DiscordNative.fileManager.saveWithDialog(data, filename);
+ }
+
+}
+
+export async function exportCollections() {
+ const collections = await getCollections();
+ return JSON.stringify({ collections }, null, 4);
+}
+
+
+export async function importCollections(data: string) {
+ try {
+ var parsed = JSON.parse(data);
+ } catch (err) {
+ console.log(data);
+ throw new Error("Failed to parse JSON: " + String(err));
+ }
+
+ if ("collections" in parsed) {
+ await DataStore.set(DATA_COLLECTION_NAME, parsed.collections);
+ await refreshCacheCollection();
+ } else
+ throw new Error("Invalid Collections");
+}
+
+
+export async function uploadGifCollections(showToast = true): Promise {
+ if (IS_WEB) {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.style.display = "none";
+ input.accept = "application/json";
+ input.onchange = async () => {
+ const file = input.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = async () => {
+ try {
+ await importCollections(reader.result as string);
+ if (showToast) toastSuccess();
+ } catch (err) {
+ console.error(err);
+ // new Logger("SettingsSync").error(err);
+ if (showToast) toastFailure(err);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ document.body.appendChild(input);
+ input.click();
+ setImmediate(() => document.body.removeChild(input));
+ } else {
+ const [file] = await DiscordNative.fileManager.openFiles({
+ filters: [
+ { name: "Gif Collections", extensions: ["json"] },
+ { name: "all", extensions: ["*"] }
+ ]
+ });
+
+ if (file) {
+ try {
+ await importCollections(new TextDecoder().decode(file.data));
+ if (showToast) toastSuccess();
+ } catch (err) {
+ console.error(err);
+ // new Logger("SettingsSync").error(err);
+ if (showToast) toastFailure(err);
+ }
+ }
+ }
+}
+
+
+
+const toastSuccess = () => Toasts.show({
+ type: Toasts.Type.SUCCESS,
+ message: "Settings successfully imported.",
+ id: Toasts.genId()
+});
+
+const toastFailure = (err: any) => Toasts.show({
+ type: Toasts.Type.FAILURE,
+ message: `Failed to import settings: ${String(err)}`,
+ id: Toasts.genId()
+});
diff --git a/src/equicordplugins/gifCollections/utils/uuidv4.ts b/src/equicordplugins/gifCollections/utils/uuidv4.ts
new file mode 100644
index 00000000..5ffe0891
--- /dev/null
+++ b/src/equicordplugins/gifCollections/utils/uuidv4.ts
@@ -0,0 +1,19 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export function uuidv4(prefix: string) {
+ let d = new Date().getTime();
+ d += performance.now();
+ return `${prefix}xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.replace(/[xy]/g, c => {
+ const r = (d + Math.random() * 16) % 16 | 0;
+ d = Math.floor(d / 16);
+ if (c === "x") {
+ return r.toString(16);
+ } else {
+ return ((r & 0x3) | 0x8).toString(16);
+ }
+ });
+}