FavouriteAnything: Prettier thumbnails and code cleanup (#1317)

* Perf improvements

* Remove :has rule

* Undo getThumbnailUrl changes, add newline

* FavouriteAnything update
This commit is contained in:
Davri 2026-08-20 18:18:12 +02:00 • committed by GitHub
parent fbb68a9638
commit 139d1ad472
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 178 additions and 316 deletions

View file

@ -93,7 +93,7 @@ export default definePlugin({
description: "Message forward utilities including NSFW bypass and UI improvements.",
tags: ["Chat", "Utility"],
searchTerms: ["selfForward", "betterForwardMeta"],
authors: [Devs.thororen, Devs.sadan, Devs.nin0dev, EquicordDevs.VillainsRule, EquicordDevs.davri],
authors: [Devs.thororen, Devs.sadan, Devs.nin0dev, EquicordDevs.VillainsRule, Devs.Davri],
settings,
managedStyle,
patches: [

View file

@ -11,11 +11,11 @@ import { Embed, ListRow, Message, MessageAttachment, ScrollerBaseRef } from "@ve
import { ChannelType } from "@vencord/discord-types/enums";
import { findByCodeLazy, findComponentByCode, findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack";
import { ChannelStore, ExpressionPickerStore, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common";
import { ReactNode } from "react";
import { ComponentProps, ReactNode } from "react";
import { SignedUrlsStore } from "./stores";
import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass } from "./types";
import { cl, defs, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils";
import { AttachmentContextProviderProps, AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass, StaticFilePickerItemProps } from "./types";
import { cl, getFilenameAndExtension, getFileThumbnailUrl, hasPermission, ImageUtils, sendAttachment, transformAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils";
export const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext<null | number>(null));
@ -23,7 +23,6 @@ const AttachmentContext = proxyLazyWebpack(() => React.createContext<null | Atta
const ManaSearchBar = findComponentByCodeLazy<ManaSearchBarProps>("#{intl::SEARCH}),ref");
const FavoriteButton = findComponentByCodeLazy<FavoriteButtonProps>("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}");
const SendIcon = findComponentByCodeLazy("M6.6 10.02 14 11.4a.6.6");
const createChannelRecordFromServer = findByCodeLazy(".GUILD_TEXT]", "fromServer)");
const createMessageRecord = findByCodeLazy(".createFromServer(", ".isBlockedForMessage", "messageReference:");
@ -73,8 +72,6 @@ export const AttachmentPreview = proxyLazyWebpack(() => {
};
});
const noopRender = () => null;
export function FilePicker({ onSelectItem }: FilePickerProps) {
const listRef = useRef<ScrollerBaseRef>(null);
@ -121,24 +118,11 @@ export function FilePicker({ onSelectItem }: FilePickerProps) {
return (
<div id="files-picker-tab-panel" role="tabpanel" aria-labelledby="files-picker-tab" className={cl("container")}>
<div className={cl("container-header")}>
<ManaSearchBar
autoFocus
placeholder="Search files"
query={query}
onChange={handleChange}
onClear={handleClear}
/>
<ManaSearchBar autoFocus placeholder="Search files" query={query} onChange={handleChange} onClear={handleClear} />
</div>
{count > 0 ? (
<div className={cl("container-body")}>
<ListScrollerThin
ref={listRef}
sections={[count]}
sectionHeight={0}
rowHeight={rowHeight}
renderSection={noopRender}
renderRow={renderRow}
/>
<ListScrollerThin ref={listRef} sections={[count]} sectionHeight={0} rowHeight={rowHeight} renderRow={renderRow} />
</div>
) : (
<div className={cl("container-body", "container-info")} inert>
@ -177,7 +161,7 @@ function Demo() {
format={FavouriteItemFormat.NONE}
/>
</div>
<BaseText className={cl("info-text")}>
<BaseText className={cl("info-text")} size="md">
Click the star to favourite a file.
<br />
Favourite files will show up here!
@ -186,6 +170,33 @@ function Demo() {
);
}
function SendIcon({ height = 24, width = 24, ...props }: ComponentProps<"svg">) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} fill="currentColor" viewBox="0 0 24 24" {...props}>
<path d="M6.6 10.02 14 11.4a.6.6 0 0 1 0 1.18L6.6 14l-2.94 5.87a1.48 1.48 0 0 0 1.99 1.98l17.03-8.52a1.48 1.48 0 0 0 0-2.64L5.65 2.16a1.48 1.48 0 0 0-1.99 1.98l2.94 5.88Z" />
</svg>
);
}
export function StaticFilePickerItem({ name, subtitle }: StaticFilePickerItemProps) {
const [, ext] = getFilenameAndExtension(name);
// Keep this compact! Long prop names, styles, numbers, etc could be wasteful
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 32 11" style={{ font: "2px sans-serif" }} fill="#242429">
<path d="M-2-2h32v11H-2" />
<rect width="5" height="7" rx=".5" fill="#ddf" />
<g fill="#78e">
<path d="M3-1v2.5q0 .5.5.5H6" />
{ext && <text x=".85" y="6" fontFamily="monospace">{ext.slice(1, 4)}</text>}
</g>
<path d="M6 3V-2H1" />
<text x="7" y="2.8" fill="#eff">{name}</text>
<text x="7" y="5.6" fill="#777">{subtitle}</text>
</svg>
);
}
export function FilePickerItem({ url, file, channel, onResize, onSubmit, reducePadding }: FilePickerItemProps) {
const [isFetching, setIsFetching] = useState(false);
@ -229,7 +240,7 @@ export function FilePickerItem({ url, file, channel, onResize, onSubmit, reduceP
<AttachmentPreview attachment={attachment} />
{handleClick && (
<Button onClick={handleClick} variant="secondary" disabled={isFetching}>
<SendIcon size="refresh_sm" color="currentColor" />
<SendIcon width={20} height={20} />
</Button>
)}
</div>
@ -304,14 +315,14 @@ export function AttachmentContextProvider({ attachment, component, children }: A
const raw: MessageAttachment =
"media" in originalItem
? {
...originalItem.media,
id: rest.uniqueId,
size: 0,
spoiler: rest.spoiler,
filename: (rest.spoiler ? "SPOILER_" : "") + rest.uniqueId,
content_type: originalItem.media.contentType,
proxy_url: originalItem.media.proxyUrl
}
...originalItem.media,
id: rest.uniqueId,
size: 0,
spoiler: rest.spoiler,
filename: (rest.spoiler ? "SPOILER_" : "") + rest.uniqueId,
content_type: originalItem.media.contentType,
proxy_url: originalItem.media.proxyUrl
}
: originalItem;
return { originalItem: raw, ...rest };
@ -335,28 +346,21 @@ export function AttachmentAccessory() {
const props: FavoriteButtonProps | null = useMemo(() => {
if (!attachment?.downloadUrl) return null;
const { originalItem, type, downloadUrl, srcIsAnimated } = attachment;
const width = attachment.width || 600, height = attachment.height || 400;
const width = attachment.width || 160, height = attachment.height || 55;
// Do not render the custom accessory if the original attachment component already has a gif accessory
const isAnimated = ImageUtils.isAnimated({
original: originalItem.url,
src: originalItem.proxy_url,
animated: false,
srcIsAnimated
});
const isAnimated = ImageUtils.isAnimated({ original: originalItem.url, src: originalItem.proxy_url, animated: false, srcIsAnimated });
if (isAnimated) return null;
if (type in visualMediaFormats) {
return { format: visualMediaFormats[type]!, src: originalItem.proxy_url, url: downloadUrl, width, height };
}
// 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 };
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 && <FavoriteButton {...props} className={cl("attachment-accessory")} />;

View file

@ -4,24 +4,33 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs, EquicordDevs } from "@utils/constants";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import definePlugin from "@utils/types";
import definePlugin, { OptionType } from "@utils/types";
import { ComponentType, ReactNode } from "react";
import { AttachmentAccessory, AttachmentContextProvider, EmbedAccessory, EmbedContext, EmbedMosaicContext, FilePicker } from "./components";
import { SignedUrlsStore } from "./stores";
import managedStyle from "./style.css?managed";
import { AttachmentContextProviderProps, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types";
import { getThumbnailUrl } from "./utils";
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"],
authors: [Devs.nin0dev, EquicordDevs.davri],
tags: ["Chat", "Media", "Utility"],
authors: [Devs.Davri, Devs.nin0dev],
searchTerms: ["favorite"],
managedStyle,
settings,
patches: [
// EMBEDS
{
@ -30,7 +39,7 @@ export default definePlugin({
{
// Wrap the embed component's render method in a custom context to avoid having to drill props
match: "render()",
replace: "$&{return $self.renderEmbed.call(this)}__render()"
replace: "$&{return $self.renderEmbed(this)}__render()"
},
{
// Specify the index for individual items in embed.images
@ -58,13 +67,14 @@ export default definePlugin({
},
{
// Always add our custom accessory to the attachment's adjacent content
match: "=[];",
replace: "=[$self.renderAttachmentAccessory()];"
match: /let \i=Math.max\(0,(\i)\.length-\i\)/,
replace: "$1.unshift($self.renderAttachmentAccessory());$&"
}
]
},
// 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)\))/,
@ -95,13 +105,13 @@ export default definePlugin({
replace: "$&.filter($self.filterGifs)"
}
},
// FAVOURITE BUTTON
// PROTOBUF
{
find: "#{intl::GIF_TOOLTIP_REMOVE_FROM_FAVORITES}",
find: "#{intl::FAVORITE_GIFS_LIMIT_REACHED_BODY}",
replacement: {
// Intercept the onClick callback to replace the placeholder thumbnail with a valid CDN link
match: /\(0,(\i\.\i)\)\((\{[^}].{40,60}?\})\)/,
replace: "$self.interceptAddToFavourites($2).then($1)"
// Intercept add/remove actions to generate a valid thumbnail url before storing the item
match: /function (\i)\((\i)\)\{(?=\i\.\i\.updateAsync\("favoriteGifs")/g,
replace: "async function $1($2){await $self.fixFavItem($2);await "
}
}
],
@ -134,14 +144,14 @@ export default definePlugin({
renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) {
return activeView === ExpressionPickerView.FILES ? <FilePicker onSelectItem={onSelectGIF} /> : null;
},
renderAttachment(children: ReactNode, { item }: { item: AttachmentContextProviderProps["attachment"] }) {
renderAttachment(children: ReactNode, { item }: { item: AttachmentContextProviderProps["attachment"]; }) {
return <AttachmentContextProvider attachment={item}>{children}</AttachmentContextProvider>;
},
renderCV2File(children: ReactNode, key: React.Key, component: AttachmentContextProviderProps["component"]) {
return <AttachmentContextProvider component={component} key={key}>{children}</AttachmentContextProvider>;
},
renderEmbed(this: EmbedComponent) {
return <EmbedContext.Provider value={this.props.embed}>{this.__render()}</EmbedContext.Provider>;
renderEmbed(comp: EmbedComponent) {
return <EmbedContext.Provider value={comp.props.embed}>{comp.__render()}</EmbedContext.Provider>;
},
renderEmbedMosaicItem(children: ReactNode, index: number) {
return <EmbedMosaicContext.Provider value={index}>{children}</EmbedMosaicContext.Provider>;
@ -149,21 +159,17 @@ export default definePlugin({
renderAttachmentAccessory: () => <AttachmentAccessory />,
renderEmbedAccessory: () => <EmbedAccessory />,
filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE,
interceptAddToFavourites: async (item: FavouriteItem & { url: string; }) => {
if (item.format !== FavouriteItemFormat.NONE) return item;
SignedUrlsStore.addSigned(item.url);
if (URL.canParse(item.src)) {
fixFavItem: async (item: FullFavouriteItem | string) => {
if (typeof item === "string") {
SignedUrlsStore.addSigned(item);
} else {
SignedUrlsStore.addSigned(item.url);
SignedUrlsStore.addSigned(item.src);
return item;
if (typeof item.gifSrc === "function") {
item.src = await item.gifSrc();
delete item.gifSrc;
}
}
const thumbnail = await getThumbnailUrl(item.src, item.width, item.height);
if (!thumbnail) return item;
thumbnail.search = "";
thumbnail.hash = item.src;
return { ...item, src: `${thumbnail}` };
}
});

View file

@ -1,176 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/*
* This file contains modified code from:
* https://github.com/tc39/proposal-arraybuffer-base64
* * Copyright (c) 2017 ECMA TC39 and contributors
* *
* * Permission is hereby granted, free of charge, to any person obtaining a copy
* * of this software and associated documentation files (the "Software"), to deal
* * in the Software without restriction, including without limitation the rights
* * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* * copies of the Software, and to permit persons to whom the Software is
* * furnished to do so, subject to the following conditions:
* *
* * The above copyright notice and this permission notice shall be included in all
* * copies or substantial portions of the Software.
* *
* * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* * SOFTWARE.
*/
// TODO: Remove once discord stops being insane and updates electron
interface Uint8ArrayExtended<T extends ArrayBufferLike = ArrayBufferLike> extends Uint8Array<T> {
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<ArrayBuffer>;
}
function supportsToBase64(array: Uint8Array<ArrayBufferLike>): boolean {
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);
}

View file

@ -10,22 +10,19 @@ import { Constants, Flux, FluxDispatcher, RestAPI } from "@webpack/common";
import { RefreshedUrlsResponse } from "./types";
import { BatchedRequestQueue, isAllowedHost } from "./utils";
export interface SignedUrlsStoreType {
get(url: string): string | null;
addSigned(url: string): void;
}
/** 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 implements SignedUrlsStoreType {
public static readonly _expirationThreshold = 60 * 60 * 1000;
class SignedUrlsStoreClass extends Flux.Store {
public static readonly displayName = "SignedUrlsStore";
private static readonly _expirationThreshold = 60 * 60 * 1000;
public _urls = new Map<string, string>();
public _queue = new BatchedRequestQueue<string>(batch => this._handleBatch(batch), {
private _urls = new Map<string, string>();
private _queue = new BatchedRequestQueue<string>(batch => this._handleBatch(batch), {
maxCount: 50,
timeout: 50
});
// Makes debugging easier with discord devtools
__getLocalVars() {
return { urls: this._urls, queue: this._queue };
}
@ -50,27 +47,27 @@ export const SignedUrlsStore = proxyLazyWebpack(() => {
else this._update([[`${this._clean(parsed)}`, url]]);
}
public _refresh(url: URL): void {
private _refresh(url: URL): void {
this._queue.add(`${this._clean(url)}`);
}
public _clean(url: URL): URL {
private _clean(url: URL): URL {
const clean = new URL(url);
clean.search = "";
clean.hash = "";
return clean;
}
public _isValid(url: URL | null): url is URL {
private _isValid(url: URL | null): url is URL {
return !!(url && isAllowedHost(url.hostname));
}
public _willExpire(url: URL): boolean {
private _willExpire(url: URL): boolean {
const expiryTimestamp = parseInt(url.searchParams.get("ex")!, 16) * 1000;
return isNaN(expiryTimestamp) || expiryTimestamp - SignedUrlsStoreClass._expirationThreshold < Date.now();
}
public _update(urls: [string, string][]): void {
private _update(urls: [string, string][]): void {
let hasChanged: boolean = false;
for (const [url, value] of urls) {
@ -83,16 +80,13 @@ export const SignedUrlsStore = proxyLazyWebpack(() => {
if (hasChanged) this.emitChange();
}
public async _handleBatch(batch: string[]): Promise<void> {
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!]))
);
private async _handleBatch(batch: string[]): Promise<void> {
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) as SignedUrlsStoreType;
return new SignedUrlsStoreClass(FluxDispatcher);
});

View file

@ -88,8 +88,6 @@
.vc-favouriteAnything-info-text {
text-align: center;
line-height: 1.6;
font-size: 1.1rem;
}
.vc-favouriteAnything-demo {

View file

@ -24,9 +24,7 @@ export interface ExpressionPickerTabProps extends PropsWithChildren {
viewType: ExpressionPickerView;
}
export interface FavoriteButtonProps extends Omit<FavouriteItem, "order"> {
url: string;
gifSrc?: string;
export interface FavoriteButtonProps extends Omit<FullFavouriteItem, "order"> {
className?: string;
}
@ -59,9 +57,14 @@ export interface FilePickerProps {
onSelectItem: (item: { url: string; }) => void;
}
export interface StaticFilePickerItemProps {
name: string;
subtitle: string;
}
export interface FilePickerItemProps {
url: string;
file: MessageAttachment;
url: string;
channel: Channel | null;
reducePadding?: boolean;
onResize: (key: Key, height: number) => void;
@ -117,6 +120,11 @@ export interface FavouriteItem {
order: number;
}
export interface FullFavouriteItem extends FavouriteItem {
gifSrc?: () => Promise<string>;
url: string;
}
export enum CustomItemFormat {
ATTACHMENT = 0
}
@ -131,6 +139,10 @@ export type ItemsDef<T> = T & {
[K in keyof T]: T[K] extends CustomItemDef<infer A, infer B> ? CustomItemDef<A, B> : never;
};
export interface UnfurledEmbedsResponse {
embeds: EmbedJSON[];
}
export interface RefreshedUrlsResponse {
refreshed_urls: [
{
@ -140,10 +152,6 @@ export interface RefreshedUrlsResponse {
];
}
export interface UnfurledEmbedsResponse {
embeds: EmbedJSON[];
}
export type ResizeObserverHook = (
ref: RefObject<Element | null>,
callback: (size: { width: number; height: number; }) => void,

View file

@ -12,12 +12,13 @@ import { useForceUpdater } from "@utils/react";
import { PluginNative } from "@utils/types";
import { Channel, MessageAttachment } from "@vencord/discord-types";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { Constants, DraftType, FluxDispatcher, MessageActions, PendingReplyStore, PermissionStore, RestAPI, Toasts, UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common";
import { 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 } from "react";
import { Key, ReactNode } from "react";
import { JsonValue } from "type-fest";
import { base64ToUint8Array, uint8ArrayToBase64 } from "./polyfills";
import { settings } from ".";
import { StaticFilePickerItem } from "./components";
import { AttachmentTransformer, CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types";
const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative<typeof import("./native")>;
@ -40,7 +41,7 @@ function defineItems<T extends Record<CustomItemFormat, CustomItemDef>>(def: Ite
const obj = [format, def[format].encode(data)];
const buf = deflateSync(encoder.encode(JSON.stringify(obj)));
return uint8ArrayToBase64(buf);
return buf.toBase64({ alphabet: "base64url", omitPadding: true });
} catch {
return null;
}
@ -49,7 +50,7 @@ function defineItems<T extends Record<CustomItemFormat, CustomItemDef>>(def: Ite
try {
if (!raw) return null;
const buf = inflateSync(base64ToUint8Array(raw));
const buf = inflateSync(Uint8Array.fromBase64(raw, { alphabet: "base64url" }));
const parsed: unknown[] | null = JSON.parse(decoder.decode(buf));
if (!Array.isArray(parsed)) return null;
@ -98,28 +99,58 @@ export const defs = defineItems({
// 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 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];
}
export async function getThumbnailUrl(data: string, width: number, height: number): Promise<URL | null> {
try {
const decoded = defs.decode(data);
if (!decoded || !width || !height) return null;
function renderToHTML(node: ReactNode): Promise<string> {
return new Promise(resolve => {
const container = document.createElement("div");
const root = createRoot(container);
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;
queueMicrotask(() => {
ReactDOM.flushSync(() => root.render(node));
resolve(container.innerHTML);
root.unmount();
});
});
}
export const FALLBACK_THUMBNAIL = new URL("https://images-ext-1.discordapp.net/external/085KIKMVni8n60G3GHE1rGcA0xgH6OgBKIZqUiQYsXc/%3Fname%3DUnknown%2520file%26subtitle%3D0%2520MB/https/placeholder.nin0.dev/image");
async function getThumbnailBase(item: MessageAttachment): Promise<URL | null> {
const [filename, ext] = getFilenameAndExtension(item.filename);
const name = Humanize.truncatechars(item.title ? item.title : filename, 50) + (ext?.slice(0, 6) ?? "");
const subtitle = Humanize.filesize(item.size);
if (settings.store.localThumbnails) {
const html = await renderToHTML(<StaticFilePickerItem name={ name } subtitle = { subtitle } />);
// encodeURIComponent is intentionally avoided since it would bloat the url size by replacing otherwise safe characters
return URL.parse("data:image/svg+xml," + html.replaceAll("%", "%25").replaceAll("#", "%23"));
} else {
const url = new URL("https://placeholder.nin0.dev/image");
url.searchParams.append("name", name || " ");
url.searchParams.append("subtitle", subtitle);
return await RestAPI.post({ url: Constants.Endpoints.UNFURL_EMBED_URLS, body: { urls: [url] }, retries: 3 })
.then(({ body }: { body: UnfurledEmbedsResponse; }) => {
const [{ thumbnail } = {}] = body.embeds;
return thumbnail?.proxy_url ? URL.parse(thumbnail.proxy_url) : null;
});
}
}
export async function getFileThumbnailUrl(item: MessageAttachment): Promise<URL> {
try {
const base = await getThumbnailBase(item);
const metadata = defs.encode(CustomItemFormat.ATTACHMENT, item)?.toString();
if (!base || !metadata) return FALLBACK_THUMBNAIL;
base.hash = metadata;
return base;
} catch {
return fallbackThumbnail;
return FALLBACK_THUMBNAIL;
}
}
@ -175,8 +206,9 @@ export async function sendAttachment(attachment: MessageAttachment, channel: Cha
const [upload] = uploads.splice(uploadIdx);
UploadManager.setUploads({ uploads, channelId: channel.id, draftType: DraftType.ChannelMessage });
// Empty titles and descriptions are allowed
if (title != null) upload.filename = title;
if (title != null) upload.filename = title + (getFilenameAndExtension(upload.filename)[1] ?? "");
if (description != null) upload.description = description;
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id });

View file

@ -675,8 +675,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({
id: 383365021415243776n
},
paige: {
name: "paige",
id: 1375697625864601650n
name: "paige",
id: 1375697625864601650n
},
jax: {
name: "jax",
@ -1302,10 +1302,6 @@ export const EquicordDevs = Object.freeze({
name: "korzi",
id: 740966310875365416n
},
davri: {
name: "Davri",
id: 457579346282938368n
},
yash: {
name: "yash",
id: 889150838658977874n