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..06ef074cfbb --- /dev/null +++ b/src/plugins/favouriteAnything/components.tsx @@ -0,0 +1,367 @@ +/* + * 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 { ComponentProps, ReactNode } from "react"; + +import { SignedUrlsStore } from "./stores"; +import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass, StaticFilePickerItemProps } from "./types"; +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)); +const AttachmentContext = proxyLazyWebpack(() => React.createContext(null)); + +const ManaSearchBar = findComponentByCodeLazy("#{intl::SEARCH}),ref"); +const FavoriteButton = findComponentByCodeLazy("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}"); + +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 ( + + ); + }; +}); + +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! +
+ + ); +} + +function SendIcon({ height = 24, width = 24, ...props }: ComponentProps<"svg">) { + return ( + + + + ); +} + +export function StaticFilePickerItem({ name, subtitle }: StaticFilePickerItemProps) { + const [, ext] = getFilenameAndExtension(name); + + // Keep this compact! Long prop names, styles, numbers, etc could be wasteful + return ( + + + + + + {ext && {ext.slice(1, 4)}} + + + {name} + {subtitle} + + ); +} + +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 || 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 }); + if (isAnimated) return null; + + if (type in visualMediaFormats) { + return { format: visualMediaFormats[type]!, src: originalItem.proxy_url, url: downloadUrl, width, height }; + } + + 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 new file mode 100644 index 00000000000..5c7093b3c2c --- /dev/null +++ b/src/plugins/favouriteAnything/index.tsx @@ -0,0 +1,175 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * 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, { 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, 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", + description: "Favourite any image, video, or file attachment", + tags: ["Chat", "Media", "Utility"], + authors: [Devs.Davri, Devs.nin0dev], + searchTerms: ["favorite"], + managedStyle, + settings, + 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(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: /let \i=Math.max\(0,(\i)\.length-\i\)/, + replace: "$1.unshift($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)" + } + }, + // 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) { + 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(comp: EmbedComponent) { + return {comp.__render()}; + }, + renderEmbedMosaicItem(children: ReactNode, index: number) { + return {children}; + }, + renderAttachmentAccessory: () => , + renderEmbedAccessory: () => , + 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/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/stores.ts b/src/plugins/favouriteAnything/stores.ts new file mode 100644 index 00000000000..a553c9b6881 --- /dev/null +++ b/src/plugins/favouriteAnything/stores.ts @@ -0,0 +1,92 @@ +/* + * 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 = "SignedUrlsStore"; + 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..c18d13d51b8 --- /dev/null +++ b/src/plugins/favouriteAnything/style.css @@ -0,0 +1,105 @@ +.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; +} + +.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); + } +} \ No newline at end of file diff --git a/src/plugins/favouriteAnything/types.ts b/src/plugins/favouriteAnything/types.ts new file mode 100644 index 00000000000..5f42aaf957b --- /dev/null +++ b/src/plugins/favouriteAnything/types.ts @@ -0,0 +1,165 @@ +/* + * 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 { + 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 StaticFilePickerItemProps { + name: string; + subtitle: string; +} + +export interface FilePickerItemProps { + file: MessageAttachment; + url: string; + 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 interface FullFavouriteItem extends FavouriteItem { + gifSrc?: () => Promise; + url: string; +} + +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 UnfurledEmbedsResponse { + embeds: EmbedJSON[]; +} + +export interface RefreshedUrlsResponse { + refreshed_urls: [ + { + original: string; + refreshed: string | null; + } + ]; +} + +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.tsx b/src/plugins/favouriteAnything/utils.tsx new file mode 100644 index 00000000000..e7073c951ba --- /dev/null +++ b/src/plugins/favouriteAnything/utils.tsx @@ -0,0 +1,353 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; +import { sendMessage } from "@utils/discord"; +import { proxyLazy } from "@utils/lazy"; +import { Queue } from "@utils/Queue"; +import { useForceUpdater } from "@utils/react"; +import { PluginNative } from "@utils/types"; +import { Channel, MessageAttachment } from "@vencord/discord-types"; +import { findByCodeLazy, findByPropsLazy } from "@webpack"; +import { Constants, createRoot, DraftType, FluxDispatcher, Humanize, MessageActions, PendingReplyStore, PermissionStore, ReactDOM, RestAPI, 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, 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 buf.toBase64({ alphabet: "base64url", omitPadding: true }); + } catch { + return null; + } + }, + decode: (raw: string) => { + try { + if (!raw) return null; + + const buf = inflateSync(Uint8Array.fromBase64(raw, { alphabet: "base64url" })); + const parsed: unknown[] | null = JSON.parse(decoder.decode(buf)); + if (!Array.isArray(parsed)) return null; + + const [format, data] = parsed as [keyof typeof def, JsonValue]; + if (!(format in def)) return null; + + return { format, data: def[format].decode(data) } as { + [F in CustomItemFormat]: { format: F; data: Type; }; + }[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) +}); + +export function getFilenameAndExtension(filename: string): [name: string, ext: string | null] { + const ext = filename.lastIndexOf("."); + return ext > 0 ? [filename.substring(0, ext), filename.substring(ext)] : [filename, null]; +} + +function renderToHTML(node: ReactNode): Promise { + return new Promise(resolve => { + const container = document.createElement("div"); + const root = createRoot(container); + + queueMicrotask(() => { + ReactDOM.flushSync(() => root.render(node)); + resolve(container.innerHTML); + root.unmount(); + }); + }); +} + +export const FALLBACK_THUMBNAIL = new URL("https://images-ext-1.discordapp.net/external/085KIKMVni8n60G3GHE1rGcA0xgH6OgBKIZqUiQYsXc/%3Fname%3DUnknown%2520file%26subtitle%3D0%2520MB/https/placeholder.nin0.dev/image"); + +async function getThumbnailBase(item: MessageAttachment): Promise { + const [filename, ext] = getFilenameAndExtension(item.filename); + const name = Humanize.truncatechars(item.title ? item.title : filename, 50) + (ext?.slice(0, 6) ?? ""); + const subtitle = Humanize.filesize(item.size); + + if (settings.store.localThumbnails) { + const html = await renderToHTML(); + // encodeURIComponent is intentionally avoided since it would bloat the url size by replacing otherwise safe characters + return URL.parse("data:image/svg+xml," + html.replaceAll("%", "%25").replaceAll("#", "%23")); + } else { + const url = new URL("https://placeholder.nin0.dev/image"); + url.searchParams.append("name", name || " "); + url.searchParams.append("subtitle", subtitle); + + return await RestAPI.post({ url: Constants.Endpoints.UNFURL_EMBED_URLS, body: { urls: [url] }, retries: 3 }) + .then(({ body }: { body: UnfurledEmbedsResponse; }) => { + const [{ thumbnail } = {}] = body.embeds; + return thumbnail?.proxy_url ? URL.parse(thumbnail.proxy_url) : null; + }); + } +} + +export async function getFileThumbnailUrl(item: MessageAttachment): Promise { + try { + const base = await getThumbnailBase(item); + const metadata = defs.encode(CustomItemFormat.ATTACHMENT, item)?.toString(); + if (!base || !metadata) return FALLBACK_THUMBNAIL; + + base.hash = metadata; + return base; + } catch { + return FALLBACK_THUMBNAIL; + } +} + +export const isAllowedHost = proxyLazy(() => { + // GLOBAL_ENV is not initialized immediately + const allowedHosts = new Set([ + 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 + (getFilenameAndExtension(upload.filename)[1] ?? ""); + if (description != null) upload.description = description; + + FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id }); + + void sendMessage(channel.id, {}, false, { + ...MessageActions.getSendMessageOptionsForReply(reply), + attachmentsToUpload: [upload] + }); +} + +export function hasPermission(permission: bigint, channel: Channel | null): boolean { + return !!channel && (PermissionStore.can(permission, channel) || channel.isPrivate()); +} + +const diacriticsRegex = /[\u0300-\u036f]/g; +function normalize(str: string) { + return str.normalize("NFD").replace(diacriticsRegex, "").normalize("NFKC").toLowerCase().trim(); +} + +// Stolen from favGifSearch +function fuzzySearch(searchQuery: string, searchString: string) { + let searchIndex = 0; + let score = 0; + + for (let i = 0; i < searchString.length; i++) { + if (searchString[i] === searchQuery[searchIndex]) { + score++; + searchIndex++; + } else { + score--; + } + + if (searchIndex === searchQuery.length) { + return score; + } + } + + return null; +} + +export function useFavourites(itemFormat: CustomItemFormat, searchQuery?: string) { + useEffect(() => void UserSettingsActionCreators.FrecencyUserSettingsActionCreators.loadIfNecessary(), []); + + const items = useStateFromStores( + [UserSettingsProtoStore], + () => { + const gifs: Record | 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 024acfd3089..ea6ee5779bf 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -655,8 +655,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({ id: 383365021415243776n }, paige: { - name: "paige", - id: 1375697625864601650n + name: "paige", + id: 1375697625864601650n }, jax: { name: "jax", diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index 32eb73a71c5..dbe40689447 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 === [", { @@ -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");