FavouriteAnything: Prettier thumbnails and code cleanup (#1317)
* Perf improvements * Remove :has rule * Undo getThumbnailUrl changes, add newline * FavouriteAnything update
This commit is contained in:
parent
fbb68a9638
commit
139d1ad472
9 changed files with 178 additions and 316 deletions
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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")} />;
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,8 +88,6 @@
|
|||
|
||||
.vc-favouriteAnything-info-text {
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.vc-favouriteAnything-demo {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue