Equicord/src/equicordplugins/favouriteAnything/utils.tsx
2026-10-03 11:16:17 -04:00

353 lines
14 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@utils/css";
import { sendMessage } from "@utils/discord";
import { proxyLazy } from "@utils/lazy";
import { Queue } from "@utils/Queue";
import { useForceUpdater } from "@utils/react";
import { PluginNative } from "@utils/types";
import { Channel, MessageAttachment } from "@vencord/discord-types";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { Constants, createRoot, DraftType, FluxDispatcher, Humanize, MessageActions, PendingReplyStore, PermissionStore, ReactDOM, RestAPI, showToast,UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { Key, ReactNode } from "react";
import { JsonValue } from "type-fest";
import { settings } from ".";
import { StaticFilePickerItem } from "./components";
import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types";
const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative<typeof import("./native")>;
export const cl = classNameFactory("vc-favouriteAnything-");
export const useResizeObserver: ResizeObserverHook = findByCodeLazy("borderBoxSize", "blockSize", "inlineSize");
export const ImageUtils: ImageUtils_ = findByPropsLazy("isAnimated", "getFormatQuality");
export const transformAttachment: AttachmentTransformer = findByCodeLazy("return{uniqueId", ".IS_ANIMATED");
const encoder = new TextEncoder(), decoder = new TextDecoder();
const defineItem = <const A, const B extends JsonValue>(item: CustomItemDef<A, B>) => item;
function defineItems<T extends Record<CustomItemFormat, CustomItemDef>>(def: ItemsDef<T>) {
type Type<F extends CustomItemFormat> = T[F] extends CustomItemDef<infer A> ? A : never;
return {
encode: <F extends CustomItemFormat>(format: F, data: Type<F>) => {
try {
const obj = [format, def[format].encode(data)];
const buf = deflateSync(encoder.encode(JSON.stringify(obj)));
return buf.toBase64({ alphabet: "base64url", omitPadding: true });
} catch {
return null;
}
},
decode: (raw: string) => {
try {
if (!raw) return null;
const buf = inflateSync(Uint8Array.fromBase64(raw, { alphabet: "base64url" }));
const parsed: unknown[] | null = JSON.parse(decoder.decode(buf));
if (!Array.isArray(parsed)) return null;
const [format, data] = parsed as [keyof typeof def, JsonValue];
if (!(format in def)) return null;
return { format, data: def[format].decode(data) } as {
[F in CustomItemFormat]: { format: F; data: Type<F>; };
}[CustomItemFormat];
} catch {
return null;
}
},
stringify: <F extends CustomItemFormat>(format: F, item: Type<F>) => def[format].stringify(item)
};
}
// Encode/Decode definitions for custom favourite items.
// The encode callback must return a json compatible object, preferably as compact as possible.
// Decode must recreate the original object based on the encoded value.
// Stringify returns a simple string representation used for thumbnail text and expression picker search.
export const defs = defineItems({
[CustomItemFormat.ATTACHMENT]: defineItem({
encode: ({ id, filename, size, url, content_type = "", title, description }: MessageAttachment) => [
id,
filename,
size,
new URL(url).pathname,
content_type,
title ?? null,
description ?? null
],
decode: ([id, filename, size, path, content_type, title, description]) => ({
id: id ?? "0",
filename: filename ?? "UNKNOWN",
size: +size! || 0,
url: `${new URL(path!, `https://${window.GLOBAL_ENV.CDN_HOST}`)}`,
proxy_url: `${new URL(path!, `https://${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}`)}`,
content_type: content_type ?? "application/octet-stream",
spoiler: filename?.startsWith("SPOILER_") ?? false,
title: title ?? undefined,
description: description ?? undefined
}),
stringify: ({ title, filename }) => title?.trim() || filename
})
// This could be expanded in the future with other item types (e.g. voice messages)
});
export function getFilenameAndExtension(filename: string): [name: string, ext: string | null] {
const ext = filename.lastIndexOf(".");
return ext > 0 ? [filename.substring(0, ext), filename.substring(ext)] : [filename, null];
}
function renderToHTML(node: ReactNode): Promise<string> {
return new Promise(resolve => {
const container = document.createElement("div");
const root = createRoot(container);
queueMicrotask(() => {
ReactDOM.flushSync(() => root.render(node));
resolve(container.innerHTML);
root.unmount();
});
});
}
export const FALLBACK_THUMBNAIL = new URL("https://images-ext-1.discordapp.net/external/085KIKMVni8n60G3GHE1rGcA0xgH6OgBKIZqUiQYsXc/%3Fname%3DUnknown%2520file%26subtitle%3D0%2520MB/https/placeholder.nin0.dev/image");
async function getThumbnailBase(item: MessageAttachment): Promise<URL | null> {
const [filename, ext] = getFilenameAndExtension(item.filename);
const name = Humanize.truncatechars(item.title ? item.title : filename, 50) + (ext?.slice(0, 6) ?? "");
const subtitle = Humanize.filesize(item.size);
if (settings.store.localThumbnails) {
const html = await renderToHTML(<StaticFilePickerItem name={ name } subtitle = { subtitle } />);
// encodeURIComponent is intentionally avoided since it would bloat the url size by replacing otherwise safe characters
return URL.parse("data:image/svg+xml," + html.replaceAll("%", "%25").replaceAll("#", "%23"));
} else {
const url = new URL("https://placeholder.nin0.dev/image");
url.searchParams.append("name", name || " ");
url.searchParams.append("subtitle", subtitle);
return await RestAPI.post({ url: Constants.Endpoints.UNFURL_EMBED_URLS, body: { urls: [url] }, retries: 3 })
.then(({ body }: { body: UnfurledEmbedsResponse; }) => {
const [{ thumbnail } = {}] = body.embeds;
return thumbnail?.proxy_url ? URL.parse(thumbnail.proxy_url) : null;
});
}
}
export async function getFileThumbnailUrl(item: MessageAttachment): Promise<URL> {
try {
const base = await getThumbnailBase(item);
const metadata = defs.encode(CustomItemFormat.ATTACHMENT, item)?.toString();
if (!base || !metadata) return FALLBACK_THUMBNAIL;
base.hash = metadata;
return base;
} catch {
return FALLBACK_THUMBNAIL;
}
}
export const isAllowedHost = proxyLazy(() => {
// GLOBAL_ENV is not initialized immediately
const allowedHosts = new Set<string>([
window.GLOBAL_ENV.CDN_HOST,
...[window.GLOBAL_ENV.IMAGE_PROXY_ENDPOINTS, window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT]
.flatMap(endpoint => endpoint.split(","))
.map(endpoint => URL.parse(`https://${endpoint}`)?.hostname)
.filter(Boolean)
]);
return (value: string) => allowedHosts.has(value);
});
async function fetchAttachment(attachment: MessageAttachment): Promise<File> {
if (!IS_WEB)
return Native.fetchAttachment(attachment).then(
({ data, filename, type }) => new File([data], filename, { type })
);
const { content_type, filename } = attachment;
const url = URL.parse(attachment.url);
if (!url || !isAllowedHost(url.hostname)) throw new Error("Invalid URL");
const res = await fetch(url, { headers: { Accept: "*/*" } });
if (!res.ok) throw new Error("Server error");
const blob = await res.blob();
const type = blob.type || content_type || "application/octet-stream";
const data = await blob.arrayBuffer();
return new File([data], filename, { type });
}
export async function sendAttachment(attachment: MessageAttachment, channel: Channel) {
const { filename, title, description } = attachment;
const file = await fetchAttachment(attachment).catch(() =>
showToast(`Couldn't fetch ${filename}`, "failure")
);
if (!file) return;
// Using promptToUpload instead of addFiles directly since it has file size checks with error popups
await UploadHandler.promptToUpload([file], channel, DraftType.ChannelMessage).catch(() =>
showToast(`Couldn't upload ${filename}`, "failure")
);
const uploads = [...UploadAttachmentStore.getUploads(channel.id, DraftType.ChannelMessage)];
const uploadIdx = uploads.findIndex(({ item }) => item.file === file);
if (uploadIdx === -1) return;
const reply = PendingReplyStore.getPendingReply(channel.id);
const [upload] = uploads.splice(uploadIdx);
UploadManager.setUploads({ uploads, channelId: channel.id, draftType: DraftType.ChannelMessage });
// Empty titles and descriptions are allowed
if (title != null) upload.filename = title + (getFilenameAndExtension(upload.filename)[1] ?? "");
if (description != null) upload.description = description;
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id });
void sendMessage(channel.id, {}, false, {
...MessageActions.getSendMessageOptionsForReply(reply),
attachmentsToUpload: [upload]
});
}
export function hasPermission(permission: bigint, channel: Channel | null): boolean {
return !!channel && (PermissionStore.can(permission, channel) || channel.isPrivate());
}
const diacriticsRegex = /[\u0300-\u036f]/g;
function normalize(str: string) {
return str.normalize("NFD").replace(diacriticsRegex, "").normalize("NFKC").toLowerCase().trim();
}
// Stolen from favGifSearch
function fuzzySearch(searchQuery: string, searchString: string) {
let searchIndex = 0;
let score = 0;
for (let i = 0; i < searchString.length; i++) {
if (searchString[i] === searchQuery[searchIndex]) {
score++;
searchIndex++;
} else {
score--;
}
if (searchIndex === searchQuery.length) {
return score;
}
}
return null;
}
export function useFavourites(itemFormat: CustomItemFormat, searchQuery?: string) {
useEffect(() => void UserSettingsActionCreators.FrecencyUserSettingsActionCreators.loadIfNecessary(), []);
const items = useStateFromStores(
[UserSettingsProtoStore],
() => {
const gifs: Record<string, FavouriteItem> | undefined =
UserSettingsProtoStore.frecencyWithoutFetchingLatest.favoriteGifs?.gifs;
if (!gifs) return null;
return Object.entries(gifs)
.filter(([, { format }]) => format === FavouriteItemFormat.NONE)
.map(([url, { src, ...rest }]) => ({
...rest,
...defs.decode(URL.parse(src)?.hash.replace("#", "") ?? "")!,
url
}))
.filter(({ format, data }) => data && format === itemFormat);
},
[itemFormat]
);
const { state } = useStateFromStores(
[UserSettingsProtoStore],
() => {
const query = searchQuery && normalize(searchQuery);
if (!items) return { query, state: null };
if (!query) return { query, state: items.toSorted((a, b) => b.order - a.order) };
const state = items
.map(item => ({
item,
score: fuzzySearch(query, normalize(defs.stringify(item.format, item.data)))
}))
.filter(({ score }) => score !== null)
.sort((a, b) => b.score! - a.score!)
.map(({ item }) => item);
return { query, state };
},
[items, searchQuery],
// Do not rerender components using this hook unless the query has changed or the items were loaded for the first time
// This matches the behavior of the gif picker, where unfavouriting an item doesn't immediately hide it
(prev, next) => !!prev.state === !!next.state && prev.query === next.query
);
return state;
}
// Helper hook for the ListScroller component, similar utility is used in the forum channel list view
// for keeping track of the individual row heights
export function useListScroller() {
const rowHeights = useRef(new Map<Key, number>());
const update = useForceUpdater();
const handleResize = useCallback((key: Key, height: number) => {
if (height === rowHeights.current.get(key)) return;
rowHeights.current.set(key, height);
update();
}, []);
return [rowHeights.current, handleResize] as const;
}
// Wrapper class for Queue which allows batching multiple requests into one.
// A request is fired immediately if at least `maxCount` items are in this queue,
// or if enough time (`timeout`) has passed since the last item was added.
// Subsequent requests are fired in sequence.
export class BatchedRequestQueue<T> {
private items: T[] = [];
private timer: NodeJS.Timeout | null = null;
private readonly queue: Queue = new Queue();
constructor(
private readonly cb: (items: T[]) => Promise<void>,
private readonly options: { maxCount: number; timeout?: number; }
) { }
public add(item: T) {
if (this.items.indexOf(item) !== -1) return;
this.items.push(item);
if (this.items.length >= this.options.maxCount) {
this.flush();
} else {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.flush(), this.options.timeout);
}
}
private flush() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
if (this.items.length === 0) return;
const batch = this.items.splice(0, 50);
this.queue.push(() => this.cb(batch).catch(() => this.items.push(...batch)));
}
}