/* * 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
Sort By
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 (
Create Collection Collection Name setName(e)} />
); } 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 (
Rename Collection New Collection Name = 25 ? "input-warning" : ""}`} onChange={e => setNewName(e)} /> {newName.length >= 25 && Name can't be longer than 24 characters}
); }