From 91a756730194554517b67d7a1f04273cd01cf730 Mon Sep 17 00:00:00 2001 From: creations Date: Thu, 9 Apr 2026 17:01:36 -0400 Subject: [PATCH] gifcollections refactor --- .../components/contextMenus.tsx | 207 +++++ .../gifCollections/components/modals.tsx | 172 ++++ src/equicordplugins/gifCollections/index.tsx | 846 +++--------------- .../gifCollections/settings.tsx | 171 ++++ src/equicordplugins/gifCollections/style.css | 100 +-- src/equicordplugins/gifCollections/types.ts | 27 +- .../gifCollections/utils/cleanUrl.ts | 14 +- .../gifCollections/utils/collectionManager.ts | 262 +++--- .../gifCollections/utils/getFormat.ts | 3 +- .../gifCollections/utils/getGif.ts | 141 ++- .../gifCollections/utils/getUrlExtension.ts | 1 - .../gifCollections/utils/misc.ts | 15 + .../gifCollections/utils/refreshUrl.ts | 8 +- .../gifCollections/utils/settingsUtils.ts | 90 +- .../gifCollections/utils/uuidv4.ts | 12 +- 15 files changed, 938 insertions(+), 1131 deletions(-) create mode 100644 src/equicordplugins/gifCollections/components/contextMenus.tsx create mode 100644 src/equicordplugins/gifCollections/components/modals.tsx create mode 100644 src/equicordplugins/gifCollections/settings.tsx create mode 100644 src/equicordplugins/gifCollections/utils/misc.ts diff --git a/src/equicordplugins/gifCollections/components/contextMenus.tsx b/src/equicordplugins/gifCollections/components/contextMenus.tsx new file mode 100644 index 000000000..7b44bea7f --- /dev/null +++ b/src/equicordplugins/gifCollections/components/contextMenus.tsx @@ -0,0 +1,207 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { copyToClipboard } from "@utils/clipboard"; +import { Alerts, Button, ContextMenuApi, FluxDispatcher, Menu, showToast, Toasts } from "@webpack/common"; + +import { settings } from "../settings"; +import { Gif, GifItem, GifPickerInstance } from "../types"; +import { addToCollection, cache_collections, deleteCollection, getGifById, getItemCollectionNameFromId, removeFromCollection } from "../utils/collectionManager"; +import { getGif } from "../utils/getGif"; +import { stripPrefix } from "../utils/misc"; +import { uuidv4 } from "../utils/uuidv4"; +import { openCollectionInfoModal, openCreateCollectionModal, openGifInfoModal, openMoveToCollectionModal, openRenameCollectionModal } from "./modals"; + +function dispatchRefresh(collectionName: string) { + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: "" }); + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: collectionName }); +} + +function AddToCollectionMenu(gif: Gif) { + return ( + + {cache_collections.length > 0 && cache_collections.map(col => ( + addToCollection(col.name, gif)} + /> + ))} + {cache_collections.length > 0 && } + openCreateCollectionModal(gif)} + /> + + ); +} + +export 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")) return; + + if (settings.store.showCopyImageLink) { + group.push( + { + copyToClipboard(gif.url); + showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); + }} + /> + ); + } + + group.push(AddToCollectionMenu(gif)); +}; + +export function RemoveItemContextMenu({ type, nameOrId, instance }: { type: "collection" | "gif"; nameOrId: string; instance: { forceUpdate: () => void; }; }) { + return ( + 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) openCollectionInfoModal(collection); + }} + /> + + openRenameCollectionModal(nameOrId)} + /> + + )} + {type === "gif" && ( + <> + { + const gif = getGifById(nameOrId); + if (gif) openGifInfoModal(gif); + }} + /> + + { + const gif = getGifById(nameOrId); + if (!gif) return; + copyToClipboard(gif.url); + showToast("URL copied to clipboard", Toasts.Type.SUCCESS); + }} + /> + openMoveToCollectionModal(nameOrId)} + /> + + + )} + { + const doDelete = async () => { + if (type === "collection") { + deleteCollection(nameOrId); + instance.forceUpdate(); + } else { + const collectionName = getItemCollectionNameFromId(nameOrId); + await removeFromCollection(nameOrId); + if (collectionName) dispatchRefresh(collectionName); + } + }; + + if (settings.store.stopWarnings) { + doDelete(); + 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: doDelete, + }); + }} + /> + + ); +} + +export function GifPickerContextMenu({ gif }: { gif: Gif; }) { + return ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label="Gif Collections" + > + {settings.store.showCopyImageLink && ( + { + copyToClipboard(gif.url); + showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); + }} + /> + )} + {AddToCollectionMenu(gif)} + + ); +} + +export function buildGifPickerContextMenu(e: React.MouseEvent, item: GifItem, GIF_COLLECTION_PREFIX: string, GIF_ITEM_PREFIX: string, instance: GifPickerInstance) { + 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, () => + + ); + } + return null; +} diff --git a/src/equicordplugins/gifCollections/components/modals.tsx b/src/equicordplugins/gifCollections/components/modals.tsx new file mode 100644 index 000000000..3e36e52b6 --- /dev/null +++ b/src/equicordplugins/gifCollections/components/modals.tsx @@ -0,0 +1,172 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Flex } from "@components/Flex"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { classes } from "@utils/misc"; +import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Button, FluxDispatcher, TextInput, useCallback, useState } from "@webpack/common"; + +import { settings } from "../settings"; +import { Collection, Gif } from "../types"; +import { cache_collections, createCollection, getItemCollectionNameFromId, moveGifToCollection, renameCollection } from "../utils/collectionManager"; +import { cl, stripPrefix } from "../utils/misc"; + +export function openCollectionInfoModal(collection: Collection) { + openModal(props => ( + + )); +} + +export function openGifInfoModal(gif: Gif) { + openModal(props => ( + + )); +} + +export function openMoveToCollectionModal(gifId: string) { + openModal(props => ); +} + +export function openCreateCollectionModal(gif: Gif) { + openModal(props => ); +} + +export function openRenameCollectionModal(name: string) { + openModal(props => ); +} + +function InfoModal({ props, title, rows }: { props: ModalProps; title: string; rows: { label: string; value: string; }[]; }) { + return ( + + + {title} + + +
+ {rows.map(row => ( + + {row.label} + {row.value} + + ))} +
+
+ + + +
+ ); +} + +function MoveToCollectionModal({ props, gifId }: { props: ModalProps; gifId: string; }) { + return ( + + + Move To Collection + + + Select a collection to move the item to +
+ {cache_collections + .filter(col => col.name !== getItemCollectionNameFromId(gifId)) + .map(col => ( + + ))} +
+
+ + + +
+ ); +} + +function CreateCollectionModal({ props, gif }: { props: ModalProps; gif: Gif; }) { + const [name, setName] = useState(""); + const onSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + if (!name.length) return; + createCollection(name, [gif]); + props.onClose(); + }, [name, gif, props]); + + return ( + +
+ + Create Collection + + + Collection Name + + + + + +
+
+ ); +} + +function RenameCollectionModal({ props, name }: { props: ModalProps; name: string; }) { + const prefix = settings.store.collectionPrefix; + const strippedName = name.startsWith(prefix) ? name.slice(prefix.length) : name; + const [newName, setNewName] = useState(strippedName); + const tooLong = newName.length >= 25; + + const onSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.length || tooLong) return; + await renameCollection(name, newName); + props.onClose(); + }, [newName, name, tooLong, props]); + + return ( + +
+ + Rename Collection + + + New Collection Name + + {tooLong && Name can't be longer than 24 characters} + + + + +
+
+ ); +} diff --git a/src/equicordplugins/gifCollections/index.tsx b/src/equicordplugins/gifCollections/index.tsx index fad3c214e..54fb969a0 100644 --- a/src/equicordplugins/gifCollections/index.tsx +++ b/src/equicordplugins/gifCollections/index.tsx @@ -6,361 +6,81 @@ import "./style.css"; -import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; -import { DataStore } from "@api/index"; -import { definePluginSettings } from "@api/Settings"; -import { Divider } from "@components/Divider"; -import { Flex } from "@components/Flex"; -import { Heading } from "@components/Heading"; -import { Paragraph } from "@components/Paragraph"; -import { copyToClipboard } from "@utils/clipboard"; 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, ContextMenuApi, FluxDispatcher, Menu, React, showToast, TextInput, Toasts, useCallback, useState } from "@webpack/common"; +import definePlugin from "@utils/types"; +import { FluxDispatcher } from "@webpack/common"; -import { addToCollection, cache_collections, createCollection, DATA_COLLECTION_NAME, deleteCollection, fixPrefix, getCollections, getGifById, getItemCollectionNameFromId, moveGifToCollection, refreshCacheCollection, removeFromCollection, renameCollection, updateGif } from "./utils/collectionManager"; +import { addCollectionContextMenuPatch, buildGifPickerContextMenu } from "./components/contextMenus"; +import { settings, SortingOptions } from "./settings"; +import { Category, Collection, Gif, GifPickerInstance } from "./types"; +import { cache_collections, refreshCacheCollection, updateGif } from "./utils/collectionManager"; import { getFormat } from "./utils/getFormat"; -import { getGif } from "./utils/getGif"; +import { logger, stripPrefix } from "./utils/misc"; import { batchRefreshAttachmentUrls, isCdnUrlExpired } from "./utils/refreshUrl"; -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; - - if (settings.store.showCopyImageLink) { - group.push( - { - copyToClipboard(gif.url); - showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); - }} - /> - ); - } - - 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, - }, - showCopyImageLink: { - description: "Show 'Copy Image Link' option in context menus", - type: OptionType.BOOLEAN, - default: false, - }, - preventDuplicates: { - description: "Prevent adding the same GIF to a collection multiple times", - 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: () => - - } -}); +let refreshingUrls = false; +let oldTrendingCat: Category[] | null = null; export default definePlugin({ name: "GifCollections", - description: "Allows you to create collections of gifs", - authors: [Devs.Aria, EquicordDevs.creations], + description: "Allows you to create collections of gifs.", + authors: [EquicordDevs.creations], + settings, + contextMenus: { + "message": addCollectionContextMenuPatch, + }, + patches: [ { find: "renderCategoryExtras", replacement: [ { match: /(render\(\){)(.{1,50}getItemGrid)/, - replace: "$1;$self.insertCollections(this);$2" + replace: "$1;$self.insertCollections(this);$2", }, { match: /("span",\{className:\i\.\i,children:)(\i)/, - replace: "$1$self.hidePrefix($2)," + replace: "$1$self.hidePrefix($2),", }, - ] + ], }, { find: "renderEmptyFavorite", replacement: { match: /render\(\){.{1,500}onClick:this\.handleClick,/, - replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this)," - } + replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),", + }, }, { find: "renderHeaderContent()", - replacement: [ - { - match: /(renderContent\(\){)(.{1,50}resultItems)/, - replace: "$1$self.renderContent(this);$2" - }, - ] + 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" - } + replace: "$1if($self.shouldStopFetch(arguments[0])) return;$2", + }, }, ], - settings, - contextMenus: { - "message": addCollectionContextMenuPatch - }, + start() { refreshCacheCollection(); GIF_COLLECTION_PREFIX = settings.store.collectionPrefix; GIF_ITEM_PREFIX = settings.store.itemPrefix; }, - 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; + + sortedCollections(): Collection[] { + const sorted = [...cache_collections]; + const sortType = settings.store.collectionsSortType; + const sortOrder = settings.store.collectionsSortOrder === "asc" ? 1 : -1; + + return sorted.sort((a, b) => { switch (sortType) { case SortingOptions.NAME: return a.name.localeCompare(b.name) * sortOrder; @@ -373,448 +93,84 @@ export default definePlugin({ } }); }, - 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(); - const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url))); - if (expiredGifs.length > 0) { - const allUrls = [...new Set( - expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u))) - )]; - const refreshInBatches = async () => { - const fullMap: Record = {}; - for (let i = 0; i < allUrls.length; i += 50) { - const batch = allUrls.slice(i, i + 50); - const result = await batchRefreshAttachmentUrls(batch); - Object.assign(fullMap, result); - } - return fullMap; - }; - refreshInBatches().then(async refreshMap => { - if (!Object.keys(refreshMap).length) return; - let anyUpdated = false; - for (const gif of expiredGifs) { - const newSrc = refreshMap[gif.src] ?? gif.src; - const newUrl = refreshMap[gif.url] ?? gif.url; - if (newSrc !== gif.src || newUrl !== gif.url) { - await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl }); - anyUpdated = true; - } - } - if (!anyUpdated) return; - const { query } = instance.props; - FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: `${query} ` }); - await new Promise(r => setTimeout(r, 2500)); - FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query }); - }); + renderContent(instance: GifPickerInstance) { + if (!instance.props.query.startsWith(GIF_COLLECTION_PREFIX)) return; + + const collection = cache_collections.find(c => c.name === instance.props.query); + if (!collection) return; + + 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(); + + const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url))); + if (expiredGifs.length === 0) return; + + const allUrls = [...new Set( + expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u))) + )]; + + if (!refreshingUrls) this.refreshExpiredUrls(allUrls, expiredGifs, instance.props.query); + }, + + async refreshExpiredUrls(urls: string[], expiredGifs: Gif[], query: string) { + refreshingUrls = true; + try { + const fullMap: Record = {}; + for (let i = 0; i < urls.length; i += 50) { + const result = await batchRefreshAttachmentUrls(urls.slice(i, i + 50)); + Object.assign(fullMap, result); + } + + if (!Object.keys(fullMap).length) return; + + let anyUpdated = false; + for (const gif of expiredGifs) { + const newSrc = fullMap[gif.src] ?? gif.src; + const newUrl = fullMap[gif.url] ?? gif.url; + if (newSrc !== gif.src || newUrl !== gif.url) { + await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl }); + anyUpdated = true; } } + + if (!anyUpdated) return; + + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: "" }); + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query }); + } finally { + refreshingUrls = false; } }, - hidePrefix(name) { - return name.includes(":") ? name.replace(/.+?:/, "") : name; - }, - insertCollections(instance) { - const shouldRemoveAll = settings.store.onlyShowCollections; + + hidePrefix: stripPrefix, + + insertCollections(instance: GifPickerInstance) { try { if (instance.props.trendingCategories.length && instance.props.trendingCategories[0].type === "Trending") { - this.oldTrendingCat = instance.props.trendingCategories; + 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); + if (settings.store.onlyShowCollections) { + instance.props.trendingCategories = [...this.sortedCollections(), ...instance.props.favorites]; + } else if (oldTrendingCat != null) { + instance.props.trendingCategories = [...this.sortedCollections(), ...oldTrendingCat]; } } catch (err) { - console.error(err); + logger.error("Failed to insert collections", err); } }, - shouldStopFetch(query) { - return query.startsWith(GIF_COLLECTION_PREFIX) && this.collections.find(c => c.name === query) != null; + + shouldStopFetch(query: string) { + return query.startsWith(GIF_COLLECTION_PREFIX) && cache_collections.some(c => c.name === query); }, - 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; + + collectionContextMenu(e: React.MouseEvent, instance: GifPickerInstance) { + return buildGifPickerContextMenu(e, instance.props.item, GIF_COLLECTION_PREFIX, GIF_ITEM_PREFIX, instance); }, }); - -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; - copyToClipboard(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; - const menuItems: React.ReactNode[] = []; - - if (settings.store.showCopyImageLink) { - menuItems.push( - { - copyToClipboard(gif.url); - showToast("Image link copied to clipboard", Toasts.Type.SUCCESS); - }} - /> - ); - } - - menuItems.push( - - {collections.map(col => ( - addToCollection(col.name, gif)} - /> - ))} - {collections.length > 0 && } - openModal(modalProps => ( - - ))} - /> - - ); - - return <>{menuItems}; -}; - -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} - -
- - - -
-
-
- ); -} diff --git a/src/equicordplugins/gifCollections/settings.tsx b/src/equicordplugins/gifCollections/settings.tsx new file mode 100644 index 000000000..6752eb975 --- /dev/null +++ b/src/equicordplugins/gifCollections/settings.tsx @@ -0,0 +1,171 @@ +/* + * 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 { definePluginSettings } from "@api/Settings"; +import { Divider } from "@components/Divider"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { OptionType } from "@utils/types"; +import { Alerts, Button, useState } from "@webpack/common"; + +import { DATA_COLLECTION_NAME, getCollections, refreshCacheCollection } from "./utils/collectionManager"; +import { cl } from "./utils/misc"; +import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils"; + +export const SortingOptions = { + NAME: 1, + CREATION_DATE: 2, + MODIFIED_DATE: 3, +} as const; + +export const settings = definePluginSettings({ + itemPrefix: { + description: "The prefix for gif items.", + type: OptionType.STRING, + default: "gc-item:", + restartNeeded: true, + }, + collectionPrefix: { + description: "The prefix for collections.", + type: OptionType.STRING, + default: "gc:", + restartNeeded: true, + }, + onlyShowCollections: { + description: "Only show collections in the GIF picker.", + type: OptionType.BOOLEAN, + default: false, + restartNeeded: true, + }, + stopWarnings: { + description: "Skip deletion confirmation dialogs.", + type: OptionType.BOOLEAN, + default: false, + }, + showCopyImageLink: { + description: "Show copy image link option in context menus.", + type: OptionType.BOOLEAN, + default: false, + }, + preventDuplicates: { + description: "Prevent adding the same GIF to a collection multiple times.", + type: OptionType.BOOLEAN, + default: false, + }, + defaultEmptyCollectionImage: { + description: "The image shown when a collection has no images.", + 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: SortSettingsComponent, + }, + importGifs: { + type: OptionType.COMPONENT, + description: "Import collections.", + component: () => + , + }, + exportGifs: { + type: OptionType.COMPONENT, + description: "Export collections.", + component: () => + , + }, + resetCollections: { + type: OptionType.COMPONENT, + description: "Reset collections.", + component: () => + , + }, +}); + +function RadioOption({ name, value, checked, onChange, label }: { name: string; value: string | number; checked: boolean; onChange: () => void; label: string; }) { + return ( +
+ +
+ ); +} + +function SortSettingsComponent() { + const [sortType, setSortType] = useState(settings.store.collectionsSortType ?? SortingOptions.NAME); + const [sortOrder, setSortOrder] = useState(settings.store.collectionsSortOrder ?? "asc"); + + const handleSortType = (value: number) => { + setSortType(value); + settings.store.collectionsSortType = value; + }; + + const handleSortOrder = (value: string) => { + setSortOrder(value); + settings.store.collectionsSortOrder = value; + }; + + return ( +
+ Sort Collections + + Choose a sorting criteria for your collections + +
+ Sort By + handleSortType(SortingOptions.NAME)} label="Name" /> + handleSortType(SortingOptions.CREATION_DATE)} label="Creation Date" /> + handleSortType(SortingOptions.MODIFIED_DATE)} label="Modified Date" /> +
+ +
+ Order + handleSortOrder("asc")} label="Ascending" /> + handleSortOrder("desc")} label="Descending" /> +
+
+ ); +} diff --git a/src/equicordplugins/gifCollections/style.css b/src/equicordplugins/gifCollections/style.css index a729e604d..313036e52 100644 --- a/src/equicordplugins/gifCollections/style.css +++ b/src/equicordplugins/gifCollections/style.css @@ -1,41 +1,41 @@ -.collections-sort-container { +.vc-gif-collections-sort-container { padding: 12px; background-color: var(--background-secondary-alt); border-radius: 8px; margin-bottom: 16px; } -.collections-sort-title { +.vc-gif-collections-sort-title { font-size: 16px; font-weight: 600; color: var(--text-strong); margin-bottom: 8px; } -.collections-sort-divider { +.vc-gif-collections-sort-divider { margin: 10px 0; - border-top: 1px solid var(--input-border-default, var(--input-border)); + border-top: 1px solid var(--border-subtle); } -.collections-sort-description, -.collections-sort-section-title { +.vc-gif-collections-sort-description, +.vc-gif-collections-sort-section-title { font-size: 14px; font-weight: 500; color: var(--text-default); margin-bottom: 8px; } -.collections-sort-section { +.vc-gif-collections-sort-section { margin-top: 12px; } -.collections-sort-option { +.vc-gif-collections-sort-option { display: flex; align-items: center; margin-bottom: 8px; } -.collections-sort-label { +.vc-gif-collections-sort-label { font-size: 14px; font-weight: 400; color: var(--text-default); @@ -44,43 +44,41 @@ align-items: center; } -.collections-sort-input { +.vc-gif-collections-sort-input { margin-right: 10px; cursor: pointer; accent-color: var(--brand-experiment); } -.collections-sort-label:hover { +.vc-gif-collections-sort-label:hover { color: var(--text-strong); } -.custom-modal { - background-color: var(--background-primary); +.vc-gif-collections-modal { + background-color: var(--modal-background) !important; border-radius: 8px; - box-shadow: 0 8px 16px rgb(0 0 0 / 24%), 0 16px 32px rgb(0 0 0 / 24%); } -.custom-modal-header { +.vc-gif-collections-modal-header { padding: 16px; background-color: var(--background-secondary-alt); - border-bottom: 1px solid var(--background-base-low); + border-bottom: 1px solid var(--border-subtle); } -.custom-modal-title { +.vc-gif-collections-modal-title { font-size: 16px; font-weight: 600; color: var(--text-strong); } -.custom-modal-content { +.vc-gif-collections-modal-content { padding: 16px; display: flex; flex-direction: column; gap: 16px; } -.gif-info, -.collection-info { +.vc-gif-collections-info-row { display: flex; justify-content: space-between; align-items: center; @@ -88,17 +86,17 @@ padding: 8px 0; } -.collection-info:last-child { +.vc-gif-collections-info-row:last-child { border-bottom: none; } -.collection-info-title { +.vc-gif-collections-info-title { font-size: 14px; font-weight: 500; color: var(--text-default); } -.collection-info-text { +.vc-gif-collections-info-text { font-size: 14px; color: var(--text-muted); max-width: 70%; @@ -107,82 +105,54 @@ text-overflow: ellipsis; } -.custom-modal-footer { +.vc-gif-collections-modal-footer { padding: 16px; background-color: var(--background-secondary-alt); 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 { +.vc-gif-collections-rename-text { font-size: 14px; font-weight: 500; color: var(--text-default); margin: 16px 0 2px; } -.rename-collection-input { +.vc-gif-collections-rename-input { outline: 0 solid transparent; transition: outline 0.2s; border-radius: 4px; } -.input-warning { +.vc-gif-collections-input-warning { outline: 1px solid var(--text-feedback-critical); } -.warning-text { +.vc-gif-collections-warning-text { margin-top: 16px; font-size: 14px; color: var(--text-feedback-critical); } -.collection-buttons { +.vc-gif-collections-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-faint); - border-radius: 4px; - background-color: var(--background-secondary-alt); - color: var(--text-default); - outline: none; -} - -.collection-button { +.vc-gif-collections-button { text-align: center; padding: 8px; - background-color: var(--background-base-low); + background-color: var(--background-secondary); color: var(--text-default); border: none; border-radius: 4px; cursor: pointer; - transition: background-color 0.2s; -} \ No newline at end of file + transition: background-color 0.15s; +} + +.vc-gif-collections-button:hover { + background-color: var(--background-modifier-hover); +} diff --git a/src/equicordplugins/gifCollections/types.ts b/src/equicordplugins/gifCollections/types.ts index 8b1e8ef27..5d94f6d2e 100644 --- a/src/equicordplugins/gifCollections/types.ts +++ b/src/equicordplugins/gifCollections/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -export enum Format { NONE = 0, IMAGE = 1, VIDEO = 2 } +export enum Format { IMAGE = 1, VIDEO = 2 } export interface Category { type: "Trending" | "Category"; @@ -28,11 +28,26 @@ export interface Gif { attachmentId?: string; } -export interface Props { - favorites: { [src: string]: any; }; - trendingCategories: Category[]; -} - type WithRequired = T & { [P in K]-?: T[P] }; export type Collection = WithRequired; + +export interface GifPickerInstance { + props: { + query: string; + resultItems: { id: string; format: Format; src: string; url: string; width: number; height: number; }[]; + trendingCategories: Category[]; + favorites: Category[]; + item: { name?: string; id?: string; src?: string; url?: string; height?: number; width?: number; }; + }; + forceUpdate: () => void; +} + +export interface GifItem { + name?: string; + id?: string; + src?: string; + url?: string; + height?: number; + width?: number; +} diff --git a/src/equicordplugins/gifCollections/utils/cleanUrl.ts b/src/equicordplugins/gifCollections/utils/cleanUrl.ts index 415598487..edba2eac2 100644 --- a/src/equicordplugins/gifCollections/utils/cleanUrl.ts +++ b/src/equicordplugins/gifCollections/utils/cleanUrl.ts @@ -4,8 +4,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -export const cleanUrl = (url: string) => { - const urlObject = new URL(url); - urlObject.search = ""; - return urlObject.href; -}; +export function cleanUrl(url: string): string { + try { + const urlObject = new URL(url); + urlObject.search = ""; + return urlObject.href; + } catch { + return url; + } +} diff --git a/src/equicordplugins/gifCollections/utils/collectionManager.ts b/src/equicordplugins/gifCollections/utils/collectionManager.ts index d7617dab8..eafcc839b 100644 --- a/src/equicordplugins/gifCollections/utils/collectionManager.ts +++ b/src/equicordplugins/gifCollections/utils/collectionManager.ts @@ -5,192 +5,142 @@ */ import { DataStore } from "@api/index"; -import { settings } from "@equicordplugins/gifCollections/index"; -import { Collection, Gif } from "@equicordplugins/gifCollections/types"; import { Toasts } from "@webpack/common"; +import { settings } from "../settings"; +import { Collection, Gif } from "../types"; import { getFormat } from "./getFormat"; +import { logger } from "./misc"; 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"); - - if (settings.store.preventDuplicates) { - const isDuplicate = collections[collectionIndex].gifs.some(g => g.url === gif.url); - if (isDuplicate) { - return Toasts.show({ - message: "This GIF is already in the collection", - type: Toasts.Type.FAILURE, - id: Toasts.genId(), - options: { - duration: 3000, - position: Toasts.Position.BOTTOM - } - }); - } - } - - 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(); - +async function saveCollections(collections: Collection[]) { + cache_collections = collections; 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(/:+$/, "") + ":"; +export async function createCollection(name: string, gifs: Gif[]): Promise { + const collections = [...cache_collections]; + const fullName = `${settings.store.collectionPrefix}${name}`; - const collections = await getCollections(); + if (collections.some(c => c.name === fullName)) { + Toasts.show({ message: "That collection already exists", type: Toasts.Type.FAILURE, id: Toasts.genId(), options: { duration: 3000, position: Toasts.Position.BOTTOM } }); + return; + } - collections.forEach(c => { - const nameParts = c.name.split(":"); - c.name = `${normalizedPrefix}${nameParts[nameParts.length - 1]}`; + const latestGifSrc = gifs[gifs.length - 1]?.src ?? settings.store.defaultEmptyCollectionImage; + collections.push({ + name: fullName, + src: latestGifSrc, + format: getFormat(latestGifSrc), + type: "Category", + gifs, + createdAt: Date.now(), + lastUpdated: Date.now(), }); - await DataStore.set(DATA_COLLECTION_NAME, collections); - await refreshCacheCollection(); -}; + await saveCollections(collections); +} -export const getItemCollectionNameFromId = (id: string): string | undefined => { - const collections = cache_collections; +export async function addToCollection(name: string, gif: Gif): Promise { + const collections = [...cache_collections]; + const collection = collections.find(c => c.name === name); + if (!collection) return void logger.warn("Collection not found"); + + if (settings.store.preventDuplicates && collection.gifs.some(g => g.url === gif.url)) { + Toasts.show({ message: "This GIF is already in the collection", type: Toasts.Type.FAILURE, id: Toasts.genId(), options: { duration: 3000, position: Toasts.Position.BOTTOM } }); + return; + } + + collection.gifs = [...collection.gifs, { ...gif, addedAt: Date.now() }]; + collection.src = gif.src; + collection.format = getFormat(gif.src); + collection.lastUpdated = Date.now(); + + await saveCollections(collections); +} + +export async function renameCollection(oldName: string, newName: string): Promise { + const collections = [...cache_collections]; + const collection = collections.find(c => c.name === oldName); + if (!collection) return void logger.warn("Collection not found"); + + collection.name = `${settings.store.collectionPrefix}${newName}`; + collection.lastUpdated = Date.now(); + + await saveCollections(collections); +} + +export async function removeFromCollection(id: string): Promise { + const collections = [...cache_collections]; const collection = collections.find(c => c.gifs.some(g => g.id === id)); - return collection?.name; -}; + if (!collection) return void logger.warn("Collection not found"); -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; -}; + collection.gifs = collection.gifs.filter(g => g.id !== id); + const latestGifSrc = collection.gifs.length ? collection.gifs[collection.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; + collection.src = latestGifSrc; + collection.format = getFormat(latestGifSrc); + collection.lastUpdated = Date.now(); -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"); + await saveCollections(collections); +} - const gifIndex = collections[fromCollectionIndex].gifs.findIndex(g => g.id === gifId); - if (gifIndex === -1) return console.warn("gif not found"); +export async function deleteCollection(name: string): Promise { + await saveCollections(cache_collections.filter(c => c.name !== name)); +} - const gif = collections[fromCollectionIndex].gifs[gifIndex]; - gif.addedAt = Date.now(); - collections[fromCollectionIndex].gifs.splice(gifIndex, 1); - collections[toCollectionIndex].gifs.push(gif); +export async function moveGifToCollection(gifId: string, fromName: string, toName: string): Promise { + const collections = [...cache_collections]; + const from = collections.find(c => c.name === fromName); + const to = collections.find(c => c.name === toName); + if (!from || !to) return void logger.warn("Collection not found"); - 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 gifIndex = from.gifs.findIndex(g => g.id === gifId); + if (gifIndex === -1) return void logger.warn("Gif not found"); - 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(); + const gif = { ...from.gifs[gifIndex], addedAt: Date.now() }; + from.gifs = from.gifs.filter((_, i) => i !== gifIndex); + to.gifs = [...to.gifs, gif]; - await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; + const updateCollectionMeta = (col: Collection) => { + const latest = col.gifs.length ? col.gifs[col.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage; + col.src = latest; + col.format = getFormat(latest); + col.lastUpdated = Date.now(); + }; -export const updateGif = async (gifId: string, updatedGif: Gif): Promise => { - const collections = await getCollections(); - const collectionIndex = collections.findIndex(c => c.gifs.some(g => g.id === gifId)); - if (collectionIndex === -1) return console.warn("collection not found"); + updateCollectionMeta(from); + updateCollectionMeta(to); - const gifIndex = collections[collectionIndex].gifs.findIndex(g => g.id === gifId); - if (gifIndex === -1) return console.warn("gif not found"); + await saveCollections(collections); +} - collections[collectionIndex].gifs[gifIndex] = updatedGif; - collections[collectionIndex].lastUpdated = Date.now(); +export async function updateGif(gifId: string, updatedGif: Gif): Promise { + const collections = [...cache_collections]; + const collection = collections.find(c => c.gifs.some(g => g.id === gifId)); + if (!collection) return void logger.warn("Collection not found"); - await DataStore.set(DATA_COLLECTION_NAME, collections); - return await refreshCacheCollection(); -}; + const gifIndex = collection.gifs.findIndex(g => g.id === gifId); + if (gifIndex === -1) return void logger.warn("Gif not found"); + + collection.gifs = collection.gifs.map((g, i) => i === gifIndex ? updatedGif : g); + collection.lastUpdated = Date.now(); + + await saveCollections(collections); +} + +export function getItemCollectionNameFromId(id: string): string | undefined { + return cache_collections.find(c => c.gifs.some(g => g.id === id))?.name; +} + +export function getGifById(id: string): Gif | undefined { + return cache_collections.flatMap(c => c.gifs).find(g => g.id === id); +} diff --git a/src/equicordplugins/gifCollections/utils/getFormat.ts b/src/equicordplugins/gifCollections/utils/getFormat.ts index 3ab90b8ec..238a2d781 100644 --- a/src/equicordplugins/gifCollections/utils/getFormat.ts +++ b/src/equicordplugins/gifCollections/utils/getFormat.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Format } from "@equicordplugins/gifCollections/types"; - +import { Format } from "../types"; import { getUrlExtension } from "./getUrlExtension"; const videoExtensions = ["mp4", "ogg", "webm", "avi", "wmv", "flv", "mov", "mkv", "m4v"]; diff --git a/src/equicordplugins/gifCollections/utils/getGif.ts b/src/equicordplugins/gifCollections/utils/getGif.ts index caf41cc68..f1f3a2a08 100644 --- a/src/equicordplugins/gifCollections/utils/getGif.ts +++ b/src/equicordplugins/gifCollections/utils/getGif.ts @@ -4,61 +4,58 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { settings } from "@equicordplugins/gifCollections/index"; -import { Gif } from "@equicordplugins/gifCollections/types"; import { Message } from "@vencord/discord-types"; import { MessageStore, SnowflakeUtils } from "@webpack/common"; +import { settings } from "../settings"; +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 { +function isValidSnowflake(snowflake: string): boolean { + return !Number.isNaN(SnowflakeUtils.extractTimestamp(snowflake)); +} + +function getGifByTarget(url: string, target?: HTMLElement | null): Gif | null { const liElement = target?.closest("li"); - if (!target || !liElement || !liElement.id) return null; + if (!target || !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; + 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; + 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; - +function getGifByMessageAndTarget(target: HTMLElement, message: Message): Gif | null { + const wrapper = target.closest("[class*=\"imageWrapper\"]"); + const url = wrapper?.querySelector("video")?.src ?? wrapper?.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; +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 - ); + const urls = [ + e.url && cleanUrl(e.url), + e.image && cleanUrl(e.image.url), + e.image?.proxyURL, + e.video?.proxyURL, + e.thumbnail?.proxyURL, + ]; + return urls.some(u => u === cleanedUrl); }); + if (embed) { - if (embed.image) + if (embed.image) { return { id: uuidv4(settings.store.itemPrefix), height: embed.image.height, @@ -66,62 +63,52 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null 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, - }; + } + if (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, + }; + } + if (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, - channelId: message.channel_id, - messageId: message.id, - attachmentId: attachment.id - }; + if (attachment) { + return { + id: uuidv4(settings.store.itemPrefix), + height: attachment.height ?? 50, + width: attachment.width ?? 50, + src: attachment.proxy_url, + url: attachment.url, + channelId: message.channel_id, + messageId: message.id, + attachmentId: attachment.id, + }; + } 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; - } +export function getGif(message: Message | null, url: string | null, target: HTMLElement | null): Gif | null { + if (message && url) return getGifByMessageAndUrl(url, message); + if (message && target && !url) return getGifByMessageAndTarget(target, message); 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; + const resolvedUrl = url.startsWith("https://discord.com/") + ? target.parentElement?.querySelector("img")?.src ?? url + : url; + return getGifByTarget(resolvedUrl, target); } -}; - -function isValidSnowFlake(snowflake: string) { - return !Number.isNaN(SnowflakeUtils.extractTimestamp(snowflake)); + return null; } diff --git a/src/equicordplugins/gifCollections/utils/getUrlExtension.ts b/src/equicordplugins/gifCollections/utils/getUrlExtension.ts index 05ca07165..5f33fec5a 100644 --- a/src/equicordplugins/gifCollections/utils/getUrlExtension.ts +++ b/src/equicordplugins/gifCollections/utils/getUrlExtension.ts @@ -5,7 +5,6 @@ */ 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/misc.ts b/src/equicordplugins/gifCollections/utils/misc.ts new file mode 100644 index 000000000..685ed08b0 --- /dev/null +++ b/src/equicordplugins/gifCollections/utils/misc.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 { classNameFactory } from "@utils/css"; +import { Logger } from "@utils/Logger"; + +export const cl = classNameFactory("vc-gif-collections-"); +export const logger = new Logger("GifCollections"); + +export function stripPrefix(name: string): string { + return name.includes(":") ? name.replace(/.+?:/, "") : name; +} diff --git a/src/equicordplugins/gifCollections/utils/refreshUrl.ts b/src/equicordplugins/gifCollections/utils/refreshUrl.ts index c07922ca5..76d25cdbf 100644 --- a/src/equicordplugins/gifCollections/utils/refreshUrl.ts +++ b/src/equicordplugins/gifCollections/utils/refreshUrl.ts @@ -6,12 +6,15 @@ import { RestAPI } from "@webpack/common"; +import { logger } from "./misc"; + export function isCdnUrlExpired(url: string): boolean { try { const ex = new URL(url).searchParams.get("ex"); if (!ex) return false; return parseInt(ex, 16) * 1000 < Date.now(); - } catch { + } catch (e) { + logger.warn("Failed to parse CDN URL expiry", e); return false; } } @@ -28,7 +31,8 @@ export async function batchRefreshAttachmentUrls(urls: string[]): Promise { - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - }); + if (IS_DISCORD_DESKTOP) { + DiscordNative.fileManager.saveWithDialog(data, "gif-collections.json"); } else { - DiscordNative.fileManager.saveWithDialog(data, filename); + saveFile(new File([data], "gif-collections.json", { type: "application/json" })); } - } export async function exportCollections() { @@ -40,53 +28,23 @@ export async function exportCollections() { } 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)); - } + const parsed = JSON.parse(data); if ("collections" in parsed) { await DataStore.set(DATA_COLLECTION_NAME, parsed.collections); await refreshCacheCollection(); - } else - throw new Error("Invalid Collections"); + } else { + throw new Error("Invalid collections format"); + } } export async function uploadGifCollections(showToast = true): Promise { - if (IS_WEB || IS_EQUIBOP || IS_VESKTOP) { - 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 { + if (IS_DISCORD_DESKTOP) { const [file] = await DiscordNative.fileManager.openFiles({ filters: [ { name: "Gif Collections", extensions: ["json"] }, - { name: "all", extensions: ["*"] } - ] + { name: "all", extensions: ["*"] }, + ], }); if (file) { @@ -94,22 +52,32 @@ export async function uploadGifCollections(showToast = true): Promise { await importCollections(new TextDecoder().decode(file.data)); if (showToast) toastSuccess(); } catch (err) { - console.error(err); - // new Logger("SettingsSync").error(err); + logger.error("Failed to import collections", err); if (showToast) toastFailure(err); } } + } else { + const file = await chooseFile("application/json"); + if (!file) return; + + try { + await importCollections(await file.text()); + if (showToast) toastSuccess(); + } catch (err) { + logger.error("Failed to import collections", err); + if (showToast) toastFailure(err); + } } } const toastSuccess = () => Toasts.show({ type: Toasts.Type.SUCCESS, - message: "Settings successfully imported.", - id: Toasts.genId() + message: "Collections imported successfully.", + id: Toasts.genId(), }); -const toastFailure = (err: any) => Toasts.show({ +const toastFailure = (err: unknown) => Toasts.show({ type: Toasts.Type.FAILURE, - message: `Failed to import settings: ${String(err)}`, - id: Toasts.genId() + message: `Failed to import collections: ${String(err)}`, + id: Toasts.genId(), }); diff --git a/src/equicordplugins/gifCollections/utils/uuidv4.ts b/src/equicordplugins/gifCollections/utils/uuidv4.ts index 5ffe08916..0dae663d3 100644 --- a/src/equicordplugins/gifCollections/utils/uuidv4.ts +++ b/src/equicordplugins/gifCollections/utils/uuidv4.ts @@ -5,15 +5,5 @@ */ 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); - } - }); + return `${prefix}${crypto.randomUUID()}`; }