Rewrite favouriteAnything (#830)
* Add PendingReplyStore types * Update uploadHandler and embedMedia types * Update scroller types * Update favouriteAnything * Fix find * Revert the devs array * Move prop types to types.ts, separate AttachmentPreview lookups * Update copyright * Inline imports * Fix crash * Minor changes * Replace useResizeObserver * Add hostname checks to fetchAttachment * Use managed style instead of direct import * Update description * Move ImageUtils to top * change find --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
4a406457a7
commit
8f2cc6d52a
16 changed files with 1336 additions and 103 deletions
|
|
@ -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;
|
||||
|
|
|
|||
25
packages/discord-types/src/components.d.ts
vendored
25
packages/discord-types/src/components.d.ts
vendored
|
|
@ -411,6 +411,24 @@ export type MaskedLink = ComponentType<PropsWithChildren<{
|
|||
channelId?: string;
|
||||
}>>;
|
||||
|
||||
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<ScrollerBaseRef>;
|
||||
}
|
||||
|
||||
export type ScrollerThin = ComponentType<PropsWithChildren<ScrollerBaseProps & {
|
||||
|
|
@ -448,9 +467,9 @@ export type ListScrollerThin = ComponentType<ScrollerBaseProps & {
|
|||
renderSidebar?: (listVisible: boolean, sidebarVisible: boolean) => 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;
|
||||
|
|
|
|||
26
packages/discord-types/src/stores/PendingReplyStore.d.ts
vendored
Normal file
26
packages/discord-types/src/stores/PendingReplyStore.d.ts
vendored
Normal file
|
|
@ -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<string, PendingReplyState>;
|
||||
getPendingReply(channelId: string): PendingReply | undefined;
|
||||
}
|
||||
1
packages/discord-types/src/stores/index.d.ts
vendored
1
packages/discord-types/src/stores/index.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
294
src/equicordplugins/favouriteAnything/components.tsx
Normal file
294
src/equicordplugins/favouriteAnything/components.tsx
Normal file
|
|
@ -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<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 DmChannel: Channel & { new(base?: Partial<Channel>): Channel; } = findByPropsLazy("fromServer", "sortRecipients");
|
||||
const MessageClass: Message & { new(base?: Partial<Message>): 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<MessageComponentClass>;
|
||||
|
||||
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 (
|
||||
<MessageAttachmentsComponent
|
||||
channel={channel}
|
||||
message={message}
|
||||
canDeleteAttachments={false}
|
||||
shouldHideMediaOptions={false}
|
||||
inlineAttachmentMedia
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export function FilePicker({ onSelectItem }: FilePickerProps) {
|
||||
const listRef = useRef<ScrollerBaseRef>(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 (
|
||||
<FilePickerItem
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
file={item.data}
|
||||
channel={channel}
|
||||
reducePadding={row !== count - 1}
|
||||
onResize={handleResize}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => void listRef.current?.scrollToTop(), [query]);
|
||||
|
||||
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={query => ExpressionPickerStore.setSearchQuery(query)}
|
||||
onClear={() => ExpressionPickerStore.setSearchQuery("")}
|
||||
/>
|
||||
</div>
|
||||
{count > 0 ? (
|
||||
<div className={cl("container-body")}>
|
||||
<ListScrollerThin
|
||||
ref={listRef}
|
||||
sections={[count]}
|
||||
sectionHeight={0}
|
||||
rowHeight={(_, row) => (favs?.[row] && rowHeights.get(favs[row].url)) ?? 100}
|
||||
renderSection={() => null}
|
||||
renderRow={({ row }) => renderRow(row)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cl("container-body", "container-info")} inert>
|
||||
{query.trim() ? <EmptyList /> : <Demo />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyList() {
|
||||
return <BaseText className={cl("info-text")}>No files match your search.</BaseText>;
|
||||
}
|
||||
|
||||
const demoAttachment: MessageAttachment = {
|
||||
id: "1",
|
||||
filename: "file",
|
||||
content_type: "application/octet-stream",
|
||||
size: 123 * 1024,
|
||||
spoiler: false,
|
||||
url: "",
|
||||
proxy_url: ""
|
||||
};
|
||||
|
||||
function Demo() {
|
||||
return (
|
||||
<>
|
||||
<div className={cl("attachment-container", "demo", "first")}>
|
||||
<AttachmentPreview attachment={demoAttachment} />
|
||||
<FavoriteButton
|
||||
className={cl("demo-favourite-button")}
|
||||
url="https://example.org"
|
||||
src="https://example.org"
|
||||
width={100}
|
||||
height={100}
|
||||
format={FavouriteItemFormat.NONE}
|
||||
/>
|
||||
</div>
|
||||
<BaseText className={cl("info-text")}>
|
||||
Click the star to favourite a file.
|
||||
<br />
|
||||
Favourite files will show up here!
|
||||
</BaseText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilePickerItem({ url, file, channel, onResize, onSubmit, reducePadding }: FilePickerItemProps) {
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div ref={ref} className={cl("attachment-container", reducePadding && "reduced-padding")}>
|
||||
<AttachmentPreview attachment={attachment} />
|
||||
{handleClick && (
|
||||
<Button onClick={handleClick} variant="secondary" disabled={isFetching}>
|
||||
<SendIcon size="refresh_sm" color="currentColor" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className={cl("image-accessory")}>
|
||||
<FavoriteButton {...props} className={Classes.gifFavoriteButton} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const visualMediaFormats: Partial<Record<AttachmentItem["type"], FavouriteItemFormat>> = 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 && <FavoriteButton {...props} className={cl("attachment-accessory")} />;
|
||||
}
|
||||
|
|
@ -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<FavoriteButtonProps, "width" | "height" | "url"> {
|
||||
proxyUrl?: string;
|
||||
video?: boolean;
|
||||
}
|
||||
|
||||
interface EmbedComponent extends Component<{ embed: Embed; }> {
|
||||
__render: () => ReactNode;
|
||||
}
|
||||
|
||||
const FavoriteButton = findComponentByCodeLazy<FavoriteButtonProps>("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}");
|
||||
const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer");
|
||||
const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
|
||||
export const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
|
||||
export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext<null | number>(null));
|
||||
export const AttachmentContext = proxyLazyWebpack(() => React.createContext<null | AttachmentItem>(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<ExpressionPickerTabProps>, activeView: ExpressionPickerView) {
|
||||
return (
|
||||
<EmbedContext.Provider value={this.props.embed}>
|
||||
{this.__render()}
|
||||
</EmbedContext.Provider>
|
||||
<>
|
||||
<Tab
|
||||
id="gif-picker-tab"
|
||||
key="gif-picker-tab"
|
||||
aria-controls="gif-picker-tab-panel"
|
||||
aria-selected={activeView === ExpressionPickerView.GIF}
|
||||
isActive={activeView === ExpressionPickerView.GIF}
|
||||
viewType={ExpressionPickerView.GIF}
|
||||
>
|
||||
Media
|
||||
</Tab>
|
||||
<Tab
|
||||
id="files-picker-tab"
|
||||
key="files-picker-tab"
|
||||
aria-controls="files-picker-tab-panel"
|
||||
aria-selected={activeView === ExpressionPickerView.FILES}
|
||||
isActive={activeView === ExpressionPickerView.FILES}
|
||||
viewType={ExpressionPickerView.FILES}
|
||||
>
|
||||
{getIntlMessage("FILES")}
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
},
|
||||
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 ? <FilePicker onSelectItem={onSelectGIF} /> : null;
|
||||
},
|
||||
renderAttachment(children: ReactNode, props: { item: AttachmentItem; }) {
|
||||
return <AttachmentContext.Provider value={props.item}>{children}</AttachmentContext.Provider>;
|
||||
},
|
||||
renderEmbed(this: EmbedComponent) {
|
||||
return <EmbedContext.Provider value={this.props.embed}>{this.__render()}</EmbedContext.Provider>;
|
||||
},
|
||||
renderEmbedMosaicItem(children: ReactNode, index: number) {
|
||||
return <EmbedMosaicContext.Provider value={index}>{children}</EmbedMosaicContext.Provider>;
|
||||
},
|
||||
renderAttachmentAccessory: () => <AttachmentAccessory />,
|
||||
renderEmbedAccessory: () => <EmbedAccessory />,
|
||||
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 (
|
||||
<FavoriteButton
|
||||
format={video ? Format.VIDEO : Format.IMAGE}
|
||||
className={Classes?.gifFavoriteButton}
|
||||
src={proxyUrl ?? url}
|
||||
url={url}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
const thumbnail = await getThumbnailUrl(item.src, item.width, item.height);
|
||||
if (!thumbnail) return item;
|
||||
|
||||
thumbnail.search = "";
|
||||
thumbnail.hash = item.src;
|
||||
return { ...item, src: `${thumbnail}` };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
31
src/equicordplugins/favouriteAnything/native.ts
Normal file
31
src/equicordplugins/favouriteAnything/native.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
176
src/equicordplugins/favouriteAnything/polyfills.ts
Normal file
176
src/equicordplugins/favouriteAnything/polyfills.ts
Normal file
|
|
@ -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<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>): array is Uint8ArrayExtended<ArrayBufferLike> {
|
||||
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);
|
||||
}
|
||||
98
src/equicordplugins/favouriteAnything/stores.ts
Normal file
98
src/equicordplugins/favouriteAnything/stores.ts
Normal file
|
|
@ -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<string, string>();
|
||||
private _queue = new BatchedRequestQueue<string>(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<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 SignedUrlsStore(FluxDispatcher) as Store;
|
||||
});
|
||||
112
src/equicordplugins/favouriteAnything/style.css
Normal file
112
src/equicordplugins/favouriteAnything/style.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
140
src/equicordplugins/favouriteAnything/types.ts
Normal file
140
src/equicordplugins/favouriteAnything/types.ts
Normal file
|
|
@ -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<FavouriteItem, "order"> {
|
||||
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<ComponentClass<MessageComponentProps>, "new"> {
|
||||
new(props: MessageComponentProps): Component<MessageComponentProps> & {
|
||||
renderAttachments(message: Partial<Message>): ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManaSearchBarProps extends Pick<
|
||||
ComponentPropsWithRef<TextInput>,
|
||||
"autoFocus" | "placeholder" | "onKeyDown" | "disabled" | "onChange" | "onBlur" | "onFocus" | "autoComplete" | "ref"
|
||||
> {
|
||||
query?: string;
|
||||
onClear?: () => void;
|
||||
inputProps?: ComponentProps<TextInput>;
|
||||
}
|
||||
|
||||
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<A = any, B extends JsonValue = any> {
|
||||
encode: (data: A) => B | null;
|
||||
decode: (data: PartialDeep<B, { recurseIntoArrays: true; }>) => NoInfer<A> | null;
|
||||
stringify: (data: A) => string;
|
||||
}
|
||||
|
||||
export type ItemsDef<T> = T & {
|
||||
[K in keyof T]: T[K] extends CustomItemDef<infer A, infer B> ? CustomItemDef<A, B> : never;
|
||||
};
|
||||
|
||||
export interface RefreshedUrlsResponse {
|
||||
refreshed_urls: [
|
||||
{
|
||||
original: string;
|
||||
refreshed: string | null;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface UnfurledEmbedsResponse {
|
||||
embeds: EmbedJSON[];
|
||||
}
|
||||
|
||||
export type ResizeObserverHook = (
|
||||
ref: RefObject<Element | null>,
|
||||
callback: (size: { width: number; height: number; }) => void,
|
||||
deps?: unknown[]
|
||||
) => void;
|
||||
|
||||
export interface ImageUtils {
|
||||
isAnimated(image: { src: string; original?: string; animated: boolean; srcIsAnimated?: boolean; }): boolean;
|
||||
}
|
||||
312
src/equicordplugins/favouriteAnything/utils.ts
Normal file
312
src/equicordplugins/favouriteAnything/utils.ts
Normal file
|
|
@ -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<typeof import("./native")>;
|
||||
|
||||
export const cl = classNameFactory("vc-favouriteAnything-");
|
||||
|
||||
export const useResizeObserver: ResizeObserverHook = findByCodeLazy("borderBoxSize", "blockSize", "inlineSize");
|
||||
export const ImageUtils: ImageUtils_ = findByPropsLazy("isAnimated", "getFormatQuality");
|
||||
|
||||
const defineItem = <const A, const B extends JsonValue>(item: CustomItemDef<A, B>) => item;
|
||||
function defineItems<T extends Record<CustomItemFormat, CustomItemDef>>(def: ItemsDef<T>) {
|
||||
type Type<F extends CustomItemFormat> = T[F] extends CustomItemDef<infer A> ? A : never;
|
||||
|
||||
return {
|
||||
encode: <F extends CustomItemFormat>(format: F, data: Type<F>) => {
|
||||
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<F>; };
|
||||
}[CustomItemFormat];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
stringify: <F extends CustomItemFormat>(format: F, item: Type<F>) => 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<URL | null> {
|
||||
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<string>([
|
||||
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<File> {
|
||||
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<string, FavouriteItem> | 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<string, FavouriteItem> | 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<Key, number>());
|
||||
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<T> {
|
||||
private items: T[] = [];
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private readonly queue: Queue = new Queue();
|
||||
|
||||
constructor(
|
||||
private readonly cb: (items: T[]) => Promise<void>,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
};
|
||||
|
||||
export const ApplicationAssetUtils = mapMangledModuleLazy("getAssetImage: size must === [", {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue