diff --git a/packages/discord-types/src/common/messages/Embed.d.ts b/packages/discord-types/src/common/messages/Embed.d.ts index 1c06756f4..9142d417c 100644 --- a/packages/discord-types/src/common/messages/Embed.d.ts +++ b/packages/discord-types/src/common/messages/Embed.d.ts @@ -23,10 +23,10 @@ export interface EmbedMedia { height: number; width: number; url: string; - proxyURL: string; + proxyURL?: string; placeholder: string; placeholderVersion: number; - description: string; + description?: string; srcIsAnimated: boolean; flags: number; contentType: string; diff --git a/packages/discord-types/src/components.d.ts b/packages/discord-types/src/components.d.ts index 32c7be0a0..2a3ac6cad 100644 --- a/packages/discord-types/src/components.d.ts +++ b/packages/discord-types/src/components.d.ts @@ -411,6 +411,24 @@ export type MaskedLink = ComponentType>; +interface ScrollToOptions { + animate?: boolean; + callback?: (() => unknown); +} + +/** Full type can be found at {@link https://github.com/fedeericodl/discord-client-types/blob/main/src/discord_common/js/packages/design/components/Scroller/utils/core/getAnimatedScrollHelpers.ts} */ +export interface ScrollerBaseRef { + scrollTo: (props: { to: number; } & ScrollToOptions) => void; + scrollPageUp: (props?: ScrollToOptions) => void; + scrollPageDown: (props?: ScrollToOptions) => void; + scrollToTop: (props?: ScrollToOptions) => void; + scrollToBottom: (props?: ScrollToOptions) => void; + isScrolledToTop: () => boolean; + isScrolledToBottom: () => boolean; + getDistanceFromTop: () => number; + getDistanceFromBottom: () => number; +} + export interface ScrollerBaseProps { className?: string; style?: CSSProperties; @@ -418,6 +436,7 @@ export interface ScrollerBaseProps { paddingFix?: boolean; onClose?(): void; onScroll?(): void; + ref: Ref; } export type ScrollerThin = ComponentType React.ReactNode; wrapSection?: (section: number, children: React.ReactNode) => React.ReactNode; - sectionHeight: number; - rowHeight: number; - footerHeight?: number; + sectionHeight: number | ((section: number) => number); + rowHeight: number | ((section: number, row: number) => number); + footerHeight?: number | ((section: number) => number); sidebarHeight?: number; chunkSize?: number; diff --git a/packages/discord-types/src/stores/PendingReplyStore.d.ts b/packages/discord-types/src/stores/PendingReplyStore.d.ts new file mode 100644 index 000000000..1819277cb --- /dev/null +++ b/packages/discord-types/src/stores/PendingReplyStore.d.ts @@ -0,0 +1,26 @@ +import { Channel, FluxStore, Message } from ".."; + +/** Full pending reply state object */ +interface PendingReply { + /** The channel in which the pending reply was created */ + channel: Channel; + /** The message that you're replying to */ + message: Message; + /** Whether to ping the person you're replying to */ + shouldMention: boolean; + /** Whether to show the ping \@ON/\@OFF toggle. Disabled by default for self-replies. */ + showMentionToggle: boolean; +} + +/** Shallow state object which is persisted between restarts. Based on {@link PendingReply}. */ +interface PendingReplyState { + channelId: string; + messageId: string; + shouldMention: boolean; + showMentionToggle: boolean; +} + +export class PendingReplyStore extends FluxStore { + getState(): Record; + getPendingReply(channelId: string): PendingReply | undefined; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 019efb38e..5c4b2472a 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -25,6 +25,7 @@ export * from "./MediaEngineStore"; export * from "./MessageStore"; export * from "./NotificationSettingsStore"; export * from "./OverridePremiumTypeStore"; +export * from "./PendingReplyStore"; export * from "./PermissionStore"; export * from "./PopoutWindowStore"; export * from "./PresenceStore"; diff --git a/src/equicordplugins/favouriteAnything/components.tsx b/src/equicordplugins/favouriteAnything/components.tsx new file mode 100644 index 000000000..ac38c411e --- /dev/null +++ b/src/equicordplugins/favouriteAnything/components.tsx @@ -0,0 +1,294 @@ +/* + * 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 { Channel, Message, MessageAttachment, ScrollerBaseRef } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { findByCodeLazy, findByPropsLazy, 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 { AttachmentContext, EmbedContext, EmbedMosaicContext } from "."; +import { SignedUrlsStore } from "./stores"; +import { AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass } from "./types"; +import { cl, defs, hasPermission, ImageUtils, sendAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils"; + +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 DmChannel: Channel & { new(base?: Partial): Channel; } = findByPropsLazy("fromServer", "sortRecipients"); +const MessageClass: Message & { new(base?: Partial): Message; } = findByCodeLazy(";mentionChannels;mentionGames;"); + +const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer"); + +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(new DmChannel({ id: "0", type: ChannelType.GUILD_TEXT })); + + return function AttachmentPreview({ attachment }: AttachmentsComponentProps) { + const message = useMemo( + () => new MessageClass({ attachments: [attachment], channel_id: channel.id }), + [attachment] + ); + + 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 renderRow = (row: number) => { + const item = favs?.[row]; + if (!item) return null; + + return ( + + ); + }; + + useEffect(() => void listRef.current?.scrollToTop(), [query]); + + return ( +
+
+ ExpressionPickerStore.setSearchQuery(query)} + onClear={() => ExpressionPickerStore.setSearchQuery("")} + /> +
+ {count > 0 ? ( +
+ (favs?.[row] && rowHeights.get(favs[row].url)) ?? 100} + renderSection={() => null} + renderRow={({ row }) => renderRow(row)} + /> +
+ ) : ( +
+ {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 && ( +
+ +
+ ) + ); +} + +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, width = 600, height = 400, srcIsAnimated } = attachment; + + // 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/equicordplugins/favouriteAnything/index.tsx b/src/equicordplugins/favouriteAnything/index.tsx index 8780f309e..9797c9b42 100644 --- a/src/equicordplugins/favouriteAnything/index.tsx +++ b/src/equicordplugins/favouriteAnything/index.tsx @@ -5,133 +5,159 @@ */ import { Devs, EquicordDevs } from "@utils/constants"; +import { getIntlMessage } from "@utils/discord"; import definePlugin from "@utils/types"; import { Embed } from "@vencord/discord-types"; -import { findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack"; +import { proxyLazyWebpack } from "@webpack"; import { React } from "@webpack/common"; -import { Component, ReactNode } from "react"; +import { ComponentType, ReactNode } from "react"; -enum Format { - NONE = 0, - IMAGE = 1, - VIDEO = 2 -} +import { AttachmentAccessory, EmbedAccessory, FilePicker } from "./components"; +import { SignedUrlsStore } from "./stores"; +import managedStyle from "./style.css?managed"; +import { AttachmentItem, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types"; +import { getThumbnailUrl } from "./utils"; -interface FavoriteButtonProps { - width: number; - height: number; - // Media URL - src: string; - // Provider source URL - url: string; - format: Format; - className?: string; -} - -interface AccessoryProps - extends Pick { - proxyUrl?: string; - video?: boolean; -} - -interface EmbedComponent extends Component<{ embed: Embed; }> { - __render: () => ReactNode; -} - -const FavoriteButton = findComponentByCodeLazy("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}"); -const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer"); -const EmbedContext = proxyLazyWebpack(() => React.createContext(null)); +export const EmbedContext = proxyLazyWebpack(() => React.createContext(null)); +export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext(null)); +export const AttachmentContext = proxyLazyWebpack(() => React.createContext(null)); export default definePlugin({ name: "FavouriteAnything", - description: "Favourite any image", + description: "Favourite any image, video, or file attachment", authors: [Devs.nin0dev, EquicordDevs.davri], + managedStyle, patches: [ + // EMBEDS { - find: "static isAnimated", + find: "#{intl::SUPPRESS_ALL_EMBEDS}", replacement: [ - // .isAnimated is checked in almost every media overlay event listener, so it's easier to patch the source. { - match: /static isAnimated\((\i)\)\{/, - replace: - "static isAnimated($1,override){if(!override)return true;" + // 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()" }, - // Always render the custom accessory if the prop wasn't provided. This mostly affects video attachments. - // Url and proxyUrl are additionally set to the same value, since the original url property only stores the thumbnail. { - match: /(?<=this\.props\.renderAccessory\(\):)null/, - replace: - "$self.Accessory({...this.props,url:this.props.proxyUrl,video:true})" - }, - // Always return static thumbnails for non gif media to prevent graphical glitches (side effect of the first patch). - { - match: /getSrc\(\i\)\{.*?let \i=/, - replace: "$&!this.constructor.isAnimated(this.props,true)||" - }, - // Hide the default "GIF" tag accessory that is visible when discord is unfocused. - { - match: "return this.props.shouldRenderAccessory?", - replace: "$&!this.constructor.isAnimated(this.props,true)||" + // 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))" } ] }, - // Wrap the embed component with a custom context provider to avoid having to drill props. { - find: "#{intl::SUPPRESS_ALL_EMBEDS}", + // Override the default renderAdjacentContent prop value for all types of embed components (renderImageComponent, renderVideoComponent...) + find: "#{intl::MEDIA_MOSAIC_ALT_TEXT_POPOUT_TITLE}", replacement: { - match: "render()", - replace: "$&{return $self.renderEmbed.call(this)}__render()" + match: /renderAdjacentContent:(\i)/g, + replace: "$&=$self.renderEmbedAccessory" } }, - // Replace the default gif accessory with a custom one that skips fileType checks. Mostly affects image attachments. + // ATTACHMENTS { - find: "renderComponentAccessories", + 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()];" + } + ] + }, + // 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: /\i=>\(\)=>\{.{200,300}?null\}/, - replace: "props=>()=>$self.Accessory({...props,video:false})" + match: '.sortBy("order").reverse()', + replace: "$&.filter($self.filterGifs)" } }, - // Add a proxyUrl prop alongside the src prop, which is used for video thumbnails. + // FAVOURITE BUTTON { - find: /disableArrowKeySeek:\i\}\)\}/, + find: "#{intl::GIF_TOOLTIP_REMOVE_FROM_FAVORITES}", replacement: { - match: /src:\i(?=,\.\.\.\i)/, - replace: "$&,proxyUrl:this.props.src" + // 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)" } } ], - renderEmbed(this: EmbedComponent) { + renderTabs(Tab: ComponentType, activeView: ExpressionPickerView) { return ( - - {this.__render()} - + <> + + Media + + + {getIntlMessage("FILES")} + + ); }, - Accessory(props: AccessoryProps) { - const embed = React.useContext(EmbedContext); - const content = embed?.image ?? embed?.video; + renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) { + return activeView === ExpressionPickerView.FILES ? : null; + }, + renderAttachment(children: ReactNode, props: { item: AttachmentItem; }) { + 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; - const { url, proxyUrl, width, height, video } = - embed && content - ? { - ...content, - url: (embed.type === "gifv" && embed.url) || content.url, - proxyUrl: content.proxyURL, - video: !!embed.video - } - : props; + SignedUrlsStore.addSigned(item.url); - if (!width || !height || !url) return null; + if (URL.canParse(item.src)) { + SignedUrlsStore.addSigned(item.src); + return item; + } - return ( - - ); + 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/equicordplugins/favouriteAnything/native.ts b/src/equicordplugins/favouriteAnything/native.ts new file mode 100644 index 000000000..63bf6c715 --- /dev/null +++ b/src/equicordplugins/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/equicordplugins/favouriteAnything/polyfills.ts b/src/equicordplugins/favouriteAnything/polyfills.ts new file mode 100644 index 000000000..fc6950a20 --- /dev/null +++ b/src/equicordplugins/favouriteAnything/polyfills.ts @@ -0,0 +1,176 @@ +/* + * 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 + +interface Uint8ArrayExtended extends Uint8Array { + toBase64(options?: { alphabet?: "base64" | "base64url"; omitPadding?: boolean; }): string; +} + +interface Uint8ArrayConstructorExtended extends Uint8ArrayConstructor { + fromBase64( + base64: string, + options?: { + alphabet?: "base64" | "base64url"; + lastChunkHandling?: "loose" | "strict" | "stop-before-partial"; + } + ): Uint8ArrayExtended; +} + +function supportsToBase64(array: Uint8Array): array is Uint8ArrayExtended { + return "toBase64" in array && typeof array.toBase64 === "function"; +} + +function supportsFromBase64(ctor: Uint8ArrayConstructor): ctor is Uint8ArrayConstructorExtended { + 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/equicordplugins/favouriteAnything/stores.ts b/src/equicordplugins/favouriteAnything/stores.ts new file mode 100644 index 000000000..53b59f45a --- /dev/null +++ b/src/equicordplugins/favouriteAnything/stores.ts @@ -0,0 +1,98 @@ +/* + * 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(() => { + interface Store { + get(url: string): string | null; + addSigned(url: string): void; + } + + class SignedUrlsStore extends Flux.Store implements Store { + private static readonly _expirationThreshold = 60 * 60 * 1000; + + private _urls = new Map(); + private _queue = new BatchedRequestQueue(batch => this._handleBatch(batch), { + maxCount: 50, + timeout: 50 + }); + + __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 - SignedUrlsStore._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 SignedUrlsStore(FluxDispatcher) as Store; +}); diff --git a/src/equicordplugins/favouriteAnything/style.css b/src/equicordplugins/favouriteAnything/style.css new file mode 100644 index 000000000..a0acd6019 --- /dev/null +++ b/src/equicordplugins/favouriteAnything/style.css @@ -0,0 +1,112 @@ +.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-start: 6px; + inset-inline-end: unset; + position: absolute; + top: 6px; + z-index: 3; + cursor: pointer; + + :has(> &) { + position: relative; + } + + :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/equicordplugins/favouriteAnything/types.ts b/src/equicordplugins/favouriteAnything/types.ts new file mode 100644 index 000000000..583ac409c --- /dev/null +++ b/src/equicordplugins/favouriteAnything/types.ts @@ -0,0 +1,140 @@ +/* + * 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 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: MessageAttachment; +} + +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; +} diff --git a/src/equicordplugins/favouriteAnything/utils.ts b/src/equicordplugins/favouriteAnything/utils.ts new file mode 100644 index 000000000..b90ea9653 --- /dev/null +++ b/src/equicordplugins/favouriteAnything/utils.ts @@ -0,0 +1,312 @@ +/* + * 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 { 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"); + +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(new TextEncoder().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(new TextDecoder().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 = "" }: MessageAttachment) => [ + id, + filename, + size, + new URL(url).pathname, + content_type + ], + decode: ([id, filename, size, path, content_type]) => ({ + 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 + }), + stringify: ({ filename }) => 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 file = await fetchAttachment(attachment) + .catch(() => + Toasts.show({ + message: `Couldn't fetch ${attachment.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 ${attachment.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 }); + 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; +} + +function filterItems(items: Record | null, itemFormat: CustomItemFormat, query?: string) { + if (!items) return null; + + const validItems = Object.entries(items) + .filter(([, { format }]) => format === FavouriteItemFormat.NONE) + .map(([url, { src, ...rest }]) => ({ + ...rest, + ...defs.decode(URL.parse(src)?.hash.replace("#", "") ?? "")!, + url + })) + .filter(({ format, data }) => data && format === itemFormat); + + if (!query) return validItems.sort((a, b) => b.order - a.order); + + return validItems + .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); +} + +export function useFavourites(itemFormat: CustomItemFormat, searchQuery?: string) { + useEffect(() => void UserSettingsActionCreators.FrecencyUserSettingsActionCreators.loadIfNecessary(), []); + + const { state } = useStateFromStores( + [UserSettingsProtoStore], + () => { + const query = searchQuery && normalize(searchQuery); + const items: Record | null = + UserSettingsProtoStore.frecencyWithoutFetchingLatest.favoriteGifs?.gifs; + + return { query, state: filterItems(items, itemFormat, query) }; + }, + [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/equicordplugins/remix/index.tsx b/src/equicordplugins/remix/index.tsx index e44f673fa..19595a5ef 100644 --- a/src/equicordplugins/remix/index.tsx +++ b/src/equicordplugins/remix/index.tsx @@ -10,8 +10,8 @@ import { PaintbrushIcon } from "@components/Icons"; import { EquicordDevs } from "@utils/constants"; import { closeModal, openModal } from "@utils/modal"; import definePlugin from "@utils/types"; -import { extractAndLoadChunksLazy, findStoreLazy } from "@webpack"; -import { ChannelStore, DraftType, FluxDispatcher, Menu, SelectedChannelStore, UploadHandler } from "@webpack/common"; +import { extractAndLoadChunksLazy } from "@webpack"; +import { ChannelStore, DraftType, FluxDispatcher, Menu, PendingReplyStore, SelectedChannelStore, UploadHandler } from "@webpack/common"; import RemixModal from "./RemixModal"; import css from "./styles.css?managed"; @@ -19,7 +19,6 @@ import css from "./styles.css?managed"; const requireCreateStickerModal = extractAndLoadChunksLazy([".CREATE_STICKER_MODAL,", "isDisplayingIndividualStickers"]); const requireSettingsMenu = extractAndLoadChunksLazy(['type:"USER_SETTINGS_MODAL_OPEN"']); -const PendingReplyStore = findStoreLazy("PendingReplyStore"); const validMediaTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; const UploadContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { diff --git a/src/plugins/spotifyShareCommands/index.ts b/src/plugins/spotifyShareCommands/index.ts index d00d4a2ca..b4a85eb16 100644 --- a/src/plugins/spotifyShareCommands/index.ts +++ b/src/plugins/spotifyShareCommands/index.ts @@ -22,7 +22,7 @@ import { sendMessage } from "@utils/discord"; import definePlugin from "@utils/types"; import { Command } from "@vencord/discord-types"; import { findByPropsLazy } from "@webpack"; -import { FluxDispatcher, MessageActions } from "@webpack/common"; +import { FluxDispatcher, MessageActions, PendingReplyStore } from "@webpack/common"; interface Album { id: string; @@ -55,7 +55,6 @@ interface Track { } const Spotify = findByPropsLazy("getPlayerState"); -const PendingReplyStore = findByPropsLazy("getPendingReply"); function makeCommand(name: string, formatUrl: (track: Track) => string): Command { return { diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts index 9ef29c889..175ab75de 100644 --- a/src/webpack/common/stores.ts +++ b/src/webpack/common/stores.ts @@ -82,7 +82,7 @@ export let PopoutWindowStore: t.PopoutWindowStore; export let ApplicationCommandIndexStore: t.ApplicationCommandIndexStore; export let EditMessageStore: t.EditMessageStore; export let QuestStore: t.QuestStore; -export let PendingReplyStore: GenericStore; +export let PendingReplyStore: t.PendingReplyStore; export let ExperimentStore: GenericStore; /** diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index 914ffbe0f..a23668bc6 100644 --- a/src/webpack/common/utils.ts +++ b/src/webpack/common/utils.ts @@ -142,7 +142,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 === [", {