From 9d910b3f72baa4ddec2092d8af804214156a32da Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:28:28 +0200 Subject: [PATCH 01/14] Add FavouriteAnything --- .../src/common/messages/Message.d.ts | 2 + src/plugins/favouriteAnything/components.tsx | 363 ++++++++++++++++++ src/plugins/favouriteAnything/index.tsx | 170 ++++++++ src/plugins/favouriteAnything/native.ts | 31 ++ src/plugins/favouriteAnything/polyfills.ts | 162 ++++++++ src/plugins/favouriteAnything/stores.ts | 95 +++++ src/plugins/favouriteAnything/style.css | 107 ++++++ src/plugins/favouriteAnything/types.ts | 157 ++++++++ src/plugins/favouriteAnything/utils.ts | 321 ++++++++++++++++ src/utils/constants.ts | 4 + src/webpack/common/utils.ts | 2 +- 11 files changed, 1413 insertions(+), 1 deletion(-) create mode 100644 src/plugins/favouriteAnything/components.tsx create mode 100644 src/plugins/favouriteAnything/index.tsx create mode 100644 src/plugins/favouriteAnything/native.ts create mode 100644 src/plugins/favouriteAnything/polyfills.ts create mode 100644 src/plugins/favouriteAnything/stores.ts create mode 100644 src/plugins/favouriteAnything/style.css create mode 100644 src/plugins/favouriteAnything/types.ts create mode 100644 src/plugins/favouriteAnything/utils.ts diff --git a/packages/discord-types/src/common/messages/Message.d.ts b/packages/discord-types/src/common/messages/Message.d.ts index b347eecbd8d..0b71979f2cd 100644 --- a/packages/discord-types/src/common/messages/Message.d.ts +++ b/packages/discord-types/src/common/messages/Message.d.ts @@ -291,6 +291,8 @@ export interface MessageAttachment { content_type?: string; width?: number; height?: number; + title?: string; + description?: string; } export interface ReactionEmoji { diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx new file mode 100644 index 00000000000..442b1c4c78b --- /dev/null +++ b/src/plugins/favouriteAnything/components.tsx @@ -0,0 +1,363 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import { LazyComponentWrapper } from "@utils/lazyReact"; +import { Embed, ListRow, Message, MessageAttachment, ScrollerBaseRef } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { findByCodeLazy, findComponentByCode, findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack"; +import { ChannelStore, ExpressionPickerStore, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common"; +import { ReactNode } from "react"; + +import { SignedUrlsStore } from "./stores"; +import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass } from "./types"; +import { cl, defs, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; + +export const EmbedContext = proxyLazyWebpack(() => React.createContext(null)); +export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext(null)); +const AttachmentContext = proxyLazyWebpack(() => React.createContext(null)); + +const ManaSearchBar = findComponentByCodeLazy("#{intl::SEARCH}),ref"); +const FavoriteButton = findComponentByCodeLazy("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}"); +const SendIcon = findComponentByCodeLazy("M6.6 10.02 14 11.4a.6.6"); + +const createChannelRecordFromServer = findByCodeLazy(".GUILD_TEXT]", "fromServer)"); +const createMessageRecord = findByCodeLazy(".createFromServer(", ".isBlockedForMessage", "messageReference:"); + +const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer"); + +function createPreviewMessage(attachment: MessageAttachment, channelId: string) { + const previewMessage = { + id: `favourite-anything-preview-${attachment.id}`, + attachments: [attachment], + channel_id: channelId, + content: "", + type: 0, + timestamp: new Date().toISOString() + }; + + return createMessageRecord(previewMessage) as Message; +} + +export const AttachmentPreview = proxyLazyWebpack(() => { + // findComponentByCodeLazy doesn't work properly with component classes, this must be kept within the lazy scope + const MessageComponent = findComponentByCode("this.renderAttachments") as LazyComponentWrapper; + + class MessageAttachmentsComponent extends MessageComponent { + render(): ReactNode { + return this.renderAttachments(this.props.message); + } + } + + const channel = Object.freeze(createChannelRecordFromServer({ id: "0", type: ChannelType.GUILD_TEXT })); + + return function AttachmentPreview({ attachment }: AttachmentsComponentProps) { + const message = useMemo( + () => createPreviewMessage(attachment, channel.id), + [attachment, channel.id] + ); + + return ( + + ); + }; +}); + +const noopRender = () => null; + +export function FilePicker({ onSelectItem }: FilePickerProps) { + const listRef = useRef(null); + + const { channelId, query } = ExpressionPickerStore.useExpressionPickerStore(store => ({ + channelId: store.activeChannelId as string, + query: store.searchQuery + })); + + const channel = useStateFromStores([ChannelStore], () => ChannelStore.getChannel(channelId), [channelId]); + + const favs = useFavourites(CustomItemFormat.ATTACHMENT, query); + const count = useMemo(() => (favs ? Object.keys(favs).length : 0), [favs]); + + const [rowHeights, handleResize] = useListScroller(); + + const handleSubmit = useCallback((url: string) => onSelectItem({ url }), []); + const handleChange = useCallback((query: string) => ExpressionPickerStore.setSearchQuery(query), []); + const handleClear = useCallback(() => ExpressionPickerStore.setSearchQuery(""), []); + + const renderRow = useCallback(({ row }: ListRow) => { + const item = favs?.[row]; + if (!item) return null; + + return ( + + ); + }, [favs, channel, count, handleResize, handleSubmit]); + + const rowHeight = useCallback( + (_: number, row: number) => (favs?.[row] && rowHeights.get(favs[row].url)) ?? 100, + [favs, rowHeights] + ); + + useEffect(() => void listRef.current?.scrollToTop(), [query]); + + return ( +
+
+ +
+ {count > 0 ? ( +
+ +
+ ) : ( +
+ {query.trim() ? : } +
+ )} +
+ ); +} + +function EmptyList() { + return No files match your search.; +} + +const demoAttachment: MessageAttachment = { + id: "1", + filename: "file", + content_type: "application/octet-stream", + size: 123 * 1024, + spoiler: false, + url: "", + proxy_url: "" +}; + +function Demo() { + return ( + <> +
+ + +
+ + Click the star to favourite a file. +
+ Favourite files will show up here! +
+ + ); +} + +export function FilePickerItem({ url, file, channel, onResize, onSubmit, reducePadding }: FilePickerItemProps) { + const [isFetching, setIsFetching] = useState(false); + + const ref = useRef(null); + useResizeObserver(ref, ({ height }) => onResize(url, height), [onResize, url]); + + const attachment = useStateFromStores( + [SignedUrlsStore], + () => ({ ...file, url: SignedUrlsStore.get(file.url), proxy_url: SignedUrlsStore.get(file.proxy_url) }), + [file], + lodash.isEqual + ) as MessageAttachment; + + const { canAttachFiles, canSendMessages } = useStateFromStores( + [PermissionStore], + () => ({ + canAttachFiles: hasPermission(PermissionsBits.ATTACH_FILES, channel), + canSendMessages: hasPermission(PermissionsBits.SEND_MESSAGES, channel) + }), + [channel] + ); + + const handleClick = useMemo(() => { + switch (true) { + case canAttachFiles: + return async () => { + setIsFetching(true); + await sendAttachment(attachment, channel!); + ExpressionPickerStore.closeExpressionPicker(); + setIsFetching(false); + }; + case canSendMessages: + return () => onSubmit(url); + default: + return null; + } + }, [attachment, canAttachFiles, canSendMessages, channel, url]); + + return ( +
+ + {handleClick && ( + + )} +
+ ); +} + +export function EmbedAccessory() { + const embed = React.useContext(EmbedContext); + const mosaicIndex = React.useContext(EmbedMosaicContext); + + const props: FavoriteButtonProps | null = useMemo(() => { + if (!embed || embed.type === "gifv") return null; + + const { video, image, images, thumbnail } = embed; + + if (video) { + // This field is missing on videos by third party providers (TikTok, YouTube ...) + const isProxiedVideo = !!video.proxyURL; + + // External videos don't have a video.proxyURL property that could be used for the preview - use the static thumbnail instead + const src = video.proxyURL ?? thumbnail?.proxyURL ?? video.url; + const format = isProxiedVideo ? FavouriteItemFormat.VIDEO : FavouriteItemFormat.IMAGE; + + // External videos' content.url usually doesn't point to a valid resource that could be embedded + const url = !isProxiedVideo ? embed.url! : video.url; + + return { ...video, format, src, url }; + } + + const img = (mosaicIndex != null && images?.[mosaicIndex]) || image; + if (!img) return null; + + const src = img.proxyURL ?? img.url; + + // Do not render the custom embed accessory if the original image already has a gif accessory + const isAnimated = ImageUtils.isAnimated({ ...img, original: img.url, src, animated: false }); + if (isAnimated) return null; + + return { ...img, format: FavouriteItemFormat.IMAGE, src }; + }, [embed, mosaicIndex]); + + return ( + props && ( +
+ +
+ ) + ); +} + +export function AttachmentContextProvider({ attachment, component, children }: AttachmentContextProviderProps) { + const attachmentItem: AttachmentItem | null = useMemo(() => { + if (component) { + const { id, size, name, spoiler, file } = component; + const raw = { + ...file, + size, + filename: name, + id, + spoiler, + content_type: file.contentType, + proxy_url: file.proxyUrl + }; + + return transformAttachment(raw); + } + + if (attachment) { + const { originalItem, ...rest } = attachment; + + // Regular media attachments and cv2 media attachments are structured differently + const raw: MessageAttachment = + "media" in originalItem + ? { + ...originalItem.media, + id: rest.uniqueId, + size: 0, + spoiler: rest.spoiler, + filename: (rest.spoiler ? "SPOILER_" : "") + rest.uniqueId, + content_type: originalItem.media.contentType, + proxy_url: originalItem.media.proxyUrl + } + : originalItem; + + return { originalItem: raw, ...rest }; + } + + return null; + }, [attachment, component]); + + return {children}; +} + +const visualMediaFormats: Partial> = Object.freeze({ + IMAGE: FavouriteItemFormat.IMAGE, + VIDEO: FavouriteItemFormat.VIDEO, + CLIP: FavouriteItemFormat.VIDEO +}); + +export function AttachmentAccessory() { + const attachment = React.useContext(AttachmentContext); + + const props: FavoriteButtonProps | null = useMemo(() => { + if (!attachment?.downloadUrl) return null; + const { originalItem, type, downloadUrl, srcIsAnimated } = attachment; + const width = attachment.width || 600, height = attachment.height || 400; + + // Do not render the custom accessory if the original attachment component already has a gif accessory + const isAnimated = ImageUtils.isAnimated({ + original: originalItem.url, + src: originalItem.proxy_url, + animated: false, + srcIsAnimated + }); + if (isAnimated) return null; + + if (type in visualMediaFormats) { + return { format: visualMediaFormats[type]!, src: originalItem.proxy_url, url: downloadUrl, width, height }; + } + + // Non visual attachments have to be encoded to store metadata in the src property. + // Note that this isn't a valid url yet, the full url (with a fallback image for vanilla client compat) + // is generated via `getThumbnailUrl` once the user clicks the favourite button + const src = defs.encode(CustomItemFormat.ATTACHMENT, originalItem)?.toString(); + if (!src) return null; + + return { format: FavouriteItemFormat.NONE, src, url: downloadUrl, width, height }; + }, [attachment]); + + return props && ; +} diff --git a/src/plugins/favouriteAnything/index.tsx b/src/plugins/favouriteAnything/index.tsx new file mode 100644 index 00000000000..b368b06bbd5 --- /dev/null +++ b/src/plugins/favouriteAnything/index.tsx @@ -0,0 +1,170 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { getIntlMessage } from "@utils/discord"; +import definePlugin from "@utils/types"; +import { ComponentType, ReactNode } from "react"; + +import { AttachmentAccessory, AttachmentContextProvider, EmbedAccessory, EmbedContext, EmbedMosaicContext, FilePicker } from "./components"; +import { SignedUrlsStore } from "./stores"; +import managedStyle from "./style.css?managed"; +import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types"; +import { getThumbnailUrl } from "./utils"; + +export default definePlugin({ + name: "FavouriteAnything", + description: "Favourite any image, video, or file attachment", + tags: ["Chat", "Media", "Utility"], + authors: [Devs.davri, Devs.nin0dev], + searchTerms: ["favorite"], + managedStyle, + patches: [ + // EMBEDS + { + find: "this.renderInlineMediaEmbed", + replacement: [ + { + // Wrap the embed component's render method in a custom context to avoid having to drill props + match: "render()", + replace: "$&{return $self.renderEmbed.call(this)}__render()" + }, + { + // Specify the index for individual items in embed.images + match: /\.images\.map\((\i)=>(this.renderImage\(\{[^}]{50,100}\}\))\)/, + replace: ".images.map(($1,index)=>$self.renderEmbedMosaicItem($2,index))" + } + ] + }, + { + // Override the default renderAdjacentContent prop value for all types of embed components (renderImageComponent, renderVideoComponent...) + find: "#{intl::MEDIA_MOSAIC_ALT_TEXT_POPOUT_TITLE}", + replacement: { + match: /renderAdjacentContent:\i/g, + replace: "$&=$self.renderEmbedAccessory" + } + }, + // ATTACHMENTS + { + find: '["VIDEO","CLIP","AUDIO"]', + replacement: [ + { + // Wrap the attachment component in a custom context to avoid having to drill props + match: /(?<=children:)(\i)=>(\i\(\1\))\}\):(\i\(\))/, + replace: "$1=>$self.renderAttachment($2,arguments[0])}):$self.renderAttachment($3,arguments[0])" + }, + { + // Always add our custom accessory to the attachment's adjacent content + match: "=[];", + replace: "=[$self.renderAttachmentAccessory()];" + } + ] + }, + // COMPONENTS V2 + { + // Handle the FILE message component separately since it has different props from the standard attachment component + find: "#{intl::ATTACHMENT_FILENAME_UNKNOWN}", + replacement: { + match: /(?<=case \i\.\i\.FILE:)return(\(0,\i\.jsx\)\(\i,\{\.\.\.(\i)\},(\i)\))/, + replace: "return $self.renderCV2File($1,$3,$2)" + } + }, + // EXPRESSION PICKER + { + find: "#{intl::EXPRESSION_PICKER_CATEGORIES_A11Y_LABEL}", + replacement: [ + { + // Replace the "GIFs" tab with two custom tabs + match: /\(0,\i\.jsx\)\((\i),[^}]{20,40}?"aria-selected":(\i)[^}]{50,100}?#{intl::EXPRESSION_PICKER_GIF}\)\}\)/, + replace: "$self.renderTabs($1,$2)" + }, + { + // Insert the custom file picker into the expression picker's body + match: /\{onSelectGIF:(\i),[^}]{20,40}\}\):null,(?=(\i)===)/, + replace: "$&$self.renderFilePicker($2,$1)," + } + ] + }, + { + // Hide favourite files from the GIFs/Media tab + find: '.sortBy("order").reverse().value()', + replacement: { + match: '.sortBy("order").reverse()', + replace: "$&.filter($self.filterGifs)" + } + }, + // FAVOURITE BUTTON + { + find: "#{intl::GIF_TOOLTIP_REMOVE_FROM_FAVORITES}", + replacement: { + // Intercept the onClick callback to replace the placeholder thumbnail with a valid CDN link + match: /\(0,(\i\.\i)\)\((\{[^}].{40,60}?\})\)/, + replace: "$self.interceptAddToFavourites($2).then($1)" + } + } + ], + renderTabs(Tab: ComponentType, activeView: ExpressionPickerView) { + return ( + <> + + {getIntlMessage("QUICKSEARCH_MEDIA")} + + + {getIntlMessage("QUICKSEARCH_FILES")} + + + ); + }, + renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) { + return activeView === ExpressionPickerView.FILES ? : null; + }, + renderAttachment(children: ReactNode, { item }: { item: AttachmentContextProviderProps["attachment"] }) { + return {children}; + }, + renderCV2File(children: ReactNode, key: React.Key, component: AttachmentContextProviderProps["component"]) { + return {children}; + }, + renderEmbed(this: EmbedComponent) { + return {this.__render()}; + }, + renderEmbedMosaicItem(children: ReactNode, index: number) { + return {children}; + }, + renderAttachmentAccessory: () => , + renderEmbedAccessory: () => , + filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE, + interceptAddToFavourites: async (item: FavouriteItem & { url: string; }) => { + if (item.format !== FavouriteItemFormat.NONE) return item; + + SignedUrlsStore.addSigned(item.url); + + if (URL.canParse(item.src)) { + SignedUrlsStore.addSigned(item.src); + return item; + } + + const thumbnail = await getThumbnailUrl(item.src, item.width, item.height); + if (!thumbnail) return item; + + thumbnail.search = ""; + thumbnail.hash = item.src; + return { ...item, src: `${thumbnail}` }; + } +}); diff --git a/src/plugins/favouriteAnything/native.ts b/src/plugins/favouriteAnything/native.ts new file mode 100644 index 00000000000..63bf6c71556 --- /dev/null +++ b/src/plugins/favouriteAnything/native.ts @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { MessageAttachment } from "@vencord/discord-types"; + +const allowedHosts = new Set([ + "cdn.discordapp.com", + "images-ext-1.discordapp.net", + "images-ext-2.discordapp.net", + "media.discordapp.net" +]); + +// Discord has very strict CORS rules for which types of assets can be fetched from where (CDN/Media proxy), +// and most binary file types are prohibited by both. This function serves as a simple bypass. +export async function fetchAttachment(_: unknown, attachment: MessageAttachment) { + const { content_type, filename } = attachment; + const url = URL.parse(attachment.url); + if (!url || !allowedHosts.has(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 { type, data, filename }; +} diff --git a/src/plugins/favouriteAnything/polyfills.ts b/src/plugins/favouriteAnything/polyfills.ts new file mode 100644 index 00000000000..2fd5ef139c2 --- /dev/null +++ b/src/plugins/favouriteAnything/polyfills.ts @@ -0,0 +1,162 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/* + * This file contains modified code from: + * https://github.com/tc39/proposal-arraybuffer-base64 + * * Copyright (c) 2017 ECMA TC39 and contributors + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in all + * * copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * * SOFTWARE. + */ + +// TODO: Remove once discord stops being insane and updates electron (stable has to be on >=38.0.0) + +function supportsToBase64(array: Uint8Array): boolean { + return "toBase64" in array && typeof array.toBase64 === "function"; +} + +function supportsFromBase64(ctor: Uint8ArrayConstructor): boolean { + return "fromBase64" in ctor && typeof ctor.fromBase64 === "function"; +} + +const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +const map = new Map(chars.split("").map((c, i) => [c, i])); + +export function uint8ArrayToBase64(arr: Uint8Array): string { + if (supportsToBase64(arr)) { + return arr.toBase64({ alphabet: "base64url", omitPadding: true }); + } + + if ("detached" in arr.buffer && arr.buffer.detached) { + throw new TypeError("toBase64 called on array backed by detached buffer"); + } + + let result = ""; + + let i = 0; + for (; i + 2 < arr.length; i += 3) { + const triplet = (arr[i] << 16) + (arr[i + 1] << 8) + arr[i + 2]; + result += + chars[(triplet >> 18) & 63] + + chars[(triplet >> 12) & 63] + + chars[(triplet >> 6) & 63] + + chars[triplet & 63]; + } + if (i + 2 === arr.length) { + const triplet = (arr[i] << 16) + (arr[i + 1] << 8); + result += chars[(triplet >> 18) & 63] + chars[(triplet >> 12) & 63] + chars[(triplet >> 6) & 63]; + } else if (i + 1 === arr.length) { + const triplet = arr[i] << 16; + result += chars[(triplet >> 18) & 63] + chars[(triplet >> 12) & 63]; + } + + return result; +} + +function decodeBase64Chunk(chunk: string): number[] { + const actualChunkLength = chunk.length; + if (actualChunkLength < 4) { + chunk += actualChunkLength === 2 ? "AA" : "A"; + } + + const c1 = chunk[0]; + const c2 = chunk[1]; + const c3 = chunk[2]; + const c4 = chunk[3]; + + const triplet = (map.get(c1)! << 18) + (map.get(c2)! << 12) + (map.get(c3)! << 6) + map.get(c4)!; + + const chunkBytes = [(triplet >> 16) & 255, (triplet >> 8) & 255, triplet & 255]; + + if (actualChunkLength === 2) { + return [chunkBytes[0]]; + } else if (actualChunkLength === 3) { + return [chunkBytes[0], chunkBytes[1]]; + } + return chunkBytes; +} + +const asciiWhitespaceRegex = /[\u0009\u000A\u000C\u000D\u0020]/; +function skipAsciiWhitespace(string: string, index: number): number { + for (; index < string.length; ++index) { + if (!asciiWhitespaceRegex.test(string[index])) { + break; + } + } + return index; +} + +export function base64ToUint8Array(string: string): Uint8Array { + if (supportsFromBase64(Uint8Array)) { + return Uint8Array.fromBase64(string, { alphabet: "base64url" }); + } + + const bytes: number[] = []; + let chunk = ""; + + let index = 0; + while (true) { + index = skipAsciiWhitespace(string, index); + if (index === string.length) { + if (chunk.length > 0) { + if (chunk.length === 1) { + throw new SyntaxError("malformed padding: exactly one additional character"); + } + bytes.push(...decodeBase64Chunk(chunk)); + } + break; + } + const char = string[index]; + ++index; + if (char === "=") { + if (chunk.length < 2) { + throw new SyntaxError("padding is too early"); + } + index = skipAsciiWhitespace(string, index); + if (chunk.length === 2) { + if (index === string.length) { + throw new SyntaxError("malformed padding - only one ="); + } + if (string[index] === "=") { + ++index; + index = skipAsciiWhitespace(string, index); + } + } + if (index < string.length) { + throw new SyntaxError("unexpected character after padding"); + } + bytes.push(...decodeBase64Chunk(chunk)); + break; + } + if (!chars.includes(char)) { + throw new SyntaxError(`unexpected character ${JSON.stringify(char)}`); + } + + chunk += char; + if (chunk.length === 4) { + bytes.push(...decodeBase64Chunk(chunk)); + chunk = ""; + } + } + + return new Uint8Array(bytes); +} diff --git a/src/plugins/favouriteAnything/stores.ts b/src/plugins/favouriteAnything/stores.ts new file mode 100644 index 00000000000..5db9209e493 --- /dev/null +++ b/src/plugins/favouriteAnything/stores.ts @@ -0,0 +1,95 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { proxyLazyWebpack } from "@webpack"; +import { Constants, Flux, FluxDispatcher, RestAPI } from "@webpack/common"; + +import { RefreshedUrlsResponse } from "./types"; +import { BatchedRequestQueue, isAllowedHost } from "./utils"; + +/** Used for storing and automatically refreshing signed CDN/Media proxy urls ({@link https://docs.discord.food/reference#signed-attachment-urls}). */ +export const SignedUrlsStore = proxyLazyWebpack(() => { + class SignedUrlsStoreClass extends Flux.Store { + public static readonly displayName = "SignedUrlsStoreClass"; + private static readonly _expirationThreshold = 60 * 60 * 1000; + + private _urls = new Map(); + private _queue = new BatchedRequestQueue(batch => this._handleBatch(batch), { + maxCount: 50, + timeout: 50 + }); + + // Makes debugging easier with discord devtools + __getLocalVars() { + return { urls: this._urls, queue: this._queue }; + } + + public get(url: string): string | null { + const key = URL.parse(url); + if (!this._isValid(key)) return null; + + const value = this._urls.get(`${this._clean(key)}`) ?? null; + + const parsed = URL.parse(value!); + if (!parsed || this._willExpire(parsed)) this._refresh(key); + + return value; + } + + public addSigned(url: string): void { + const parsed = URL.parse(url); + if (!this._isValid(parsed)) return; + + if (this._willExpire(parsed)) this._refresh(parsed); + else this._update([[`${this._clean(parsed)}`, url]]); + } + + private _refresh(url: URL): void { + this._queue.add(`${this._clean(url)}`); + } + + private _clean(url: URL): URL { + const clean = new URL(url); + clean.search = ""; + clean.hash = ""; + return clean; + } + + private _isValid(url: URL | null): url is URL { + return !!(url && isAllowedHost(url.hostname)); + } + + private _willExpire(url: URL): boolean { + const expiryTimestamp = parseInt(url.searchParams.get("ex")!, 16) * 1000; + return isNaN(expiryTimestamp) || expiryTimestamp - SignedUrlsStoreClass._expirationThreshold < Date.now(); + } + + private _update(urls: [string, string][]): void { + let hasChanged: boolean = false; + + for (const [url, value] of urls) { + if (!value || url === value || this._urls.get(url) === value) continue; + + this._urls.set(url, value); + hasChanged = true; + } + + if (hasChanged) this.emitChange(); + } + + private async _handleBatch(batch: string[]): Promise { + await RestAPI.post({ + url: Constants.Endpoints.ATTACHMENTS_REFRESH_URLS, + body: { attachment_urls: batch }, + retries: 3 + }).then(({ body }: { body: RefreshedUrlsResponse }) => + this._update(body.refreshed_urls.map(({ original, refreshed }) => [original, refreshed!])) + ); + } + } + + return new SignedUrlsStoreClass(FluxDispatcher); +}); diff --git a/src/plugins/favouriteAnything/style.css b/src/plugins/favouriteAnything/style.css new file mode 100644 index 00000000000..400cb0d12cc --- /dev/null +++ b/src/plugins/favouriteAnything/style.css @@ -0,0 +1,107 @@ +.vc-favouriteAnything-attachment-container { + padding: 12px; + display: flex; + gap: 12px; + + &.vc-favouriteAnything-reduced-padding { + padding-bottom: 0; + } + + > :first-child { + margin: 0; + flex: 1; + min-width: 0; + + > [class*="nonVisualMediaItem"] { + width: 100%; + + [class*="mosaicItem"] { + min-width: 0; + max-width: unset; + width: 100%; + } + + [class*="spoilerContent"] { + min-width: 0; + flex: 1; + } + } + } + + > button { + width: 15%; + min-width: 54px; + } +} + +.vc-favouriteAnything-container { + background: var(--background-base-lower); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + width: 100%; +} + +.vc-favouriteAnything-container-header { + background: var(--background-surface-high); + border-bottom: 1px solid var(--border-subtle); + padding: var(--custom-gif-picker-gutter-size); + z-index: 1; +} + +.vc-favouriteAnything-container-body { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + flex: 1; + overflow: hidden; + + &.vc-favouriteAnything-container-info { + justify-content: center; + align-items: center; + gap: 1.5rem; + } +} + +.vc-favouriteAnything-image-accessory { + inset-inline-end: unset; + position: absolute; + z-index: 3; + cursor: pointer; + margin: 6px; + + :is(.imageWrapper:is(:hover, :focus-within) ~ &, :hover, :focus-within) > [class*="gifFavoriteButton"] { + opacity: 1; + transform: translateY(0); + } +} + +.vc-favouriteAnything-attachment-accessory { + cursor: pointer; + + &:hover { + background: var(--interactive-background-hover); + } +} + +.vc-favouriteAnything-info-text { + text-align: center; + line-height: 1.6; + font-size: 1.1rem; +} + +.vc-favouriteAnything-demo { + width: 70%; + justify-self: center; + position: relative; + + .vc-favouriteAnything-demo-favourite-button { + position: absolute; + inset-inline-end: 4px; + top: 4px; + border-radius: 4px; + box-shadow: 0 0 0 4px var(--yellow-300); + } +} diff --git a/src/plugins/favouriteAnything/types.ts b/src/plugins/favouriteAnything/types.ts new file mode 100644 index 00000000000..f730f39a23a --- /dev/null +++ b/src/plugins/favouriteAnything/types.ts @@ -0,0 +1,157 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Channel, Embed, EmbedJSON, Message, MessageAttachment, TextInput } from "@vencord/discord-types"; +import { Component, ComponentClass, ComponentProps, ComponentPropsWithRef, Key, PropsWithChildren, ReactNode, RefObject } from "react"; +import { JsonValue, PartialDeep } from "type-fest"; + +export enum ExpressionPickerView { + EMOJI = "emoji", + GIF = "gif", + STICKER = "sticker", + SOUNDBOARD = "soundboard", + FILES = "files" +} + +export interface ExpressionPickerTabProps extends PropsWithChildren { + id?: string; + "aria-controls"?: string; + "aria-selected"?: boolean; + isActive?: boolean; + viewType: ExpressionPickerView; +} + +export interface FavoriteButtonProps extends Omit { + url: string; + gifSrc?: string; + className?: string; +} + +// Partial type, renderAttachments only uses a few props +interface MessageComponentProps { + message: Message; + channel: Channel; + gifAutoPlay?: boolean; + canDeleteAttachments?: boolean; + shouldHideMediaOptions?: boolean; + inlineAttachmentMedia?: boolean; +} + +export interface MessageComponentClass extends Omit, "new"> { + new(props: MessageComponentProps): Component & { + renderAttachments(message: Partial): ReactNode; + }; +} + +export interface ManaSearchBarProps extends Pick< + ComponentPropsWithRef, + "autoFocus" | "placeholder" | "onKeyDown" | "disabled" | "onChange" | "onBlur" | "onFocus" | "autoComplete" | "ref" +> { + query?: string; + onClear?: () => void; + inputProps?: ComponentProps; +} + +export interface FilePickerProps { + onSelectItem: (item: { url: string; }) => void; +} + +export interface FilePickerItemProps { + url: string; + file: MessageAttachment; + channel: Channel | null; + reducePadding?: boolean; + onResize: (key: Key, height: number) => void; + onSubmit: (url: string) => void; +} + +export interface AttachmentsComponentProps { + attachment: MessageAttachment; +} + +export interface AttachmentContextProviderProps extends PropsWithChildren { + attachment?: AttachmentItem; + component?: { id: string; size: number; name: string; spoiler: boolean; file: CV2Attachment; }; +} + +export interface EmbedComponent extends Component<{ embed: Embed; }> { + __render: () => ReactNode; +} + +export interface AttachmentItem { + contentType: string; + type: "IMAGE" | "VIDEO" | "CLIP" | "AUDIO" | "VISUAL_PLACEHOLDER" | "PLAINTEXT_PREVIEW" | "OTHER" | "INVALID"; + width?: number; + height?: number; + downloadUrl: string; + spoiler: boolean; + srcIsAnimated: boolean; + uniqueId: string; + originalItem: TOriginal; +} + +export interface CV2Attachment { + url: string; + proxyUrl: string; + width: number; + height: number; + placeholder?: string; + contentType: string; + flags: number; +} + +export enum FavouriteItemFormat { + NONE = 0, + IMAGE = 1, + VIDEO = 2 +} + +export interface FavouriteItem { + format: FavouriteItemFormat; + src: string; + width: number; + height: number; + order: number; +} + +export enum CustomItemFormat { + ATTACHMENT = 0 +} + +export interface CustomItemDef { + encode: (data: A) => B | null; + decode: (data: PartialDeep) => NoInfer | null; + stringify: (data: A) => string; +} + +export type ItemsDef = T & { + [K in keyof T]: T[K] extends CustomItemDef ? CustomItemDef : never; +}; + +export interface RefreshedUrlsResponse { + refreshed_urls: [ + { + original: string; + refreshed: string | null; + } + ]; +} + +export interface UnfurledEmbedsResponse { + embeds: EmbedJSON[]; +} + +export type ResizeObserverHook = ( + ref: RefObject, + callback: (size: { width: number; height: number; }) => void, + deps?: unknown[] +) => void; + +export interface ImageUtils { + isAnimated(image: { src: string; original?: string; animated: boolean; srcIsAnimated?: boolean; }): boolean; +} + +export type AttachmentTransformer = (attachment: MessageAttachment, inlineAttachmentMedia?: boolean) => AttachmentItem; diff --git a/src/plugins/favouriteAnything/utils.ts b/src/plugins/favouriteAnything/utils.ts new file mode 100644 index 00000000000..5807bcf3324 --- /dev/null +++ b/src/plugins/favouriteAnything/utils.ts @@ -0,0 +1,321 @@ +/* + * 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, DraftType, FluxDispatcher, MessageActions, PendingReplyStore, PermissionStore, RestAPI, Toasts, UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common"; +import { deflateSync, inflateSync } from "fflate"; +import { Key } from "react"; +import { JsonValue } from "type-fest"; + +import { base64ToUint8Array, uint8ArrayToBase64 } from "./polyfills"; +import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types"; + +const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative; + +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 = (item: CustomItemDef) => item; +function defineItems>(def: ItemsDef) { + type Type = T[F] extends CustomItemDef ? A : never; + + return { + encode: (format: F, data: Type) => { + try { + const obj = [format, def[format].encode(data)]; + + const buf = deflateSync(encoder.encode(JSON.stringify(obj))); + return uint8ArrayToBase64(buf); + } catch { + return null; + } + }, + decode: (raw: string) => { + try { + if (!raw) return null; + + const buf = inflateSync(base64ToUint8Array(raw)); + 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; }; + }[CustomItemFormat]; + } catch { + return null; + } + }, + stringify: (format: F, item: Type) => 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) +}); + +// TODO: make thumbnails prettier +const fallbackThumbnail = new URL("https://images-ext-1.discordapp.net/external/pGTJg3YdSHpyGTltH4vZUKEyQoNzf5mtqbSJs7I4ebc/https/equicord.org/assets/plugins/favoriteAnything/invalid.png"); + +export async function getThumbnailUrl(data: string, width: number, height: number): Promise { + try { + const decoded = defs.decode(data); + if (!decoded || !width || !height) return null; + + const text = defs.stringify(decoded.format, decoded.data); + const url = new URL(`https://placehold.jp/42/444/fff/${width}x${height}.png`); + url.searchParams.append("text", text); + + 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 ? new URL(thumbnail.proxy_url) : fallbackThumbnail; + }); + } catch { + return fallbackThumbnail; + } +} + +export const isAllowedHost = proxyLazy(() => { + // GLOBAL_ENV is not initialized immediately + const allowedHosts = new Set([ + 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 { + 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(() => + Toasts.show({ message: `Couldn't fetch ${filename}`, id: Toasts.genId(), type: Toasts.Type.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(() => + Toasts.show({ message: `Couldn't upload ${filename}`, id: Toasts.genId(), type: Toasts.Type.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; + 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 | 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()); + 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 { + private items: T[] = []; + private timer: NodeJS.Timeout | null = null; + private readonly queue: Queue = new Queue(); + + constructor( + private readonly cb: (items: T[]) => Promise, + 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))); + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b685039fd4b..287e07b0a26 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -649,6 +649,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ Lunascape: { name: "Lunascape", id: 383365021415243776n + }, + davri: { + name: "Davri", + id: 457579346282938368n } } satisfies Record); diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index 32eb73a71c5..4e64910ef61 100644 --- a/src/webpack/common/utils.ts +++ b/src/webpack/common/utils.ts @@ -144,7 +144,7 @@ export const UserUtils = { export const UploadManager = findByPropsLazy("clearAll", "addFile"); export const UploadHandler = { - promptToUpload: findByCodeLazy("Unexpected mismatch between files and file metadata") as (files: File[], channel: t.Channel, draftType: Number) => void + promptToUpload: findByCodeLazy("Unexpected mismatch between files and file metadata") as (files: File[], channel: t.Channel, draftType: Number) => Promise }; export const ApplicationAssetUtils = mapMangledModuleLazy("getAssetImage: size must === [", { From f26dabef28b3aaf4b2f8dae7e89402f7b2eaa352 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:36:48 +0200 Subject: [PATCH 02/14] FA: inline send icon --- src/plugins/favouriteAnything/components.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index 442b1c4c78b..4599ada62ae 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -11,7 +11,7 @@ import { Embed, ListRow, Message, MessageAttachment, ScrollerBaseRef } from "@ve import { ChannelType } from "@vencord/discord-types/enums"; import { findByCodeLazy, findComponentByCode, findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack"; import { ChannelStore, ExpressionPickerStore, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common"; -import { ReactNode } from "react"; +import { ComponentProps, ReactNode } from "react"; import { SignedUrlsStore } from "./stores"; import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass } from "./types"; @@ -23,7 +23,6 @@ const AttachmentContext = proxyLazyWebpack(() => React.createContext("#{intl::SEARCH}),ref"); const FavoriteButton = findComponentByCodeLazy("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}"); -const SendIcon = findComponentByCodeLazy("M6.6 10.02 14 11.4a.6.6"); const createChannelRecordFromServer = findByCodeLazy(".GUILD_TEXT]", "fromServer)"); const createMessageRecord = findByCodeLazy(".createFromServer(", ".isBlockedForMessage", "messageReference:"); @@ -186,6 +185,21 @@ function Demo() { ); } +function SendIcon({ height = 24, width = 24, ...props }: ComponentProps<"svg">) { + return ( + + + + ); +} + export function FilePickerItem({ url, file, channel, onResize, onSubmit, reducePadding }: FilePickerItemProps) { const [isFetching, setIsFetching] = useState(false); @@ -229,7 +243,7 @@ export function FilePickerItem({ url, file, channel, onResize, onSubmit, reduceP {handleClick && ( )} From 7e156d08605cf9ea1c935d56bbe4c2bc849e3615 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:42:29 +0200 Subject: [PATCH 03/14] FA: remove top level function bind --- src/plugins/favouriteAnything/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/favouriteAnything/index.tsx b/src/plugins/favouriteAnything/index.tsx index b368b06bbd5..3b4a39e8fae 100644 --- a/src/plugins/favouriteAnything/index.tsx +++ b/src/plugins/favouriteAnything/index.tsx @@ -30,7 +30,7 @@ export default definePlugin({ { // Wrap the embed component's render method in a custom context to avoid having to drill props match: "render()", - replace: "$&{return $self.renderEmbed.call(this)}__render()" + replace: "$&{return $self.renderEmbed(this)}__render()" }, { // Specify the index for individual items in embed.images @@ -141,8 +141,8 @@ export default definePlugin({ renderCV2File(children: ReactNode, key: React.Key, component: AttachmentContextProviderProps["component"]) { return {children}; }, - renderEmbed(this: EmbedComponent) { - return {this.__render()}; + renderEmbed(comp: EmbedComponent) { + return {comp.__render()}; }, renderEmbedMosaicItem(children: ReactNode, index: number) { return {children}; From d8ea871b1b09748016ebfa209aa7c354e8c635e0 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:12:11 +0200 Subject: [PATCH 04/14] FA: Fix titles missing an extension --- src/plugins/favouriteAnything/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/favouriteAnything/utils.ts b/src/plugins/favouriteAnything/utils.ts index 5807bcf3324..3de605740b5 100644 --- a/src/plugins/favouriteAnything/utils.ts +++ b/src/plugins/favouriteAnything/utils.ts @@ -175,8 +175,12 @@ export async function sendAttachment(attachment: MessageAttachment, channel: Cha 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; + if (title != null) { + const ext = upload.filename.lastIndexOf("."); + upload.filename = title + (ext > 0 ? upload.filename.substring(ext) : ""); + } if (description != null) upload.description = description; FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id }); From 9d1e8ceff80d7d4e56bbf7cabfc25013299035b5 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:44:36 +0200 Subject: [PATCH 05/14] FA: change store displayname Co-authored-by: sadan <117494111+sadan4@users.noreply.github.com> --- src/plugins/favouriteAnything/stores.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/favouriteAnything/stores.ts b/src/plugins/favouriteAnything/stores.ts index 5db9209e493..6a40d4a24ea 100644 --- a/src/plugins/favouriteAnything/stores.ts +++ b/src/plugins/favouriteAnything/stores.ts @@ -13,7 +13,7 @@ import { BatchedRequestQueue, isAllowedHost } from "./utils"; /** Used for storing and automatically refreshing signed CDN/Media proxy urls ({@link https://docs.discord.food/reference#signed-attachment-urls}). */ export const SignedUrlsStore = proxyLazyWebpack(() => { class SignedUrlsStoreClass extends Flux.Store { - public static readonly displayName = "SignedUrlsStoreClass"; + public static readonly displayName = "SignedUrlsStore"; private static readonly _expirationThreshold = 60 * 60 * 1000; private _urls = new Map(); From be9e003e7c797e7d5e329da36268062eb5ce7421 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:30:41 +0200 Subject: [PATCH 06/14] FA: improve patches --- src/plugins/favouriteAnything/index.tsx | 41 ++++++++++--------------- src/plugins/favouriteAnything/types.ts | 4 +++ src/plugins/favouriteAnything/utils.ts | 15 +++++++-- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/plugins/favouriteAnything/index.tsx b/src/plugins/favouriteAnything/index.tsx index 3b4a39e8fae..83649c65e35 100644 --- a/src/plugins/favouriteAnything/index.tsx +++ b/src/plugins/favouriteAnything/index.tsx @@ -12,8 +12,8 @@ import { ComponentType, ReactNode } from "react"; import { AttachmentAccessory, AttachmentContextProvider, EmbedAccessory, EmbedContext, EmbedMosaicContext, FilePicker } from "./components"; import { SignedUrlsStore } from "./stores"; import managedStyle from "./style.css?managed"; -import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types"; -import { getThumbnailUrl } from "./utils"; +import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat, FullFavouriteItem } from "./types"; +import { fixFavouriteItem } from "./utils"; export default definePlugin({ name: "FavouriteAnything", @@ -58,8 +58,8 @@ export default definePlugin({ }, { // Always add our custom accessory to the attachment's adjacent content - match: "=[];", - replace: "=[$self.renderAttachmentAccessory()];" + match: /let \i=Math.max\(0,(\i)\.length-\i\)/, + replace: "$1.unshift($self.renderAttachmentAccessory());$&" } ] }, @@ -96,13 +96,13 @@ export default definePlugin({ replace: "$&.filter($self.filterGifs)" } }, - // FAVOURITE BUTTON + // PROTOBUF { - find: "#{intl::GIF_TOOLTIP_REMOVE_FROM_FAVORITES}", + find: "#{intl::FAVORITE_GIFS_LIMIT_REACHED_BODY}", replacement: { - // Intercept the onClick callback to replace the placeholder thumbnail with a valid CDN link - match: /\(0,(\i\.\i)\)\((\{[^}].{40,60}?\})\)/, - replace: "$self.interceptAddToFavourites($2).then($1)" + // Intercept add/remove actions to generate a valid thumbnail url before storing the item + match: /function (\i)\((\i)\)\{(?=\i\.\i\.updateAsync\("favoriteGifs")/g, + replace: "async function $1($2){await $self.convertFavItem($2);await " } } ], @@ -135,7 +135,7 @@ export default definePlugin({ renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) { return activeView === ExpressionPickerView.FILES ? : null; }, - renderAttachment(children: ReactNode, { item }: { item: AttachmentContextProviderProps["attachment"] }) { + renderAttachment(children: ReactNode, { item }: { item: AttachmentContextProviderProps["attachment"]; }) { return {children}; }, renderCV2File(children: ReactNode, key: React.Key, component: AttachmentContextProviderProps["component"]) { @@ -150,21 +150,14 @@ export default definePlugin({ renderAttachmentAccessory: () => , renderEmbedAccessory: () => , filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE, - interceptAddToFavourites: async (item: FavouriteItem & { url: string; }) => { - if (item.format !== FavouriteItemFormat.NONE) return item; - - SignedUrlsStore.addSigned(item.url); - - if (URL.canParse(item.src)) { + convertFavItem: async (item: FullFavouriteItem | string) => { + if (typeof item === "string") { + SignedUrlsStore.addSigned(item); + } else { + SignedUrlsStore.addSigned(item.url); SignedUrlsStore.addSigned(item.src); - return item; - } - const thumbnail = await getThumbnailUrl(item.src, item.width, item.height); - if (!thumbnail) return item; - - thumbnail.search = ""; - thumbnail.hash = item.src; - return { ...item, src: `${thumbnail}` }; + Object.assign(item, await fixFavouriteItem(item)); + } } }); diff --git a/src/plugins/favouriteAnything/types.ts b/src/plugins/favouriteAnything/types.ts index f730f39a23a..455e6cb5090 100644 --- a/src/plugins/favouriteAnything/types.ts +++ b/src/plugins/favouriteAnything/types.ts @@ -117,6 +117,10 @@ export interface FavouriteItem { order: number; } +export interface FullFavouriteItem extends FavouriteItem { + url: string; +} + export enum CustomItemFormat { ATTACHMENT = 0 } diff --git a/src/plugins/favouriteAnything/utils.ts b/src/plugins/favouriteAnything/utils.ts index 3de605740b5..5455e48c8e8 100644 --- a/src/plugins/favouriteAnything/utils.ts +++ b/src/plugins/favouriteAnything/utils.ts @@ -18,7 +18,7 @@ import { Key } from "react"; import { JsonValue } from "type-fest"; import { base64ToUint8Array, uint8ArrayToBase64 } from "./polyfills"; -import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types"; +import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, FullFavouriteItem, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types"; const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative; @@ -98,10 +98,21 @@ export const defs = defineItems({ // This could be expanded in the future with other item types (e.g. voice messages) }); +export async function fixFavouriteItem(item: FullFavouriteItem): Promise { + if (item.format !== FavouriteItemFormat.NONE) return item; + + const thumbnail = await getThumbnailUrl(item.src, item.width, item.height); + if (!thumbnail) return item; + + thumbnail.search = ""; + thumbnail.hash = item.src; + return { ...item, src: `${thumbnail}` }; +} + // TODO: make thumbnails prettier const fallbackThumbnail = new URL("https://images-ext-1.discordapp.net/external/pGTJg3YdSHpyGTltH4vZUKEyQoNzf5mtqbSJs7I4ebc/https/equicord.org/assets/plugins/favoriteAnything/invalid.png"); -export async function getThumbnailUrl(data: string, width: number, height: number): Promise { +async function getThumbnailUrl(data: string, width: number, height: number): Promise { try { const decoded = defs.decode(data); if (!decoded || !width || !height) return null; From 45f96e340185b0fd6a6b2b6356864f70e10f4883 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:08:48 +0200 Subject: [PATCH 07/14] FA: remove polyfill --- src/plugins/favouriteAnything/polyfills.ts | 162 --------------------- src/plugins/favouriteAnything/utils.ts | 5 +- 2 files changed, 2 insertions(+), 165 deletions(-) delete mode 100644 src/plugins/favouriteAnything/polyfills.ts diff --git a/src/plugins/favouriteAnything/polyfills.ts b/src/plugins/favouriteAnything/polyfills.ts deleted file mode 100644 index 2fd5ef139c2..00000000000 --- a/src/plugins/favouriteAnything/polyfills.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2026 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -/* - * This file contains modified code from: - * https://github.com/tc39/proposal-arraybuffer-base64 - * * Copyright (c) 2017 ECMA TC39 and contributors - * * - * * Permission is hereby granted, free of charge, to any person obtaining a copy - * * of this software and associated documentation files (the "Software"), to deal - * * in the Software without restriction, including without limitation the rights - * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * * copies of the Software, and to permit persons to whom the Software is - * * furnished to do so, subject to the following conditions: - * * - * * The above copyright notice and this permission notice shall be included in all - * * copies or substantial portions of the Software. - * * - * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * * SOFTWARE. - */ - -// TODO: Remove once discord stops being insane and updates electron (stable has to be on >=38.0.0) - -function supportsToBase64(array: Uint8Array): boolean { - return "toBase64" in array && typeof array.toBase64 === "function"; -} - -function supportsFromBase64(ctor: Uint8ArrayConstructor): boolean { - return "fromBase64" in ctor && typeof ctor.fromBase64 === "function"; -} - -const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; -const map = new Map(chars.split("").map((c, i) => [c, i])); - -export function uint8ArrayToBase64(arr: Uint8Array): string { - if (supportsToBase64(arr)) { - return arr.toBase64({ alphabet: "base64url", omitPadding: true }); - } - - if ("detached" in arr.buffer && arr.buffer.detached) { - throw new TypeError("toBase64 called on array backed by detached buffer"); - } - - let result = ""; - - let i = 0; - for (; i + 2 < arr.length; i += 3) { - const triplet = (arr[i] << 16) + (arr[i + 1] << 8) + arr[i + 2]; - result += - chars[(triplet >> 18) & 63] + - chars[(triplet >> 12) & 63] + - chars[(triplet >> 6) & 63] + - chars[triplet & 63]; - } - if (i + 2 === arr.length) { - const triplet = (arr[i] << 16) + (arr[i + 1] << 8); - result += chars[(triplet >> 18) & 63] + chars[(triplet >> 12) & 63] + chars[(triplet >> 6) & 63]; - } else if (i + 1 === arr.length) { - const triplet = arr[i] << 16; - result += chars[(triplet >> 18) & 63] + chars[(triplet >> 12) & 63]; - } - - return result; -} - -function decodeBase64Chunk(chunk: string): number[] { - const actualChunkLength = chunk.length; - if (actualChunkLength < 4) { - chunk += actualChunkLength === 2 ? "AA" : "A"; - } - - const c1 = chunk[0]; - const c2 = chunk[1]; - const c3 = chunk[2]; - const c4 = chunk[3]; - - const triplet = (map.get(c1)! << 18) + (map.get(c2)! << 12) + (map.get(c3)! << 6) + map.get(c4)!; - - const chunkBytes = [(triplet >> 16) & 255, (triplet >> 8) & 255, triplet & 255]; - - if (actualChunkLength === 2) { - return [chunkBytes[0]]; - } else if (actualChunkLength === 3) { - return [chunkBytes[0], chunkBytes[1]]; - } - return chunkBytes; -} - -const asciiWhitespaceRegex = /[\u0009\u000A\u000C\u000D\u0020]/; -function skipAsciiWhitespace(string: string, index: number): number { - for (; index < string.length; ++index) { - if (!asciiWhitespaceRegex.test(string[index])) { - break; - } - } - return index; -} - -export function base64ToUint8Array(string: string): Uint8Array { - if (supportsFromBase64(Uint8Array)) { - return Uint8Array.fromBase64(string, { alphabet: "base64url" }); - } - - const bytes: number[] = []; - let chunk = ""; - - let index = 0; - while (true) { - index = skipAsciiWhitespace(string, index); - if (index === string.length) { - if (chunk.length > 0) { - if (chunk.length === 1) { - throw new SyntaxError("malformed padding: exactly one additional character"); - } - bytes.push(...decodeBase64Chunk(chunk)); - } - break; - } - const char = string[index]; - ++index; - if (char === "=") { - if (chunk.length < 2) { - throw new SyntaxError("padding is too early"); - } - index = skipAsciiWhitespace(string, index); - if (chunk.length === 2) { - if (index === string.length) { - throw new SyntaxError("malformed padding - only one ="); - } - if (string[index] === "=") { - ++index; - index = skipAsciiWhitespace(string, index); - } - } - if (index < string.length) { - throw new SyntaxError("unexpected character after padding"); - } - bytes.push(...decodeBase64Chunk(chunk)); - break; - } - if (!chars.includes(char)) { - throw new SyntaxError(`unexpected character ${JSON.stringify(char)}`); - } - - chunk += char; - if (chunk.length === 4) { - bytes.push(...decodeBase64Chunk(chunk)); - chunk = ""; - } - } - - return new Uint8Array(bytes); -} diff --git a/src/plugins/favouriteAnything/utils.ts b/src/plugins/favouriteAnything/utils.ts index 5455e48c8e8..3b89a9aab88 100644 --- a/src/plugins/favouriteAnything/utils.ts +++ b/src/plugins/favouriteAnything/utils.ts @@ -17,7 +17,6 @@ import { deflateSync, inflateSync } from "fflate"; import { Key } from "react"; import { JsonValue } from "type-fest"; -import { base64ToUint8Array, uint8ArrayToBase64 } from "./polyfills"; import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, FullFavouriteItem, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types"; const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative; @@ -40,7 +39,7 @@ function defineItems>(def: Ite const obj = [format, def[format].encode(data)]; const buf = deflateSync(encoder.encode(JSON.stringify(obj))); - return uint8ArrayToBase64(buf); + return buf.toBase64({ alphabet: "base64url", omitPadding: true }); } catch { return null; } @@ -49,7 +48,7 @@ function defineItems>(def: Ite try { if (!raw) return null; - const buf = inflateSync(base64ToUint8Array(raw)); + const buf = inflateSync(Uint8Array.fromBase64(raw, { alphabet: "base64url" })); const parsed: unknown[] | null = JSON.parse(decoder.decode(buf)); if (!Array.isArray(parsed)) return null; From 60d05fbcbac10455271ec4a4b6c02a79c47d99dd Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:02:36 +0200 Subject: [PATCH 08/14] FA: Make the thumbnails pretty :3 --- src/plugins/favouriteAnything/components.tsx | 73 +++++++++++-------- src/plugins/favouriteAnything/index.tsx | 25 +------ src/plugins/favouriteAnything/types.ts | 13 ++-- .../favouriteAnything/{utils.ts => utils.tsx} | 65 ++++++++--------- src/webpack/common/utils.ts | 2 + 5 files changed, 84 insertions(+), 94 deletions(-) rename src/plugins/favouriteAnything/{utils.ts => utils.tsx} (85%) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index 4599ada62ae..3b7fcb65418 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -10,12 +10,12 @@ import { LazyComponentWrapper } from "@utils/lazyReact"; import { Embed, ListRow, Message, MessageAttachment, ScrollerBaseRef } from "@vencord/discord-types"; import { ChannelType } from "@vencord/discord-types/enums"; import { findByCodeLazy, findComponentByCode, findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack"; -import { ChannelStore, ExpressionPickerStore, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common"; +import { ChannelStore, ExpressionPickerStore, Humanize, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common"; import { ComponentProps, ReactNode } from "react"; import { SignedUrlsStore } from "./stores"; -import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass } from "./types"; -import { cl, defs, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; +import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass, StaticFilePickerItemProps } from "./types"; +import { cl, getExtension, getThumbnailUrl, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; export const EmbedContext = proxyLazyWebpack(() => React.createContext(null)); export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext(null)); @@ -72,8 +72,6 @@ export const AttachmentPreview = proxyLazyWebpack(() => { }; }); -const noopRender = () => null; - export function FilePicker({ onSelectItem }: FilePickerProps) { const listRef = useRef(null); @@ -135,7 +133,6 @@ export function FilePicker({ onSelectItem }: FilePickerProps) { sections={[count]} sectionHeight={0} rowHeight={rowHeight} - renderSection={noopRender} renderRow={renderRow} /> @@ -187,19 +184,32 @@ function Demo() { function SendIcon({ height = 24, width = 24, ...props }: ComponentProps<"svg">) { return ( - + ); } +export function StaticFilePickerItem({ file }: StaticFilePickerItemProps) { + const ext = getExtension(file.filename); + const filename = (file.title ? file.title + (ext ?? "") : file.filename).slice(0, 50); + const size = Humanize.filesize(file.size); + + return ( + + + + + + {ext && {ext.slice(1, 4)}} + + + {filename} + {size} + + ); +} + export function FilePickerItem({ url, file, channel, onResize, onSubmit, reducePadding }: FilePickerItemProps) { const [isFetching, setIsFetching] = useState(false); @@ -318,14 +328,14 @@ export function AttachmentContextProvider({ attachment, component, children }: A const raw: MessageAttachment = "media" in originalItem ? { - ...originalItem.media, - id: rest.uniqueId, - size: 0, - spoiler: rest.spoiler, - filename: (rest.spoiler ? "SPOILER_" : "") + rest.uniqueId, - content_type: originalItem.media.contentType, - proxy_url: originalItem.media.proxyUrl - } + ...originalItem.media, + id: rest.uniqueId, + size: 0, + spoiler: rest.spoiler, + filename: (rest.spoiler ? "SPOILER_" : "") + rest.uniqueId, + content_type: originalItem.media.contentType, + proxy_url: originalItem.media.proxyUrl + } : originalItem; return { originalItem: raw, ...rest }; @@ -345,11 +355,18 @@ const visualMediaFormats: Partial(null); + + useEffect(() => { + if (!attachment?.type || attachment.type in visualMediaFormats) return; + + getThumbnailUrl(attachment.originalItem).then(url => url && setCustomThumbnail(url.toString())); + }, [attachment]); const props: FavoriteButtonProps | null = useMemo(() => { if (!attachment?.downloadUrl) return null; const { originalItem, type, downloadUrl, srcIsAnimated } = attachment; - const width = attachment.width || 600, height = attachment.height || 400; + const width = attachment.width || 320, height = attachment.height || 110; // Do not render the custom accessory if the original attachment component already has a gif accessory const isAnimated = ImageUtils.isAnimated({ @@ -364,14 +381,10 @@ export function AttachmentAccessory() { return { format: visualMediaFormats[type]!, src: originalItem.proxy_url, url: downloadUrl, width, height }; } - // Non visual attachments have to be encoded to store metadata in the src property. - // Note that this isn't a valid url yet, the full url (with a fallback image for vanilla client compat) - // is generated via `getThumbnailUrl` once the user clicks the favourite button - const src = defs.encode(CustomItemFormat.ATTACHMENT, originalItem)?.toString(); - if (!src) return null; + if (!customThumbnail) return null; - return { format: FavouriteItemFormat.NONE, src, url: downloadUrl, width, height }; - }, [attachment]); + return { format: FavouriteItemFormat.NONE, src: customThumbnail, url: downloadUrl, width, height }; + }, [attachment, customThumbnail]); return props && ; } diff --git a/src/plugins/favouriteAnything/index.tsx b/src/plugins/favouriteAnything/index.tsx index 83649c65e35..21f828ec5a2 100644 --- a/src/plugins/favouriteAnything/index.tsx +++ b/src/plugins/favouriteAnything/index.tsx @@ -10,10 +10,8 @@ import definePlugin from "@utils/types"; import { ComponentType, ReactNode } from "react"; import { AttachmentAccessory, AttachmentContextProvider, EmbedAccessory, EmbedContext, EmbedMosaicContext, FilePicker } from "./components"; -import { SignedUrlsStore } from "./stores"; import managedStyle from "./style.css?managed"; -import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat, FullFavouriteItem } from "./types"; -import { fixFavouriteItem } from "./utils"; +import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types"; export default definePlugin({ name: "FavouriteAnything", @@ -95,15 +93,6 @@ export default definePlugin({ match: '.sortBy("order").reverse()', replace: "$&.filter($self.filterGifs)" } - }, - // PROTOBUF - { - find: "#{intl::FAVORITE_GIFS_LIMIT_REACHED_BODY}", - replacement: { - // Intercept add/remove actions to generate a valid thumbnail url before storing the item - match: /function (\i)\((\i)\)\{(?=\i\.\i\.updateAsync\("favoriteGifs")/g, - replace: "async function $1($2){await $self.convertFavItem($2);await " - } } ], renderTabs(Tab: ComponentType, activeView: ExpressionPickerView) { @@ -149,15 +138,5 @@ export default definePlugin({ }, renderAttachmentAccessory: () => , renderEmbedAccessory: () => , - filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE, - convertFavItem: async (item: FullFavouriteItem | string) => { - if (typeof item === "string") { - SignedUrlsStore.addSigned(item); - } else { - SignedUrlsStore.addSigned(item.url); - SignedUrlsStore.addSigned(item.src); - - Object.assign(item, await fixFavouriteItem(item)); - } - } + filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE }); diff --git a/src/plugins/favouriteAnything/types.ts b/src/plugins/favouriteAnything/types.ts index 455e6cb5090..77d71fd64e4 100644 --- a/src/plugins/favouriteAnything/types.ts +++ b/src/plugins/favouriteAnything/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Channel, Embed, EmbedJSON, Message, MessageAttachment, TextInput } from "@vencord/discord-types"; +import { Channel, Embed, Message, MessageAttachment, TextInput } from "@vencord/discord-types"; import { Component, ComponentClass, ComponentProps, ComponentPropsWithRef, Key, PropsWithChildren, ReactNode, RefObject } from "react"; import { JsonValue, PartialDeep } from "type-fest"; @@ -59,9 +59,12 @@ export interface FilePickerProps { onSelectItem: (item: { url: string; }) => void; } -export interface FilePickerItemProps { - url: string; +export interface StaticFilePickerItemProps { file: MessageAttachment; +} + +export interface FilePickerItemProps extends StaticFilePickerItemProps { + url: string; channel: Channel | null; reducePadding?: boolean; onResize: (key: Key, height: number) => void; @@ -144,10 +147,6 @@ export interface RefreshedUrlsResponse { ]; } -export interface UnfurledEmbedsResponse { - embeds: EmbedJSON[]; -} - export type ResizeObserverHook = ( ref: RefObject, callback: (size: { width: number; height: number; }) => void, diff --git a/src/plugins/favouriteAnything/utils.ts b/src/plugins/favouriteAnything/utils.tsx similarity index 85% rename from src/plugins/favouriteAnything/utils.ts rename to src/plugins/favouriteAnything/utils.tsx index 3b89a9aab88..66c23d1357b 100644 --- a/src/plugins/favouriteAnything/utils.ts +++ b/src/plugins/favouriteAnything/utils.tsx @@ -12,12 +12,13 @@ import { useForceUpdater } from "@utils/react"; import { PluginNative } from "@utils/types"; import { Channel, MessageAttachment } from "@vencord/discord-types"; import { findByCodeLazy, findByPropsLazy } from "@webpack"; -import { Constants, DraftType, FluxDispatcher, MessageActions, PendingReplyStore, PermissionStore, RestAPI, Toasts, UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common"; +import { createRoot, DraftType, FluxDispatcher, MessageActions, PendingReplyStore, PermissionStore, ReactDOM, Toasts, UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common"; import { deflateSync, inflateSync } from "fflate"; -import { Key } from "react"; +import { Key, ReactNode } from "react"; import { JsonValue } from "type-fest"; -import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, FullFavouriteItem, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types"; +import { StaticFilePickerItem } from "./components"; +import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook } from "./types"; const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative; @@ -97,39 +98,38 @@ export const defs = defineItems({ // This could be expanded in the future with other item types (e.g. voice messages) }); -export async function fixFavouriteItem(item: FullFavouriteItem): Promise { - if (item.format !== FavouriteItemFormat.NONE) return item; +export function getExtension(filename: string): string | null { + const ext = filename.lastIndexOf("."); + return ext > 0 ? filename.substring(ext) : null; +} - const thumbnail = await getThumbnailUrl(item.src, item.width, item.height); - if (!thumbnail) return item; +function renderToHTML(node: ReactNode): Promise { + return new Promise(resolve => { + const container = document.createElement("div"); + const root = createRoot(container); - thumbnail.search = ""; - thumbnail.hash = item.src; - return { ...item, src: `${thumbnail}` }; + // A full render can't happen while another render (or effect in this case) is happening + queueMicrotask(() => { + ReactDOM.flushSync(() => root.render(node)); + resolve(container.innerHTML); + root.unmount(); + }); + }); } -// TODO: make thumbnails prettier -const fallbackThumbnail = new URL("https://images-ext-1.discordapp.net/external/pGTJg3YdSHpyGTltH4vZUKEyQoNzf5mtqbSJs7I4ebc/https/equicord.org/assets/plugins/favoriteAnything/invalid.png"); - -async function getThumbnailUrl(data: string, width: number, height: number): Promise { +export async function getThumbnailUrl(item: MessageAttachment): Promise { try { - const decoded = defs.decode(data); - if (!decoded || !width || !height) return null; - - const text = defs.stringify(decoded.format, decoded.data); - const url = new URL(`https://placehold.jp/42/444/fff/${width}x${height}.png`); - url.searchParams.append("text", text); - - 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 ? new URL(thumbnail.proxy_url) : fallbackThumbnail; - }); + const html = await renderToHTML(); + const metadata = defs.encode(CustomItemFormat.ATTACHMENT, item)?.toString(); + if (!html || !metadata) return null; + + // The base url is the thumbnail itself which is used for vanilla client compatibility, while the hash stores extra metadata. + // encodeURIComponent is intentionally avoided since it would bloat the url size by replacing otherwise safe characters + const url = new URL("data:image/svg+xml," + html.replaceAll("%", "%25").replaceAll("#", "%23")); + url.hash = metadata; + return url; } catch { - return fallbackThumbnail; + return null; } } @@ -187,10 +187,7 @@ export async function sendAttachment(attachment: MessageAttachment, channel: Cha UploadManager.setUploads({ uploads, channelId: channel.id, draftType: DraftType.ChannelMessage }); // Empty titles and descriptions are allowed - if (title != null) { - const ext = upload.filename.lastIndexOf("."); - upload.filename = title + (ext > 0 ? upload.filename.substring(ext) : ""); - } + if (title != null) upload.filename = title + (getExtension(upload.filename) ?? ""); if (description != null) upload.description = description; FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id }); diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index 4e64910ef61..dbe40689447 100644 --- a/src/webpack/common/utils.ts +++ b/src/webpack/common/utils.ts @@ -215,4 +215,6 @@ export const DateUtils: t.DateUtils = mapMangledModuleLazy("millisecondsInUnit:" diffAsUnits: filters.byCode("days:0", "millisecondsInUnit") }); +export const Humanize: t.Humanize = findByPropsLazy("nl2br", "humanize"); + export const MessageTypeSets: t.MessageTypeSets = findByPropsLazy("REPLYABLE", "FORWARDABLE"); From 9a074bf05c999ddb148f510523fe7b71bc8985a6 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:47:34 +0200 Subject: [PATCH 09/14] FA: improve optical alignment --- src/plugins/favouriteAnything/components.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index 3b7fcb65418..278cf923bd8 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -195,8 +195,9 @@ export function StaticFilePickerItem({ file }: StaticFilePickerItemProps) { const filename = (file.title ? file.title + (ext ?? "") : file.filename).slice(0, 50); const size = Humanize.filesize(file.size); + // Keep this compact! Long prop names, styles, numbers, etc could be wasteful return ( - + @@ -204,8 +205,8 @@ export function StaticFilePickerItem({ file }: StaticFilePickerItemProps) { {ext && {ext.slice(1, 4)}} - {filename} - {size} + {filename} + {size} ); } From 5528467c8d2a78726c911296bb66f372ddb0c447 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:36:36 +0200 Subject: [PATCH 10/14] FA: inline fill color --- src/plugins/favouriteAnything/components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index 278cf923bd8..b98719cb08e 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -197,7 +197,7 @@ export function StaticFilePickerItem({ file }: StaticFilePickerItemProps) { // Keep this compact! Long prop names, styles, numbers, etc could be wasteful return ( - + From 8eede6081d9c8fdb5c95c48d76c1bbad5b246ae3 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:30:27 +0200 Subject: [PATCH 11/14] FA: nin0ware --- src/plugins/favouriteAnything/components.tsx | 56 ++++++-------------- src/plugins/favouriteAnything/index.tsx | 39 ++++++++++++-- src/plugins/favouriteAnything/stores.ts | 11 ++-- src/plugins/favouriteAnything/types.ts | 17 +++--- src/plugins/favouriteAnything/utils.tsx | 46 +++++++++++----- 5 files changed, 100 insertions(+), 69 deletions(-) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index b98719cb08e..2ae3f6d11e7 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -10,12 +10,12 @@ import { LazyComponentWrapper } from "@utils/lazyReact"; import { Embed, ListRow, Message, MessageAttachment, ScrollerBaseRef } from "@vencord/discord-types"; import { ChannelType } from "@vencord/discord-types/enums"; import { findByCodeLazy, findComponentByCode, findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack"; -import { ChannelStore, ExpressionPickerStore, Humanize, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common"; +import { ChannelStore, ExpressionPickerStore, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common"; import { ComponentProps, ReactNode } from "react"; import { SignedUrlsStore } from "./stores"; import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass, StaticFilePickerItemProps } from "./types"; -import { cl, getExtension, getThumbnailUrl, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; +import { cl, getExtension, getFileThumbnailUrl, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; export const EmbedContext = proxyLazyWebpack(() => React.createContext(null)); export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext(null)); @@ -118,23 +118,11 @@ export function FilePicker({ onSelectItem }: FilePickerProps) { return (
- +
{count > 0 ? (
- +
) : (
@@ -190,10 +178,8 @@ function SendIcon({ height = 24, width = 24, ...props }: ComponentProps<"svg">) ); } -export function StaticFilePickerItem({ file }: StaticFilePickerItemProps) { - const ext = getExtension(file.filename); - const filename = (file.title ? file.title + (ext ?? "") : file.filename).slice(0, 50); - const size = Humanize.filesize(file.size); +export function StaticFilePickerItem({ name, subtitle }: StaticFilePickerItemProps) { + const ext = getExtension(name); // Keep this compact! Long prop names, styles, numbers, etc could be wasteful return ( @@ -205,8 +191,8 @@ export function StaticFilePickerItem({ file }: StaticFilePickerItemProps) { {ext && {ext.slice(1, 4)}} - {filename} - {size} + {name} + {subtitle} ); } @@ -356,36 +342,26 @@ const visualMediaFormats: Partial(null); - - useEffect(() => { - if (!attachment?.type || attachment.type in visualMediaFormats) return; - - getThumbnailUrl(attachment.originalItem).then(url => url && setCustomThumbnail(url.toString())); - }, [attachment]); const props: FavoriteButtonProps | null = useMemo(() => { if (!attachment?.downloadUrl) return null; const { originalItem, type, downloadUrl, srcIsAnimated } = attachment; - const width = attachment.width || 320, height = attachment.height || 110; + const width = attachment.width || 160, height = attachment.height || 55; // Do not render the custom accessory if the original attachment component already has a gif accessory - const isAnimated = ImageUtils.isAnimated({ - original: originalItem.url, - src: originalItem.proxy_url, - animated: false, - srcIsAnimated - }); + const isAnimated = ImageUtils.isAnimated({ original: originalItem.url, src: originalItem.proxy_url, animated: false, srcIsAnimated }); if (isAnimated) return null; if (type in visualMediaFormats) { return { format: visualMediaFormats[type]!, src: originalItem.proxy_url, url: downloadUrl, width, height }; } - if (!customThumbnail) return null; - - return { format: FavouriteItemFormat.NONE, src: customThumbnail, url: downloadUrl, width, height }; - }, [attachment, customThumbnail]); + const gifSrc = Object.assign( + () => getFileThumbnailUrl(originalItem).then(url => url.toString()), + { [Symbol.toPrimitive]: () => "" } + ); + return { format: FavouriteItemFormat.NONE, src: originalItem.proxy_url, url: downloadUrl, width, height, gifSrc }; + }, [attachment]); return props && ; } diff --git a/src/plugins/favouriteAnything/index.tsx b/src/plugins/favouriteAnything/index.tsx index 53a8ef8342e..5c7093b3c2c 100644 --- a/src/plugins/favouriteAnything/index.tsx +++ b/src/plugins/favouriteAnything/index.tsx @@ -4,14 +4,24 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { getIntlMessage } from "@utils/discord"; -import definePlugin from "@utils/types"; +import definePlugin, { OptionType } from "@utils/types"; import { ComponentType, ReactNode } from "react"; import { AttachmentAccessory, AttachmentContextProvider, EmbedAccessory, EmbedContext, EmbedMosaicContext, FilePicker } from "./components"; +import { SignedUrlsStore } from "./stores"; import managedStyle from "./style.css?managed"; -import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types"; +import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat, FullFavouriteItem } from "./types"; + +export const settings = definePluginSettings({ + localThumbnails: { + type: OptionType.BOOLEAN, + default: false, + description: "Generate file thumbnails locally instead of using an external service (placeholder.nin0.dev). Not compatible with mobile. Toggling this option will not affect existing favourites.", + } +}); export default definePlugin({ name: "FavouriteAnything", @@ -20,6 +30,7 @@ export default definePlugin({ authors: [Devs.Davri, Devs.nin0dev], searchTerms: ["favorite"], managedStyle, + settings, patches: [ // EMBEDS { @@ -93,6 +104,15 @@ export default definePlugin({ match: '.sortBy("order").reverse()', replace: "$&.filter($self.filterGifs)" } + }, + // PROTOBUF + { + find: "#{intl::FAVORITE_GIFS_LIMIT_REACHED_BODY}", + replacement: { + // Intercept add/remove actions to generate a valid thumbnail url before storing the item + match: /function (\i)\((\i)\)\{(?=\i\.\i\.updateAsync\("favoriteGifs")/g, + replace: "async function $1($2){await $self.fixFavItem($2);await " + } } ], renderTabs(Tab: ComponentType, activeView: ExpressionPickerView) { @@ -138,5 +158,18 @@ export default definePlugin({ }, renderAttachmentAccessory: () => , renderEmbedAccessory: () => , - filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE + filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE, + fixFavItem: async (item: FullFavouriteItem | string) => { + if (typeof item === "string") { + SignedUrlsStore.addSigned(item); + } else { + SignedUrlsStore.addSigned(item.url); + SignedUrlsStore.addSigned(item.src); + + if (typeof item.gifSrc === "function") { + item.src = await item.gifSrc(); + delete item.gifSrc; + } + } + } }); diff --git a/src/plugins/favouriteAnything/stores.ts b/src/plugins/favouriteAnything/stores.ts index 6a40d4a24ea..a553c9b6881 100644 --- a/src/plugins/favouriteAnything/stores.ts +++ b/src/plugins/favouriteAnything/stores.ts @@ -81,13 +81,10 @@ export const SignedUrlsStore = proxyLazyWebpack(() => { } private async _handleBatch(batch: string[]): Promise { - await RestAPI.post({ - url: Constants.Endpoints.ATTACHMENTS_REFRESH_URLS, - body: { attachment_urls: batch }, - retries: 3 - }).then(({ body }: { body: RefreshedUrlsResponse }) => - this._update(body.refreshed_urls.map(({ original, refreshed }) => [original, refreshed!])) - ); + await RestAPI.post({ url: Constants.Endpoints.ATTACHMENTS_REFRESH_URLS, body: { attachment_urls: batch }, retries: 3 }) + .then(({ body }: { body: RefreshedUrlsResponse; }) => + this._update(body.refreshed_urls.map(({ original, refreshed }) => [original, refreshed!])) + ); } } diff --git a/src/plugins/favouriteAnything/types.ts b/src/plugins/favouriteAnything/types.ts index 77d71fd64e4..5f42aaf957b 100644 --- a/src/plugins/favouriteAnything/types.ts +++ b/src/plugins/favouriteAnything/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Channel, Embed, Message, MessageAttachment, TextInput } from "@vencord/discord-types"; +import { Channel, Embed, EmbedJSON, Message, MessageAttachment, TextInput } from "@vencord/discord-types"; import { Component, ComponentClass, ComponentProps, ComponentPropsWithRef, Key, PropsWithChildren, ReactNode, RefObject } from "react"; import { JsonValue, PartialDeep } from "type-fest"; @@ -24,9 +24,7 @@ export interface ExpressionPickerTabProps extends PropsWithChildren { viewType: ExpressionPickerView; } -export interface FavoriteButtonProps extends Omit { - url: string; - gifSrc?: string; +export interface FavoriteButtonProps extends Omit { className?: string; } @@ -60,10 +58,12 @@ export interface FilePickerProps { } export interface StaticFilePickerItemProps { - file: MessageAttachment; + name: string; + subtitle: string; } -export interface FilePickerItemProps extends StaticFilePickerItemProps { +export interface FilePickerItemProps { + file: MessageAttachment; url: string; channel: Channel | null; reducePadding?: boolean; @@ -121,6 +121,7 @@ export interface FavouriteItem { } export interface FullFavouriteItem extends FavouriteItem { + gifSrc?: () => Promise; url: string; } @@ -138,6 +139,10 @@ export type ItemsDef = T & { [K in keyof T]: T[K] extends CustomItemDef ? CustomItemDef : never; }; +export interface UnfurledEmbedsResponse { + embeds: EmbedJSON[]; +} + export interface RefreshedUrlsResponse { refreshed_urls: [ { diff --git a/src/plugins/favouriteAnything/utils.tsx b/src/plugins/favouriteAnything/utils.tsx index 66c23d1357b..4ba69ede4a3 100644 --- a/src/plugins/favouriteAnything/utils.tsx +++ b/src/plugins/favouriteAnything/utils.tsx @@ -12,13 +12,14 @@ import { useForceUpdater } from "@utils/react"; import { PluginNative } from "@utils/types"; import { Channel, MessageAttachment } from "@vencord/discord-types"; import { findByCodeLazy, findByPropsLazy } from "@webpack"; -import { createRoot, DraftType, FluxDispatcher, MessageActions, PendingReplyStore, PermissionStore, ReactDOM, Toasts, UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common"; +import { Constants, createRoot, DraftType, FluxDispatcher, Humanize, MessageActions, PendingReplyStore, PermissionStore, ReactDOM, RestAPI, Toasts, 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 } from "./types"; +import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types"; const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative; @@ -108,7 +109,6 @@ function renderToHTML(node: ReactNode): Promise { const container = document.createElement("div"); const root = createRoot(container); - // A full render can't happen while another render (or effect in this case) is happening queueMicrotask(() => { ReactDOM.flushSync(() => root.render(node)); resolve(container.innerHTML); @@ -117,19 +117,39 @@ function renderToHTML(node: ReactNode): Promise { }); } -export async function getThumbnailUrl(item: MessageAttachment): Promise { - try { - const html = await renderToHTML(); - const metadata = defs.encode(CustomItemFormat.ATTACHMENT, item)?.toString(); - if (!html || !metadata) return null; +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"); - // The base url is the thumbnail itself which is used for vanilla client compatibility, while the hash stores extra metadata. +async function getThumbnailBase(item: MessageAttachment): Promise { + const name = (item.title ? item.title + (getExtension(item.filename) ?? "") : item.filename).slice(0, 50); + const subtitle = Humanize.filesize(item.size); + + if (settings.store.localThumbnails) { + const html = await renderToHTML(); // encodeURIComponent is intentionally avoided since it would bloat the url size by replacing otherwise safe characters - const url = new URL("data:image/svg+xml," + html.replaceAll("%", "%25").replaceAll("#", "%23")); - url.hash = metadata; - return url; + 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 { + 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 null; + return FALLBACK_THUMBNAIL; } } From 2d396fa821f93513cec213ecf8cc73e26cba5137 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:32:51 +0200 Subject: [PATCH 12/14] FA: forgor --- src/plugins/favouriteAnything/utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/favouriteAnything/utils.tsx b/src/plugins/favouriteAnything/utils.tsx index 4ba69ede4a3..32aac27997e 100644 --- a/src/plugins/favouriteAnything/utils.tsx +++ b/src/plugins/favouriteAnything/utils.tsx @@ -145,7 +145,7 @@ export async function getFileThumbnailUrl(item: MessageAttachment): Promise 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 { From 2d0857e0987f2a90a5ad7ba449b2a4e0ba461f1e Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:51:03 +0200 Subject: [PATCH 13/14] FA: fix truncation --- src/plugins/favouriteAnything/components.tsx | 4 ++-- src/plugins/favouriteAnything/utils.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index 2ae3f6d11e7..14f40efffac 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -15,7 +15,7 @@ import { ComponentProps, ReactNode } from "react"; import { SignedUrlsStore } from "./stores"; import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass, StaticFilePickerItemProps } from "./types"; -import { cl, getExtension, getFileThumbnailUrl, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; +import { cl, getFilenameAndExtension, getFileThumbnailUrl, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; export const EmbedContext = proxyLazyWebpack(() => React.createContext(null)); export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext(null)); @@ -179,7 +179,7 @@ function SendIcon({ height = 24, width = 24, ...props }: ComponentProps<"svg">) } export function StaticFilePickerItem({ name, subtitle }: StaticFilePickerItemProps) { - const ext = getExtension(name); + const [, ext] = getFilenameAndExtension(name); // Keep this compact! Long prop names, styles, numbers, etc could be wasteful return ( diff --git a/src/plugins/favouriteAnything/utils.tsx b/src/plugins/favouriteAnything/utils.tsx index 32aac27997e..e7073c951ba 100644 --- a/src/plugins/favouriteAnything/utils.tsx +++ b/src/plugins/favouriteAnything/utils.tsx @@ -99,9 +99,9 @@ export const defs = defineItems({ // This could be expanded in the future with other item types (e.g. voice messages) }); -export function getExtension(filename: string): string | null { +export function getFilenameAndExtension(filename: string): [name: string, ext: string | null] { const ext = filename.lastIndexOf("."); - return ext > 0 ? filename.substring(ext) : null; + return ext > 0 ? [filename.substring(0, ext), filename.substring(ext)] : [filename, null]; } function renderToHTML(node: ReactNode): Promise { @@ -120,7 +120,8 @@ function renderToHTML(node: ReactNode): Promise { 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 { - const name = (item.title ? item.title + (getExtension(item.filename) ?? "") : item.filename).slice(0, 50); + 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) { @@ -207,7 +208,7 @@ export async function sendAttachment(attachment: MessageAttachment, channel: Cha UploadManager.setUploads({ uploads, channelId: channel.id, draftType: DraftType.ChannelMessage }); // Empty titles and descriptions are allowed - if (title != null) upload.filename = title + (getExtension(upload.filename) ?? ""); + 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 }); From 3db66d246c2e52cecb6ebd4e4a3943aa3bac30e7 Mon Sep 17 00:00:00 2001 From: Davri <42148912+Davr1@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:08:58 +0200 Subject: [PATCH 14/14] FA: fix text style --- src/plugins/favouriteAnything/components.tsx | 2 +- src/plugins/favouriteAnything/style.css | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plugins/favouriteAnything/components.tsx b/src/plugins/favouriteAnything/components.tsx index 14f40efffac..06ef074cfbb 100644 --- a/src/plugins/favouriteAnything/components.tsx +++ b/src/plugins/favouriteAnything/components.tsx @@ -161,7 +161,7 @@ function Demo() { format={FavouriteItemFormat.NONE} />
- + Click the star to favourite a file.
Favourite files will show up here! diff --git a/src/plugins/favouriteAnything/style.css b/src/plugins/favouriteAnything/style.css index 400cb0d12cc..c18d13d51b8 100644 --- a/src/plugins/favouriteAnything/style.css +++ b/src/plugins/favouriteAnything/style.css @@ -88,8 +88,6 @@ .vc-favouriteAnything-info-text { text-align: center; - line-height: 1.6; - font-size: 1.1rem; } .vc-favouriteAnything-demo { @@ -104,4 +102,4 @@ border-radius: 4px; box-shadow: 0 0 0 4px var(--yellow-300); } -} +} \ No newline at end of file