feat(plugin): GifCollections (#81)

* upload

* shouldnt  have been required

* added gif buttons inside collections ( move, copy link, information )
This commit is contained in:
Creation's 2024-10-26 12:47:28 -04:00 committed by GitHub
parent 40b645a66a
commit ff768de09c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1446 additions and 0 deletions

View file

@ -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(
<Menu.MenuItem
label="Add To Collection"
key="add-to-collection"
id="add-to-collection"
>
{collections.length > 0 && collections.map(col => (
<Menu.MenuItem
key={col.name}
id={col.name}
label={col.name.replace(/.+?:/, "")}
action={() => addToCollection(col.name, gif)}
/>
))}
{collections.length > 0 && <Menu.MenuSeparator key="separator" />}
<Menu.MenuItem
key="create-collection"
id="create-collection"
label="Create Collection"
action={() => {
openModal(modalProps => (
<CreateCollectionModal onClose={modalProps.onClose} gif={gif} modalProps={modalProps} />
));
}}
/>
</Menu.MenuItem>
);
}
};
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 (
<div className="collections-sort-container">
<Forms.FormTitle className="collections-sort-title">Sort Collections</Forms.FormTitle>
<Forms.FormDivider className="collections-sort-divider" />
<Forms.FormText className="collections-sort-description">
Choose a sorting criteria for your collections
</Forms.FormText>
<Forms.FormDivider className="collections-sort-divider" />
<div className="collections-sort-section">
<Forms.FormText className="collections-sort-section-title">Sort By</Forms.FormText>
<div className="collections-sort-option">
<label className="collections-sort-label">
<input
type="radio"
name="sortType"
value={SortingOptions.NAME}
checked={sortType === SortingOptions.NAME}
onChange={() => handleSortTypeChange(SortingOptions.NAME)}
className="collections-sort-input"
/>
Name
</label>
</div>
<div className="collections-sort-option">
<label className="collections-sort-label">
<input
type="radio"
name="sortType"
value={SortingOptions.CREATION_DATE}
checked={sortType === SortingOptions.CREATION_DATE}
onChange={() => handleSortTypeChange(SortingOptions.CREATION_DATE)}
className="collections-sort-input"
/>
Creation Date
</label>
</div>
<div className="collections-sort-option">
<label className="collections-sort-label">
<input
type="radio"
name="sortType"
value={SortingOptions.MODIFIED_DATE}
checked={sortType === SortingOptions.MODIFIED_DATE}
onChange={() => handleSortTypeChange(SortingOptions.MODIFIED_DATE)}
className="collections-sort-input"
/>
Modified Date
</label>
</div>
</div>
<Forms.FormDivider className="collections-sort-divider" />
<div className="collections-sort-section">
<Forms.FormText className="collections-sort-section-title">Order</Forms.FormText>
<div className="collections-sort-option">
<label className="collections-sort-label">
<input
type="radio"
name="sortOrder"
value="asc"
checked={sortOrder === "asc"}
onChange={() => handleSortOrderChange("asc")}
className="collections-sort-input"
/>
Ascending
</label>
</div>
<div className="collections-sort-option">
<label className="collections-sort-label">
<input
type="radio"
name="sortOrder"
value="desc"
checked={sortOrder === "desc"}
onChange={() => handleSortOrderChange("desc")}
className="collections-sort-input"
/>
Descending
</label>
</div>
</div>
</div>
);
}
},
importGifs: {
type: OptionType.COMPONENT,
description: "Import Collections",
component: () =>
<Button onClick={async () =>
(await getCollections()).length ? Alerts.show({
title: "Are you sure?",
body: "Importing collections will overwrite your current collections.",
confirmText: "Import",
confirmColor: Button.Colors.RED,
cancelText: "Nevermind",
onConfirm: async () => uploadGifCollections()
}) : uploadGifCollections()}>
Import Collections
</Button>,
},
exportGifs: {
type: OptionType.COMPONENT,
description: "Export Collections",
component: () =>
<Button onClick={downloadCollections}>
Export Collections
</Button>
},
resetCollections: {
type: OptionType.COMPONENT,
description: "Reset Collections",
component: () =>
<Button onClick={() =>
Alerts.show({
title: "Are you sure?",
body: "Resetting collections will remove all your collections.",
confirmText: "Reset",
confirmColor: Button.Colors.RED,
cancelText: "Nevermind",
onConfirm: async () => {
await DataStore.set(DATA_COLLECTION_NAME, []);
refreshCacheCollection();
}
})}>
Reset Collections
</Button>
}
});
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, () =>
<RemoveItemContextMenu type="collection" nameOrId={item.name} instance={instance} />
);
}
if (item?.id?.startsWith(GIF_ITEM_PREFIX)) {
return ContextMenuApi.openContextMenu(e, () =>
<RemoveItemContextMenu type="gif" nameOrId={item.id} instance={instance} />
);
}
const { src, url, height, width } = item;
if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX)) {
return ContextMenuApi.openContextMenu(e, () =>
<Menu.Menu
navId="gif-collection-id"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Gif Collections"
>
{MenuThingy({ gif: { ...item, id: uuidv4(GIF_ITEM_PREFIX) } })}
</Menu.Menu>
);
}
return null;
},
});
const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
<Menu.Menu
navId="gif-collection-id"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={type === "collection" ? "Delete Collection" : "Remove"}
>
{type === "collection" && (
<>
<Menu.MenuItem
key="collection-information"
id="collection-information"
label="Collection Information"
action={() => {
const collection = cache_collections.find(c => c.name === nameOrId);
if (!collection) return;
openModal(modalProps => (
<ModalRoot
{...modalProps}
size={ModalSize.SMALL}
transitionState={modalProps.transitionState}
className="custom-modal"
>
<ModalHeader separator={false} className="custom-modal-header">
<Forms.FormText className="custom-modal-title">Collection Information</Forms.FormText>
</ModalHeader>
<ModalContent className="custom-modal-content">
<Forms.FormSection>
<Flex className="collection-info">
<Forms.FormTitle tag="h5" className="collection-info-title">Name</Forms.FormTitle>
<Forms.FormText className="collection-info-text">{collection.name.replace(/.+?:/, "")}</Forms.FormText>
</Flex>
<Flex className="collection-info">
<Forms.FormTitle tag="h5" className="collection-info-title">Gifs</Forms.FormTitle>
<Forms.FormText className="collection-info-text">{collection.gifs.length}</Forms.FormText>
</Flex>
<Flex className="collection-info">
<Forms.FormTitle tag="h5" className="collection-info-title">Created At</Forms.FormTitle>
<Forms.FormText className="collection-info-text">{collection.createdAt ? new Date(collection.createdAt).toLocaleString() : "Unknown"}</Forms.FormText>
</Flex>
<Flex className="collection-info">
<Forms.FormTitle tag="h5" className="collection-info-title">Last Updated</Forms.FormTitle>
<Forms.FormText className="collection-info-text">{collection.lastUpdated ? new Date(collection.lastUpdated).toLocaleString() : "Unknown"}</Forms.FormText>
</Flex>
</Forms.FormSection>
</ModalContent>
<ModalFooter className="custom-modal-footer">
<Button onClick={modalProps.onClose} className="custom-modal-button">Close</Button>
</ModalFooter>
</ModalRoot>
));
}}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
key="rename-collection"
id="rename-collection"
label="Rename"
action={() => openModal(modalProps => (
<RenameCollectionModal
onClose={modalProps.onClose}
name={nameOrId}
modalProps={modalProps}
/>
))}
/>
</>
)}
{type === "gif" && (
<>
<Menu.MenuItem
key="gif-information"
id="gif-information"
label="Information"
action={() => {
const gifInfo = getGifById(nameOrId);
if (!gifInfo) return;
openModal(modalProps => (
<ModalRoot
{...modalProps}
size={ModalSize.SMALL}
transitionState={modalProps.transitionState}
className="custom-modal"
>
<ModalHeader separator={false} className="custom-modal-header">
<Forms.FormText className="custom-modal-title">Information</Forms.FormText>
</ModalHeader>
<ModalContent className="custom-modal-content">
<Forms.FormSection>
<Flex className="gif-info">
<Forms.FormTitle tag="h5" className="gif-info-title">Added At</Forms.FormTitle>
<Forms.FormText className="gif-info-text">{gifInfo.addedAt ? new Date(gifInfo.addedAt).toLocaleString() : "Unknown"}</Forms.FormText>
</Flex>
<Flex className="gif-info">
<Forms.FormTitle tag="h5" className="gif-info-title">Width</Forms.FormTitle>
<Forms.FormText className="gif-info-text">{gifInfo.width}</Forms.FormText>
</Flex>
<Flex className="gif-info">
<Forms.FormTitle tag="h5" className="gif-info-title">Height</Forms.FormTitle>
<Forms.FormText className="gif-info-text">{gifInfo.height}</Forms.FormText>
</Flex>
</Forms.FormSection>
</ModalContent>
<ModalFooter className="custom-modal-footer">
<Button onClick={modalProps.onClose} className="custom-modal-button">Close</Button>
</ModalFooter>
</ModalRoot>
));
}}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
key="copy-url"
id="copy-url"
label="Copy URL"
action={() => {
const gifInfo = getGifById(nameOrId);
if (!gifInfo) return;
Clipboard.copy(gifInfo.url);
showToast("URL copied to clipboard", Toasts.Type.SUCCESS);
}}
/>
<Menu.MenuItem
key="move-to-collection"
id="move-to-collection"
label="Move To Collection"
action={() => {
openModal(modalProps => (
<ModalRoot
{...modalProps}
size={ModalSize.SMALL}
transitionState={modalProps.transitionState}
className="custom-modal"
>
<ModalHeader separator={false} className="custom-modal-header">
<Forms.FormText className="custom-modal-title">Move To Collection</Forms.FormText>
</ModalHeader>
<ModalContent className="custom-modal-content">
<Forms.FormTitle tag="h5" className="custom-modal-text">
Select a collection to move the item to
</Forms.FormTitle>
<div className="collection-buttons">
{cache_collections
.filter(col => col.name !== getItemCollectionNameFromId(nameOrId))
.map(col => (
<Button
key={col.name}
id={col.name}
onClick={async () => {
const fromCollection = getItemCollectionNameFromId(nameOrId);
if (!fromCollection) return;
await moveGifToCollection(nameOrId, fromCollection, col.name);
FluxDispatcher.dispatch({
type: "GIF_PICKER_QUERY",
query: `${fromCollection} `
});
FluxDispatcher.dispatch({
type: "GIF_PICKER_QUERY",
query: `${fromCollection}`
});
modalProps.onClose();
}}
className="collection-button"
>
{col.name.replace(/.+?:/, "")}
</Button>
))}
</div>
</ModalContent>
<ModalFooter className="custom-modal-footer">
<Button onClick={modalProps.onClose} className="custom-modal-button">Close</Button>
</ModalFooter>
</ModalRoot>
));
}}
/>
<Menu.MenuSeparator />
</>
)}
<Menu.MenuItem
key="delete-collection"
id="delete-collection"
label={type === "collection" ? "Delete Collection" : "Remove"}
action={async () => {
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}`
});
}
}
});
}}
/>
</Menu.Menu>
);
const MenuThingy = ({ gif }) => {
const collections = cache_collections;
return (
<Menu.MenuItem label="Add To Collection" key="add-to-collection" id="add-to-collection">
{collections.map(col => (
<Menu.MenuItem
key={col.name}
id={col.name}
label={col.name.replace(/.+?:/, "")}
action={() => addToCollection(col.name, gif)}
/>
))}
{collections.length > 0 && <Menu.MenuSeparator />}
<Menu.MenuItem
key="create-collection"
id="create-collection"
label="Create Collection"
action={() => openModal(modalProps => (
<CreateCollectionModal onClose={modalProps.onClose} gif={gif} modalProps={modalProps} />
))}
/>
</Menu.MenuItem>
);
};
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 (
<ModalRoot {...modalProps}>
<form onSubmit={onSubmit}>
<ModalHeader>
<Forms.FormText>Create Collection</Forms.FormText>
</ModalHeader>
<ModalContent>
<Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Collection Name</Forms.FormTitle>
<TextInput onChange={e => setName(e)} />
</ModalContent>
<div style={{ marginTop: "1rem" }}>
<ModalFooter>
<Button
type="submit"
color={Button.Colors.GREEN}
disabled={!name.length}
onClick={onSubmit}
>
Create
</Button>
</ModalFooter>
</div>
</form>
</ModalRoot>
);
}
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 (
<ModalRoot {...modalProps}>
<form onSubmit={onSubmit}>
<ModalHeader>
<Forms.FormText>Rename Collection</Forms.FormText>
</ModalHeader>
<ModalContent>
<Forms.FormText className="rename-collection-text">New Collection Name</Forms.FormText>
<TextInput value={newName} className={`rename-collection-input ${newName.length >= 25 ? "input-warning" : ""}`} onChange={e => setNewName(e)} />
{newName.length >= 25 && <Forms.FormText className="warning-text">Name can't be longer than 24 characters</Forms.FormText>}
</ModalContent>
<div style={{ marginTop: "1rem" }}>
<ModalFooter>
<Button
type="submit"
color={Button.Colors.GREEN}
disabled={!newName.length || newName.length >= 25}
onClick={onSubmit}
>
Rename
</Button>
</ModalFooter>
</div>
</form>
</ModalRoot>
);
}

View file

@ -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;
}

View file

@ -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, K extends keyof T> = T & { [P in K]-?: T[P] };
export type Collection = WithRequired<Category, "gifs">;

View file

@ -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;
};

View file

@ -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<Collection[]> => (await DataStore.get<Collection[]>(DATA_COLLECTION_NAME)) ?? [];
export const getCollection = async (name: string): Promise<Collection | undefined> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
cache_collections = await getCollections();
};
export const fixPrefix = async (newPrefix: string): Promise<void> => {
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<void> => {
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();
};

View file

@ -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;
}

View file

@ -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));
}

View file

@ -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();
}

View file

@ -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);
}

View file

@ -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<void> {
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()
});

View file

@ -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);
}
});
}