From ff768de09c00a35914bcd8dcea8fe175d7f2f4e6 Mon Sep 17 00:00:00 2001 From: Creation's Date: Sat, 26 Oct 2024 12:47:28 -0400 Subject: [PATCH] feat(plugin): GifCollections (#81) * upload * shouldnt have been required * added gif buttons inside collections ( move, copy link, information ) --- src/equicordplugins/gifCollections/index.tsx | 742 ++++++++++++++++++ src/equicordplugins/gifCollections/style.css | 188 +++++ src/equicordplugins/gifCollections/types.ts | 35 + .../gifCollections/utils/cleanUrl.ts | 11 + .../gifCollections/utils/collectionManager.ts | 166 ++++ .../gifCollections/utils/getFormat.ts | 15 + .../gifCollections/utils/getGif.ts | 126 +++ .../gifCollections/utils/getUrlExtension.ts | 11 + .../gifCollections/utils/isAudio.ts | 14 + .../gifCollections/utils/settingsUtils.ts | 119 +++ .../gifCollections/utils/uuidv4.ts | 19 + 11 files changed, 1446 insertions(+) create mode 100644 src/equicordplugins/gifCollections/index.tsx create mode 100644 src/equicordplugins/gifCollections/style.css create mode 100644 src/equicordplugins/gifCollections/types.ts create mode 100644 src/equicordplugins/gifCollections/utils/cleanUrl.ts create mode 100644 src/equicordplugins/gifCollections/utils/collectionManager.ts create mode 100644 src/equicordplugins/gifCollections/utils/getFormat.ts create mode 100644 src/equicordplugins/gifCollections/utils/getGif.ts create mode 100644 src/equicordplugins/gifCollections/utils/getUrlExtension.ts create mode 100644 src/equicordplugins/gifCollections/utils/isAudio.ts create mode 100644 src/equicordplugins/gifCollections/utils/settingsUtils.ts create mode 100644 src/equicordplugins/gifCollections/utils/uuidv4.ts 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 + + +
+ 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} + +
+ + + +
+
+
+ ); +} 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); + } + }); +}