Merge branch 'dev'

This commit is contained in:
thororen1234 2026-02-26 11:24:01 -05:00
commit dbbf150c02
No known key found for this signature in database
58 changed files with 4697 additions and 1530 deletions

View file

@ -51,6 +51,7 @@ export default definePlugin({ options: { myOption: { /* ... */ } } });
```
**Prefer declarative APIs** over manual registration:
- `flux: { EVENT_NAME(data) {} }` not manual FluxDispatcher subscribe
- `contextMenus: { "nav-id": fn }` not manual addContextMenuPatch
- `chatBarButton: { render, icon }` not addChatBarButton
@ -81,7 +82,6 @@ Flag these and suggest the fix:
| `cdn.discordapp.com/icons/...` | `IconUtils.getGuildIconURL(...)` | Same |
| `cdn.discordapp.com/banners/...` | `IconUtils.getUserBannerURL(...)` | Same |
| `cdn.discordapp.com/emojis/...` | `IconUtils.getEmojiURL(...)` | Same |
| `/assets/*.png` default avatars | `IconUtils.getDefaultAvatarURL(id)` | Same |
| `/api/v9/...` or `/users/@me` | `Constants.Endpoints.*` or `RestAPI` | Endpoints change |
| `@api/Styles` for classNameFactory | `@utils/css` | `@api/Styles` is deprecated |
| `any` for Discord objects | Import from `@vencord/discord-types` | Type safety |
@ -361,6 +361,7 @@ If a PR reimplements any of these, flag it. They already exist:
**@api/Commands:** `sendBotMessage`, `findOption`
**@webpack/common:**
- Stores: `UserStore`, `GuildStore`, `ChannelStore`, `GuildMemberStore`, `SelectedChannelStore`, `SelectedGuildStore`, `PresenceStore`, `RelationshipStore`, `MessageStore`, `EmojiStore`, `ThemeStore`, `PermissionStore`, `VoiceStateStore`, 30+ more
- Actions: `RestAPI`, `FluxDispatcher`, `MessageActions`, `NavigationRouter`, `ChannelRouter`, `ChannelActionCreators`, `SettingsRouter`
- Utils: `Constants` (`.Endpoints`), `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `IconUtils`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils`

7
.rules
View file

@ -102,6 +102,7 @@
### Patch Examples
**Clean replacement:**
```js
find: "#{intl::PIN_MESSAGE}),icon:"
match: /#{intl::PIN_MESSAGE}\)/
@ -109,6 +110,7 @@ replace: "$self.getPinLabel(arguments[0]))"
```
**Appending:**
```js
find: "#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}"
match: /#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}.{0,100}(?=])/
@ -116,6 +118,7 @@ replace: "$&,$self.renderTooltip(arguments[0].guild)"
```
**Wrapping:**
```js
find: "#{intl::THREE_USERS_TYPING}"
match: /(?<="aria-atomic":!0,children:)\i/
@ -168,7 +171,6 @@ Use `definePluginSettings` from `@api/Settings`, not inline settings object.
From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs.
- `IconUtils.getUserAvatarURL(user, canAnimate?, size?)`
- `IconUtils.getDefaultAvatarURL(id)`
- `IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })`
- `IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })`
- `IconUtils.getGuildBannerURL(guild, canAnimate?)`
@ -187,7 +189,7 @@ From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs.
#### Utils
`Constants` (`Constants.Endpoints`), `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils` from `@webpack/common`
`Constants` (`Constants.Endpoints`), `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils`, `URLUtils`, `Humanize`, `EmojiUtils` from `@webpack/common`
#### Components
@ -208,4 +210,3 @@ From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs.
- `"/api/v9"`, `"/users/@me"` ⟹ `Constants.Endpoints` or `RestAPI`
- `console.log/warn/error` ⟹ `Logger` from `@utils/Logger`
- `` `${a} ${b}` `` for class names ⟹ `classes(a, b)` from `@utils/misc`
- `"/assets/*.png"` for default avatars ⟹ `IconUtils.getDefaultAvatarURL(id)`

View file

@ -139,6 +139,5 @@ window.VencordNative = {
setUpdateState: NOOP,
onCheckUpdates: NOOP,
onRepair: NOOP,
onAbout: NOOP,
},
};

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.14.2.1",
"version": "1.14.3.0",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {

View file

@ -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;

View file

@ -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;

View 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;
}

View file

@ -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";

View file

@ -25,14 +25,29 @@ export type Parser = Record<
| "parse"
| "parseTopic"
| "parseEmbedTitle"
| "parseEmbedTitleWithoutLinks"
| "parseInlineReply"
| "parseGuildVerificationFormRule"
| "parseGuildEventDescription"
| "parseAutoModerationSystemMessage"
| "parseForumPostGuidelines"
| "parseForumPostMostRecentMessage",
| "parseVoiceChannelStatus",
(content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[]
> & Record<"defaultRules" | "guildEventRules", Record<string, Record<"react" | "html" | "parse" | "match" | "order", any>>>;
> & Record<
| "parseToAST"
| "parseTopicToAST"
| "parseEmbedTitleToAST"
| "parseEmbedTitleWithoutLinksToAST"
| "parseInlineReplyToAST"
| "parseAutoModerationSystemMessageToAST",
(content: string, inline?: boolean, state?: Record<string, any>) => any[]
> & Record<"defaultRules" | "guildEventRules" | "notifCenterV2MessagePreviewRules" | "lockscreenWidgetMessageRules", Record<string, Record<"react" | "html" | "parse" | "match" | "order", any>>> & {
reactParserFor(rules: Record<string, any>): (content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[];
astParserFor(rules: Record<string, any>): (content: string, inline?: boolean, state?: Record<string, any>) => any[];
createReactRules(options: Record<string, any>): Record<string, any>;
defaultReactRuleOptions: Record<string, any>;
combineAndInjectMentionRule(rules: Record<string, any>, mentionRule: Record<string, any>): Record<string, any>;
};
export interface Alerts {
show(alert: {
@ -48,16 +63,39 @@ export interface Alerts {
onConfirmSecondary?(): void;
onCloseCallback?(): void;
}): void;
confirm(alert: {
title: any;
body: React.ReactNode;
className?: string;
confirmColor?: string;
cancelText?: string;
confirmText?: string;
secondaryConfirmText?: string;
onCancel?(): void;
onConfirmSecondary?(): void;
onCloseCallback?(): void;
}): Promise<boolean>;
/** This is a noop, it does nothing. */
close(): void;
}
export interface SnowflakeUtils {
fromTimestamp(timestamp: number): string;
fromTimestampWithSequence(timestamp: number, sequence: number): string;
extractTimestamp(snowflake: string): number;
age(snowflake: string): number;
atPreviousMillisecond(snowflake: string): string;
atNextMillisecond(snowflake: string): string;
compare(snowflake1?: string, snowflake2?: string): number;
isProbablyAValidSnowflake(snowflake: string): boolean;
castChannelIdAsMessageId(channelId: string): string;
castMessageIdAsChannelId(messageId: string): string;
castGuildIdAsEveryoneGuildRoleId(guildId: string): string;
cast(snowflake: string): string;
keys(obj: Record<string, any>): string[];
forEach(obj: Record<string, any>, callback: (value: any, key: string) => void): void;
forEachKey(obj: Record<string, any>, callback: (key: string) => void): void;
entries(obj: Record<string, any>): [string, any][];
}
interface RestRequestData {
@ -249,45 +287,55 @@ export interface UsernameUtils {
useName(user: User): string;
getUserTag(user: User, options?: UserNameUtilsTagOptions): string;
useUserTag(user: User, options?: UserNameUtilsTagOptions): string;
isNameConcealed(user: User): boolean;
getUserIsStaff(user: User): boolean;
useDirectMessageRecipient: any;
humanizeStatus: any;
}
// TODO: fix type
export class DisplayProfile {
userId: string;
guildId?: string;
banner?: string;
bio?: string;
pronouns?: string;
accentColor?: number;
themeColors?: number[];
profileEffect?: any;
popoutAnimationParticleType?: any;
profileEffectId?: string;
fetchStartedAt?: Date | null;
fetchEndedAt?: Date | null;
_userProfile?: any;
_guildMemberProfile?: any;
canUsePremiumProfileCustomization: boolean;
canEditThemes: boolean;
premiumGuildSince: Date | null;
premiumSince: Date | null;
premiumType?: PremiumType;
primaryColor?: number;
get premiumSince(): Date | null;
get premiumGuildSince(): Date | null;
get premiumType(): PremiumType | undefined;
get private(): boolean;
get widgets(): any[] | undefined;
get gameWidgets(): any[] | undefined;
get primaryColor(): number | undefined;
get canUsePremiumProfileCustomization(): boolean;
get canEditThemes(): boolean;
get application(): any;
get isLoaded(): boolean;
hasThemeColors(): boolean;
hasPremiumCustomization(): boolean;
isUsingGuildMemberBanner(): boolean;
isUsingGuildMemberBio(): boolean;
isUsingGuildMemberPronouns(): boolean;
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
getPreviewBanner(banner: string | null, canAnimate: boolean, size?: number): string | null;
getPreviewBio(bio: string | undefined): string | undefined;
getPreviewThemeColors(themeColors: number[] | undefined): number[] | undefined;
getBadges(): Array<{
id: string;
description: string;
icon: string;
link?: string;
}>;
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
getLegacyUsername(): string | null;
hasFullProfile(): boolean;
hasPremiumCustomization(): boolean;
hasThemeColors(): boolean;
isUsingGuildMemberBanner(): boolean;
isUsingGuildMemberBio(): boolean;
isUsingGuildMemberPronouns(): boolean;
}
export interface DisplayProfileUtils {
@ -317,3 +365,51 @@ export interface CommandOptions {
max_value?: number;
autocomplete?: boolean;
}
export interface URLUtils {
URL_REGEX: RegExp;
makeUrl(url: string): URL | null;
isDiscordUrl(url: string): boolean;
isDiscordCdnUrl(url: string): boolean;
isDiscordAssetUrl(url: string, proxyUrl?: string, originalUrl?: string): boolean;
isDiscordDirectAssetUrl(url: string): boolean;
isDiscordProxiedAssetUrl(url: string, proxyUrl?: string, originalUrl?: string): boolean;
isDiscordHostname(hostname: string): boolean;
isDiscordLocalhost(hostname: string): boolean;
isDiscordProtocol(url: string): boolean;
isDiscordUri(url: string): boolean;
isDiscordUrlOrUri(url: string): boolean;
isAllowedGifProviderUrl(url: string): boolean;
isAppRoute(url: string): boolean;
isOriginalContentTypeDifferent(url: string, proxyUrl: string): boolean;
toURLSafe(url: string): URL | null;
safeDecodeURIComponent(component: string): string;
safeParseWithQuery(url: string): URL | null;
format(url: URL): string;
formatPathWithQuery(path: string, query: Record<string, string>): string;
formatSearch(query: Record<string, string>): string;
}
export interface Humanize {
filesize(bytes: number, base?: number, decimals?: number, decimalSep?: string, thousandsSep?: string, suffix?: string): string;
intword(number: number, units?: string[], base?: number, decimals?: number, decimalSep?: string, thousandsSep?: string): string;
relativeTime(timestamp?: number): string;
naturalDay(timestamp?: number, format?: string): string;
ordinal(value: number): string;
numberFormat(number: number, decimals?: number, decimalSep?: string, thousandsSep?: string): string;
truncatechars(str: string, maxLength: number): string;
truncatewords(str: string, maxWords: number): string;
pad(str: string, length: number, padChar?: string, direction?: "left" | "right"): string;
date(format: string, timestamp?: Date | number): string;
time(): number;
linebreaks(str: string): string;
nl2br(str: string): string;
}
export interface EmojiUtils {
getEmojiColors(emoji: any): number[] | null;
getURL(emoji: any): string;
filterUnsupportedEmojis(emojis: any[]): any[];
triggerFullscreenAnimation(emoji: any, node: HTMLElement): void;
applyPlatformToThemedEmojiColorPalette(colors: any): any;
}

View file

@ -102,7 +102,6 @@ export default {
setUpdateState: (available: boolean) => ipcRenderer.send(IpcEvents.SET_TRAY_UPDATE_STATE, available),
onCheckUpdates: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_CHECK_UPDATES, cb); },
onRepair: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_REPAIR, cb); },
onAbout: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_ABOUT, cb); },
},
pluginHelpers: PluginHelpers

View 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")} />;
}

View file

@ -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
SignedUrlsStore.addSigned(item.url);
if (URL.canParse(item.src)) {
SignedUrlsStore.addSigned(item.src);
return item;
}
: props;
if (!width || !height || !url) return null;
const thumbnail = await getThumbnailUrl(item.src, item.width, item.height);
if (!thumbnail) return item;
return (
<FavoriteButton
format={video ? Format.VIDEO : Format.IMAGE}
className={Classes?.gifFavoriteButton}
src={proxyUrl ?? url}
url={url}
width={width}
height={height}
/>
);
thumbnail.search = "";
thumbnail.hash = item.src;
return { ...item, src: `${thumbnail}` };
}
});

View 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 };
}

View 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);
}

View 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;
});

View 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);
}
}

View 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;
}

View 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)));
}
}

View file

@ -22,7 +22,7 @@ import { Alerts, Button, ContextMenuApi, FluxDispatcher, Menu, React, showToast,
import { addToCollection, cache_collections, createCollection, DATA_COLLECTION_NAME, deleteCollection, fixPrefix, getCollections, getGifById, getItemCollectionNameFromId, moveGifToCollection, refreshCacheCollection, removeFromCollection, renameCollection, updateGif } from "./utils/collectionManager";
import { getFormat } from "./utils/getFormat";
import { getGif } from "./utils/getGif";
import { refreshGifUrl } from "./utils/refreshUrl";
import { batchRefreshAttachmentUrls, isCdnUrlExpired } from "./utils/refreshUrl";
import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
import { uuidv4 } from "./utils/uuidv4";
@ -385,11 +385,44 @@ export default definePlugin({
width: g.width,
height: g.height
})).reverse();
const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url)));
if (expiredGifs.length > 0) {
const allUrls = [...new Set<string>(
expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u)))
)];
const refreshInBatches = async () => {
const fullMap: Record<string, string> = {};
for (let i = 0; i < allUrls.length; i += 50) {
const batch = allUrls.slice(i, i + 50);
const result = await batchRefreshAttachmentUrls(batch);
Object.assign(fullMap, result);
}
return fullMap;
};
refreshInBatches().then(async refreshMap => {
if (!Object.keys(refreshMap).length) return;
let anyUpdated = false;
for (const gif of expiredGifs) {
const newSrc = refreshMap[gif.src] ?? gif.src;
const newUrl = refreshMap[gif.url] ?? gif.url;
if (newSrc !== gif.src || newUrl !== gif.url) {
await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl });
anyUpdated = true;
}
}
if (!anyUpdated) return;
const { query } = instance.props;
FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: `${query} ` });
await new Promise<void>(r => setTimeout(r, 2500));
FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query });
});
}
}
}
},
hidePrefix(name) {
return name.split(":".length > 1) ? name.replace(/.+?:/, "") : name;
return name.includes(":") ? name.replace(/.+?:/, "") : name;
},
insertCollections(instance) {
const shouldRemoveAll = settings.store.onlyShowCollections;
@ -547,40 +580,6 @@ const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
}}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
key="refresh-url"
id="refresh-url"
label="Refresh URL"
action={async () => {
const gifInfo = getGifById(nameOrId);
if (!gifInfo) return;
if (!gifInfo.channelId || !gifInfo.messageId || !gifInfo.attachmentId) {
showToast("Cannot refresh: GIF missing metadata (re-add to collection)", Toasts.Type.FAILURE);
return;
}
showToast("Refreshing URL...", Toasts.Type.MESSAGE);
const refreshedGif = await refreshGifUrl(gifInfo);
if (refreshedGif) {
await updateGif(nameOrId, refreshedGif);
const collectionName = getItemCollectionNameFromId(nameOrId);
FluxDispatcher.dispatch({
type: "GIF_PICKER_QUERY",
query: `${collectionName} `
});
FluxDispatcher.dispatch({
type: "GIF_PICKER_QUERY",
query: `${collectionName}`
});
showToast("URL refreshed successfully", Toasts.Type.SUCCESS);
} else {
showToast("Failed to refresh URL", Toasts.Type.FAILURE);
}
}}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
key="copy-url"
id="copy-url"

View file

@ -63,7 +63,7 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null
id: uuidv4(settings.store.itemPrefix),
height: embed.image.height,
width: embed.image.width,
src: embed.image.proxyURL,
src: embed.image.proxyURL!,
url: embed.image.url,
};
// Tennor

View file

@ -4,46 +4,31 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Gif } from "@equicordplugins/gifCollections/types";
import { RestAPI } from "@webpack/common";
async function fetchMessageFromAPI(channelId: string, messageId: string): Promise<any> {
export function isCdnUrlExpired(url: string): boolean {
try {
const response = await RestAPI.get({
url: `/channels/${channelId}/messages`,
query: {
limit: 1,
around: messageId
const ex = new URL(url).searchParams.get("ex");
if (!ex) return false;
return parseInt(ex, 16) * 1000 < Date.now();
} catch {
return false;
}
}
export async function batchRefreshAttachmentUrls(urls: string[]): Promise<Record<string, string>> {
try {
const response = await RestAPI.post({
url: "/attachments/refresh-urls",
body: { attachment_urls: urls }
});
if (response.ok && response.body.length > 0) {
return response.body.find((m: any) => m.id === messageId);
if (!response.ok) return {};
const map: Record<string, string> = {};
for (const { original, refreshed } of response.body.refreshed_urls) {
map[original] = refreshed;
}
} catch (error) {
console.error("Failed to fetch message for GIF refresh:", error);
return map;
} catch {
return {};
}
return null;
}
export async function refreshGifUrl(gif: Gif): Promise<Gif | null> {
if (!gif.channelId || !gif.messageId || !gif.attachmentId) {
return null;
}
const message = await fetchMessageFromAPI(gif.channelId, gif.messageId);
if (!message) {
return null;
}
const attachment = message.attachments?.find((a: any) => a.id === gif.attachmentId);
if (!attachment) {
return null;
}
return {
...gif,
src: attachment.proxy_url,
url: attachment.url
};
}

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { IconComponent, OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { findExportedComponentLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, Menu } from "@webpack/common";
const EyeIconLazy = findExportedComponentLazy("EyeIcon");
const EyeIcon: IconComponent = props => <EyeIconLazy {...props} />;
const hideMessage = (messageId: string, channelId: string) => {
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
id: messageId,
channelId,
mlDeleted: true,
});
};
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
<Menu.MenuItem
id="vc-hidemessages"
label="Hide"
icon={EyeIcon}
action={() => hideMessage(message.id, message.channel_id)}
/>
));
};
const settings = definePluginSettings({
hidePopoverButton: {
type: OptionType.BOOLEAN,
description: "Hide the hide button in the message popover.",
default: false
}
});
export default definePlugin({
name: "HideMessages",
description: "A plugin to temporarily hide messages until you restart.",
authors: [EquicordDevs.yash],
contextMenus: {
"message": messageCtxPatch
},
settings,
messagePopoverButton: {
icon: EyeIcon,
render(message: Message) {
if (settings.store.hidePopoverButton) return null;
return {
label: "Hide",
icon: EyeIcon,
message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: () => hideMessage(message.id, message.channel_id)
};
}
}
});

View file

@ -17,6 +17,20 @@ import { Channel } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import { FluxDispatcher, Menu, React, Tooltip, UserStore } from "@webpack/common";
interface CallUpdate {
ringing: string[];
ongoingRings: Record<number, string>;
messageId: string;
region: string;
}
const args: CallUpdate = {
ringing: [],
ongoingRings: {},
messageId: "",
region: "",
};
const ignoredChannelIds = new Set<string>();
const cl = classNameFactory("vc-ignore-calls-");
const Deafen = findComponentByCodeLazy("0-1.02-.1H3.05a9");
@ -74,12 +88,6 @@ const settings = definePluginSettings({
},
});
const args = {
ringing: [],
messageId: "",
region: "",
};
export default definePlugin({
name: "IgnoreCalls",
description: "Allows you to ignore calls from specific users or dm groups.",
@ -99,8 +107,9 @@ export default definePlugin({
"gdm-context": ContextMenuPatch,
},
flux: {
async CALL_UPDATE({ ringing, messageId, region }) {
args.ringing = ringing;
async CALL_UPDATE({ ringing, ongoingRings, messageId, region }) {
args.ringing = ringing || [];
args.ongoingRings = ongoingRings || {};
args.messageId = messageId;
args.region = region;
}
@ -113,6 +122,7 @@ export default definePlugin({
type: "CALL_UPDATE",
channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
messageId: args.messageId,
region: args.region
});
@ -133,6 +143,7 @@ export default definePlugin({
type: "CALL_UPDATE",
channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
messageId: args.messageId,
region: args.region
});

View file

@ -224,7 +224,7 @@ function Timestamp({ channel }: { channel: Channel; }) {
if (!lastMessage) return null;
const timestamp = SnowflakeUtils.extractTimestamp(lastMessage.id);
const className = ExperimentStore.getUserExperimentBucket("2021-09_favorites_server") >= 1 ? cl("timestamp-favorites") : cl("timestamp");
const className = ExperimentStore.getUserExperimentBucket("2026-01-favorites-server") >= 1 ? cl("timestamp-favorites") : cl("timestamp");
return <span className={className}>{formatRelativeTime(timestamp)}</span>;
}

View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "NoPushToTalk",
description: "Bypasses the push-to-talk requirement for voice activity in channels that enforce it.",
authors: [EquicordDevs.omaw],
patches: [
{
find: "PermissionVADStore",
replacement: [
{
match: /\|\|\i\.\i\.can\(\i\.\i\.USE_VAD,\i\)\|\|/,
replace: "||true||"
},
{
match: /shouldShowWarning\(\)\{return!\i\}/,
replace: "shouldShowWarning(){return false}"
},
{
match: /canUseVoiceActivity\(\)\{return \i\}/,
replace: "canUseVoiceActivity(){return true}"
},
]
}
],
});

View file

@ -41,6 +41,10 @@ const settings = definePluginSettings({
label: "Playstation",
value: "playstation",
},
{
label: "VR",
value: "vr",
},
]
}
});
@ -48,7 +52,7 @@ const settings = definePluginSettings({
export default definePlugin({
name: "PlatformSpoofer",
description: "Spoof what platform or device you're on",
authors: [EquicordDevs.Drag],
authors: [EquicordDevs.Drag, EquicordDevs.neoarz],
settingsAboutComponent: () => (
<Notice.Warning>
We can't guarantee this plugin won't get you warned or banned.
@ -95,6 +99,8 @@ export default definePlugin({
return { browser: "Discord Embedded", vcIcon: 2 };
case "playstation":
return { browser: "Discord Embedded", vcIcon: 3 };
case "vr":
return { browser: "Discord VR", vcIcon: 4 };
default:
return null;
}

View file

@ -281,7 +281,7 @@ function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput
function normalizeImageValue(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "imageUri" in value) {
const { imageUri } = value as { imageUri: unknown };
const { imageUri } = value as { imageUri: unknown; };
return typeof imageUri === "string" ? imageUri : null;
}
return null;

View file

@ -1605,17 +1605,9 @@ export default definePlugin({
}
]
},
{
// Sets intervals to progress Video Quests in the background.
find: "CAPTCHA_FAILED:",
replacement: {
match: /(?<=SUCCESS:)(\i\({)/,
replace: "!$self.processQuestForAutoComplete(arguments[0])&&$1"
}
},
{
// Adds support for dev://experiment/2025-12-quest-cta-refactor-rollout
find: '"sm",preClickCallback:',
find: '"primary",preClickCallback:',
replacement: [
{
match: /(?=let{quest:)/,
@ -1626,11 +1618,19 @@ export default definePlugin({
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)"
},
{
match: /(?<=,text:)(?=\i)/,
replace: "questifyText??"
match: /(?<=,text:)(\i),icon:\i/,
replace: "questifyText??$1"
}
]
},
{
// Sets intervals to progress Video Quests in the background.
find: "CAPTCHA_FAILED:",
replacement: {
match: /(?<=SUCCESS:)(\i\({)/,
replace: "!$self.processQuestForAutoComplete(arguments[0])&&$1"
}
},
{
// Sets intervals to progress Play Game Quests in the background.
// Triggers if a Quest has already been started but was interrupted, such as by a reload.

View file

@ -12,11 +12,11 @@ import { getCurrentChannel } from "@utils/discord";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { Button, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
import { Button, IconUtils, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
import { QuoteIcon } from "./components/QuoteIcon";
import { QuoteFont } from "./types";
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, getFileExtension, getMimeType, resetFontLoading, sizeUpgrade } from "./utils";
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, getFileExtension, getMimeType, resetFontLoading } from "./utils";
const settings = definePluginSettings({
quoteFont: {
@ -105,7 +105,7 @@ function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
const generateImage = async () => {
const image = await createQuoteImage({
avatarUrl: sizeUpgrade(message.author.getAvatarURL()),
avatarUrl: IconUtils.getUserAvatarURL(message.author, true, 512),
quote: message.content,
grayScale: gray,
author: message.author,

View file

@ -21,12 +21,6 @@ interface CustomEmojiToken {
animated: boolean;
}
export function sizeUpgrade(url: string): string {
const u = new URL(url);
u.searchParams.set("size", "512");
return u.toString();
}
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(blob => {

View file

@ -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) => {

View file

@ -21,7 +21,7 @@ export async function authorizeUser(triggerModal: boolean = true) {
responseType="code"
redirectUri="https://themes.equicord.org/api/user/auth"
permissions={0n}
clientId="1257819493422465235"
clientId="1464006702125940736"
cancelCompletesFlow={false}
callback={async ({ location }: any) => {
if (!location) return logger.error("No redirect location returned");

View file

@ -0,0 +1,66 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { IconComponent } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { findExportedComponentLazy } from "@webpack";
import { ChannelStore, Menu } from "@webpack/common";
import { settings } from "./settings";
import { getResponse, handleResponse, parseMessageContent } from "./utils";
const RobotIconLazy = findExportedComponentLazy("RobotIcon");
const RobotIcon: IconComponent = props => <RobotIconLazy {...props} />;
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
const payload = parseMessageContent(message);
if (!payload) return;
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
<Menu.MenuItem
id="vc-trivia-ai"
label="Answer With AI"
icon={RobotIcon}
action={async () => {
const ans = await getResponse(payload);
handleResponse(message, ans);
}}
/>
));
};
export default definePlugin({
name: "TriviaAI",
description: "A plugin that helps you answer trivia questions using AI.",
authors: [EquicordDevs.yash],
settings,
contextMenus: {
"message": messageCtxPatch
},
messagePopoverButton: {
icon: RobotIcon,
render(message: Message) {
const payload = parseMessageContent(message);
if (!payload) return null;
return {
label: "Answer With AI",
icon: RobotIcon,
message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: async () => {
const ans = await getResponse(payload);
handleResponse(message, ans);
}
};
}
}
});

View file

@ -0,0 +1,53 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
apiKey: {
type: OptionType.STRING,
description: "API Key.",
default: "",
placeholder: "Enter API Key here for your AI endpoint.",
componentProps: {
type: "password"
}
},
model: {
type: OptionType.STRING,
description: "AI Model to use.",
default: "google/gemini-3-flash-preview",
placeholder: "e.g. google/gemini-3-flash-preview, inception/mercury, openai/gpt-5.2-chat, etc."
},
systemPrompt: {
type: OptionType.STRING,
description: "System Prompt for the AI.",
default: "You are a helpful assistant who answers questions for the user in a concise and short way while using the least amount of words and punctuation.",
placeholder: "Enter system prompt."
},
maxTokens: {
type: OptionType.NUMBER,
description: "Maximum number of tokens in the response.",
default: 500
},
endpoint: {
type: OptionType.STRING,
description: "OpenAI Compatible AI Endpoint.",
default: "https://openrouter.ai/api/v1/chat/completions",
placeholder: "Enter your OpenAI compatible AI endpoint here."
},
autoRespond: {
type: OptionType.BOOLEAN,
description: "Automatically respond to messages on receiving a response.",
default: false
},
supportImages: {
type: OptionType.BOOLEAN,
description: "Pass images to the AI for context (if any). This is not supported by all models.",
default: true
}
});

View file

@ -0,0 +1,188 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import { Message } from "@vencord/discord-types";
import { showToast, Toasts } from "@webpack/common";
import { settings } from "./settings";
const logger = new Logger("TriviaAI");
type TextPart = {
type: "text";
text: string;
};
type ImagePart = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "high" | "low";
};
};
export type ContentPayload = string | (TextPart | ImagePart)[];
export function parseMessageContent(message: Message): ContentPayload | null {
const textParts: string[] = [];
if (message.content && message.content.trim().length > 0) {
textParts.push(message.content);
}
message.embeds.forEach(embed => {
const embedBuffer: string[] = [];
const parts = [
embed.provider?.name ? `> ${embed.provider.name}` : null,
embed.author?.name ? `**${embed.author.name}**` : null,
embed.rawTitle ? `## ${embed.rawTitle}` : null,
embed.rawDescription ?? null,
...(embed.fields?.map(f => (f.rawName && f.rawValue) ? `**${f.rawName}**: ${f.rawValue}` : null) ?? []),
embed.footer?.text ? `_${embed.footer.text}_` : null,
];
parts.forEach(p => {
if (p) embedBuffer.push(p);
});
if (embedBuffer.length > 0) textParts.push(embedBuffer.join("\n"));
});
const combinedText = textParts.join("\n\n");
if (!settings.store.supportImages) {
return combinedText || null;
}
const imageUrls = new Set<string>();
message.attachments
.filter(att => att.content_type?.startsWith("image/"))
.forEach(att => imageUrls.add(att.url));
message.embeds.forEach(embed => {
const potentialUrls = [
embed.image?.url,
embed.thumbnail?.url,
...(embed.images?.map(img => img.url) ?? [])
];
potentialUrls.forEach(url => {
if (url) imageUrls.add(url);
});
});
if (imageUrls.size === 0) {
return combinedText || null;
}
const payload: (TextPart | ImagePart)[] = [];
if (combinedText.length > 0) {
payload.push({
type: "text",
text: combinedText
});
}
imageUrls.forEach(url => {
payload.push({
type: "image_url",
image_url: { url }
});
});
return payload;
}
export async function handleResponse(message: Message, response: string): Promise<string> {
if (settings.store.autoRespond) {
sendMessage(
message.channel_id,
{ content: response },
true,
{ messageReference: { channel_id: message.channel_id, message_id: message.id } }
);
return response;
} else {
insertTextIntoChatInputBox(response);
return response;
}
}
export async function getResponse(payload: ContentPayload): Promise<string> {
if (!settings.store.apiKey) {
showToast("TriviaAI: API Key is not set.", Toasts.Type.FAILURE);
return "";
}
if (!settings.store.endpoint) {
showToast("TriviaAI: Endpoint is not set.", Toasts.Type.FAILURE);
return "";
}
if (!settings.store.model) {
showToast("TriviaAI: Model is not set.", Toasts.Type.FAILURE);
return "";
}
try {
const req = await fetch(settings.store.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${settings.store.apiKey}`
},
body: JSON.stringify({
model: settings.store.model,
messages: [
{
role: "system",
content: settings.store.systemPrompt
},
{
role: "user",
content: payload
}
],
max_tokens: settings.store.maxTokens,
})
});
if (!req.ok) {
const errorBody = await req.text();
const errorMessage = `API request failed with status ${req.status}: ${errorBody}`;
logger.error(errorMessage);
showToast(errorMessage, Toasts.Type.FAILURE);
return "";
}
const data = await req.json();
if (data.error) {
const errorMessage = data.error.message ?? "An unknown error occurred";
logger.error(`API Error: ${errorMessage}`);
showToast(errorMessage, Toasts.Type.FAILURE);
return "";
}
const response = data.choices[0].message.content;
if (!response?.trim()) {
logger.warn("no response from AI model");
return "";
}
return response;
} catch (e) {
logger.error("Error getting response from AI model", e);
showToast("Error getting response from AI model", Toasts.Type.FAILURE);
return "";
}
}

View file

@ -31,8 +31,12 @@ export default definePlugin({
],
renderButtons(user: User) {
if (!user) return null;
const positionClass = settings.store.buttonPosition === "right"
? "voice-user-buttons-right"
: "voice-user-buttons-left";
return (
<Flex flexDirection="row" className="voice-user-buttons">
<Flex flexDirection="row" className={`voice-user-buttons ${positionClass}`}>
{settings.store.showChatButton && <UserChatButton user={user} />}
{settings.store.showMuteButton && <UserMuteButton user={user} />}
{settings.store.showDeafenButton && <UserDeafenButton user={user} />}

View file

@ -30,46 +30,47 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their soundboard upon clicking deafen button.",
restartNeeded: false,
},
disableVideo: {
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their video upon clicking deafen button.",
restartNeeded: false,
},
useServer: {
type: OptionType.BOOLEAN,
description: "Use server mute/deafen instead of local when you have permission.",
default: false,
restartNeeded: false,
},
serverSelf: {
type: OptionType.BOOLEAN,
description: "Deafen / Mute yourself on the server when using mute/deafen.",
default: false,
restartNeeded: false,
},
showButtonsSelf: {
type: OptionType.SELECT,
default: "display",
description: "Whether you want to display buttons for your own user. Same functionality as other button(s) except it'll open DMs panel and mute/deafen for yourself upon clicking button(s).",
restartNeeded: true,
options: [
{ label: "Display", value: "display" },
{ label: "Display", value: "display", default: true },
{ label: "Hide", value: "hide" },
{ label: "Disable", value: "disable" },
],
},
whichNameToShow: {
type: OptionType.SELECT,
default: "both",
description: "Choose whether to show nickname or username in tooltip.",
restartNeeded: false,
options: [
{ label: "Both", value: "both", default: true },
{ label: "Global Name", value: "global" },
{ label: "Default Username", value: "username" },
{ label: "Both", value: "both" },
{ label: "Username", value: "username" },
],
},
buttonPosition: {
type: OptionType.SELECT,
description: "Choose where to place voice buttons in the voice user row.",
options: [
{ label: "Left", value: "left", default: true },
{ label: "Right", value: "right" },
],
}
}, {

View file

@ -2,6 +2,15 @@
flex: none !important;
}
.voice-user-buttons-left {
order: -1;
}
.voice-user-buttons-right {
order: 1;
margin-left: 4px;
}
.voice-user-button-container {
padding-right: 4px;
}

View file

@ -7,6 +7,7 @@
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { insertTextIntoChatInputBox } from "@utils/discord";
import definePlugin, { makeRange, OptionType } from "@utils/types";
import type { Channel } from "@vencord/discord-types";
import { GuildChannelStore, Menu, React, RestAPI, UserStore, VoiceStateStore } from "@webpack/common";
@ -48,6 +49,17 @@ function sendPatch(channel: Channel, body: Record<string, any>, bypass = false)
});
}
function mentionVoiceUsers(channel: Channel) {
const currentUserId = UserStore.getCurrentUser()?.id;
const mentions = Object.values(VoiceStateStore.getVoiceStatesForChannel(channel.id))
.map(state => state.userId)
.filter((userId, index, arr) => userId && userId !== currentUserId && arr.indexOf(userId) === index)
.map(userId => `<@${userId}>`);
if (mentions.length === 0) return;
insertTextIntoChatInputBox(`${mentions.join(" ")} `);
}
interface VoiceChannelContextProps {
channel: Channel;
}
@ -69,6 +81,13 @@ const VoiceChannelContext: NavContextMenuPatchCallback = (children, { channel }:
key="voice-tools"
id="voice-tools"
>
<Menu.MenuItem
key="voice-tools-mention-all"
id="voice-tools-mention-all"
label="Mention all Users"
action={() => mentionVoiceUsers(channel)}
/>
<Menu.MenuItem
key="voice-tools-disconnect-all"
id="voice-tools-disconnect-all"

View file

@ -22,7 +22,7 @@ import "./settings";
import { debounce } from "@shared/debounce";
import { IpcEvents } from "@shared/IpcEvents";
import { BrowserWindow, ipcMain, nativeTheme, shell, systemPreferences } from "electron";
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, shell, systemPreferences } from "electron";
import monacoHtml from "file://monacoWin.html?minify&base64";
import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile, unlink } from "fs/promises";
@ -139,16 +139,17 @@ ipcMain.on(IpcEvents.GET_MONACO_THEME, e => {
e.returnValue = nativeTheme.shouldUseDarkColors ? "vs-dark" : "vs-light";
});
let monacoWin: BrowserWindow | null = null;
ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
const title = "Equicord QuickCSS Editor";
const existingWindow = BrowserWindow.getAllWindows().find(w => w.title === title);
if (existingWindow && !existingWindow.isDestroyed()) {
existingWindow.focus();
if (monacoWin && !monacoWin.isDestroyed()) {
monacoWin.show();
monacoWin.focus();
return;
}
const win = new BrowserWindow({
title,
monacoWin = new BrowserWindow({
title: "Equicord QuickCSS Editor",
autoHideMenuBar: true,
darkTheme: true,
webPreferences: {
@ -159,9 +160,28 @@ ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
}
});
makeLinksOpenExternally(win);
monacoWin.once("closed", () => { monacoWin = null; });
await win.loadURL(`data:text/html;base64,${monacoHtml}`);
makeLinksOpenExternally(monacoWin);
await monacoWin.loadURL(`data:text/html;base64,${monacoHtml}`);
});
app.on("before-quit", async event => {
if (monacoWin && !monacoWin.isDestroyed() && !monacoWin.isVisible()) {
const result = await dialog.showMessageBox({
type: "question",
buttons: ["Cancel", "Close Anyway"],
defaultId: 0,
title: "QuickCSS Editor Open",
message: "QuickCSS editor is still open in the background.",
detail: "Do you want to close Discord anyway? This will also close the QuickCSS editor."
});
if (result.response === 1) {
app.exit();
}
}
});
ipcMain.handle(IpcEvents.GET_RENDERER_CSS, () => readFile(RENDERER_CSS_PATH, "utf-8"));

View file

@ -87,7 +87,7 @@ export default definePlugin({
replace: "$&$self.onPopoutClose();"
},
{
match: /ref:(\i),style:\i(?=.{0,250}#{intl::5fWB8U::raw})/,
match: /ref:(\i),style:\i(?=.{0,250}#{intl::USER_PROFILE_ACCOUNT_POPOUT_BUTTON_A11Y_LABEL})/,
replace: "$&,onContextMenu:($self.grabRef($1),$self.openAccountPanelContextMenu)"
}
]

View file

@ -21,10 +21,10 @@ import "./style.css";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { FluxDispatcher } from "@webpack/common";
import type { GuildFolder } from "@vencord/discord-types";
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common";
import { ReactNode } from "react";
import FolderSideBar from "./FolderSideBar";
@ -35,15 +35,42 @@ enum FolderIconDisplay {
MoreThanOneFolderExpanded
}
type GuildTreeNode = {
id: string | number;
type?: string;
parentId?: string | number | null;
name?: string;
children?: GuildTreeNode[];
isBetterFoldersNested?: boolean;
[key: string]: unknown;
};
type FolderRenderProps = {
isBetterFolders?: boolean;
folderNode?: GuildTreeNode;
};
type FolderDragItem = {
type?: string;
nodeId: string | number;
};
type FolderMentionProps = {
mentionCount?: number;
folderNode?: GuildTreeNode;
};
export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
export const SortedGuildStore = findStoreLazy("SortedGuildStore");
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize");
const MAX_TREE_FILTER_DEPTH = 1000;
let lastGuildId = null as string | null;
let dispatchingFoldersClose = false;
function getGuildFolder(id: string) {
return SortedGuildStore.getGuildFolders().find(folder => folder.guildIds.includes(id));
return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id));
}
function closeFolders() {
@ -52,22 +79,31 @@ function closeFolders() {
}
// Nuckyz: Unsure if this should be a general utility or not
function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean) {
function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean, visited = new WeakSet<object>(), depth = 0) {
if (children == null) {
return false;
}
if (depth > MAX_TREE_FILTER_DEPTH) {
return false;
}
if (typeof children === "object") {
if (visited.has(children)) return false;
visited.add(children);
}
if (!Array.isArray(children)) {
if (predicate(children)) {
return true;
}
return filterTreeWithTargetNode(children.props?.children, predicate);
return filterTreeWithTargetNode(children.props?.children, predicate, visited, depth + 1);
}
let childIsTargetChild = false;
for (let i = 0; i < children.length; i++) {
const shouldKeep = filterTreeWithTargetNode(children[i], predicate);
const shouldKeep = filterTreeWithTargetNode(children[i], predicate, visited, depth + 1);
if (shouldKeep) {
childIsTargetChild = true;
continue;
@ -79,6 +115,75 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
return childIsTargetChild;
}
function getNestedFolderMap(): Record<string, string> {
return settings.store.nestedFolders ?? {};
}
function saveNestedFolderMap(map: Record<string, string>) {
settings.store.nestedFolders = map;
}
export function getParentFolderId(childId: string | number): string | undefined {
return getNestedFolderMap()[childId.toString()];
}
export function getChildFolderIds(parentId: string | number): string[] {
const map = getNestedFolderMap();
return Object.entries(map)
.filter(([, pid]) => pid === String(parentId))
.map(([cid]) => cid);
}
function getDescendantFolderIds(parentId: string | number): string[] {
const descendants: string[] = [];
const queue = [...getChildFolderIds(parentId)];
while (queue.length) {
const current = queue.shift();
if (!current) continue;
descendants.push(current);
queue.push(...getChildFolderIds(current));
}
return descendants;
}
function nestFolder(childId: string, parentId: string) {
if (childId === parentId) return;
if (hasParentInChain(parentId, childId)) return;
const map = { ...getNestedFolderMap() };
delete map[childId];
map[childId] = parentId;
saveNestedFolderMap(map);
}
function unnestFolder(childId: string) {
const map = { ...getNestedFolderMap() };
if (map[childId] == null) return;
delete map[childId];
saveNestedFolderMap(map);
}
function hasParentInChain(childId: string, parentId: string): boolean {
const seen = new Set<string>();
let current = getParentFolderId(childId);
while (current != null && !seen.has(current)) {
if (current === parentId) return true;
seen.add(current);
current = getParentFolderId(current);
}
return false;
}
function areNestedRelated(firstId: string, secondId: string): boolean {
return hasParentInChain(firstId, secondId) || hasParentInChain(secondId, firstId);
}
export const settings = definePluginSettings({
sidebar: {
type: OptionType.BOOLEAN,
@ -132,8 +237,10 @@ export const settings = definePluginSettings({
{ label: "When more than one folder is expanded", value: FolderIconDisplay.MoreThanOneFolderExpanded }
],
restartNeeded: true
}
});
},
}).withPrivateSettings<{
nestedFolders: Record<string, string>;
}>();
const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!=='undefined'?isBetterFolders:arguments[0]?.isBetterFolders";
const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!=='undefined'?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds";
@ -146,6 +253,9 @@ export default definePlugin({
isModified: true,
settings,
start() {
settings.store.nestedFolders ??= {};
},
patches: [
{
@ -190,6 +300,14 @@ export default definePlugin({
}
]
},
{
find: '("guildsnav")',
predicate: () => !settings.store.sidebar,
replacement: {
match: /switch\((\i)\.type\){case \i\.\i\.FOLDER:.{0,800}?case \i\.\i\.GUILD:.{0,800}?default:return null}/,
replace: "return $self.wrapGuildNodeComponent($1,()=>{$&},false,void 0);"
}
},
{
// This is the parent folder component
find: ".toggleGuildFolderExpand(",
@ -248,6 +366,23 @@ export default definePlugin({
}
]
},
{
find: ".FOLDER_ITEM_ANIMATION_DURATION),",
replacement: [
{
match: /mentionCount:(\i),isMentionLowImportance:(\i)/,
replace: "mentionCount:$self.getFolderMentionCountWithNested(arguments[0]),isMentionLowImportance:$self.getFolderIsMentionLowImportanceWithNested(arguments[0],$2)"
},
{
match: /(\{id:\i,name:\i,children:\i\})=(\i),/,
replace: "$1=$self.getFolderNodeForRender($2),"
},
{
match: /(?<=gap:"xs",className:)(\i\.\i)/,
replace: "$self.getFolderGuildsListClassName(arguments[0],$1)"
}
]
},
{
find: "APPLICATION_LIBRARY,render:",
predicate: () => settings.store.sidebar,
@ -263,7 +398,7 @@ export default definePlugin({
replace: (m, conditions, props) => `${m},${conditions}$self.FolderSideBar(${props})`
},
{
// Add grid styles to fix aligment with other visual refresh elements
// Add grid styles to fix alignment with other visual refresh elements
match: /(?<=className:)\i\.\i(?=,"data-fullscreen")/,
replace: `"${GRID_STYLE_NAME} "+$&`
}
@ -277,6 +412,35 @@ export default definePlugin({
match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/,
replace: "$self.closeFolders();"
}
},
{
find: "[GuildDropTarget]",
all: true,
replacement: [
{
match: /(\i)=!(\i)&&null==(\i)\.parentId/,
replace: "$1=$self.shouldShowCombineTarget($2,$3)"
},
{
match: /canDrop:\i=>\i\.nodeId.{0,100}==\i\.parentId\)/,
replace: "canDrop:e=>$self.canDropOnFolder(e,arguments[1],arguments[3])"
},
{
match: /drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/,
replace: "$&if($self.handleFolderDrop(arguments[0],arguments[1],arguments[2],arguments[3]))return;"
},
{
match: /\[\i\.\i\.GUILD\](?=.{0,250}#{intl::DND_DROP_COMBINE})/,
replace: "[...$self.getFolderAcceptTypes(arguments[0]?.targetNode)]"
}
]
},
{
find: ".hasFetchedRequestToJoinGuilds)",
replacement: {
match: /return \i\.type!==\i\.\i\.GUILD/,
replace: "return $self.renderFolderChild(arguments[0],arguments[1],arguments[2])"
}
}
],
@ -308,11 +472,15 @@ export default definePlugin({
FluxDispatcher.wait(() => {
const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders();
const expandedId = data.folderId?.toString();
if (expandedFolders.size > 1) {
for (const id of expandedFolders) if (id !== data.folderId)
for (const id of expandedFolders) {
const folderId = id?.toString();
if (folderId === expandedId || areNestedRelated(folderId, expandedId)) continue;
FolderUtils.toggleGuildFolderExpand(id);
}
}
dispatchingFoldersClose = false;
});
@ -326,12 +494,219 @@ export default definePlugin({
FolderSideBar,
closeFolders,
getGuildMentionCount(guildId: string): number {
const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? [];
let count = 0;
for (const channelId of mentionChannelIds) {
const channel = ChannelStore.getChannel(channelId);
if (channel?.guild_id !== guildId) continue;
count += ReadStateStore.getMentionCount(channelId);
}
return count;
},
getFolderMentionMetaWithNested(props: FolderMentionProps | undefined): { mentionCount: number, hasNestedMention: boolean; } {
const folderNode = props?.folderNode;
const baseMentionCount = props?.mentionCount ?? 0;
if (folderNode?.id == null) {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
const descendantIds = getDescendantFolderIds(folderNode.id);
if (descendantIds.length === 0) {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
const tree = SortedGuildStore.getGuildsTree();
if (typeof tree?.getNode !== "function") {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
const nestedGuildIds = new Set<string>();
for (const childFolderId of descendantIds) {
const stack = [...(tree.getNode(childFolderId)?.children ?? [])];
while (stack.length) {
const node = stack.pop();
if (!node) continue;
if (node.type === "guild") {
nestedGuildIds.add(node.id?.toString());
continue;
}
if (node.type === "folder" && Array.isArray(node.children)) {
stack.push(...node.children);
}
}
}
if (nestedGuildIds.size === 0) {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
let nestedMentionCount = 0;
for (const guildId of nestedGuildIds) nestedMentionCount += this.getGuildMentionCount(guildId);
return {
mentionCount: baseMentionCount + nestedMentionCount,
hasNestedMention: nestedMentionCount > 0
};
},
getFolderMentionCountWithNested(props: FolderMentionProps | undefined): number {
return this.getFolderMentionMetaWithNested(props).mentionCount;
},
getFolderIsMentionLowImportanceWithNested(props: FolderMentionProps | undefined, originalFlag: boolean): boolean {
return this.getFolderMentionMetaWithNested(props).hasNestedMention ? false : originalFlag;
},
augmentFolderChildren(folderNode: GuildTreeNode, originalChildren: GuildTreeNode[]): GuildTreeNode[] {
const childIds = getChildFolderIds(folderNode.id);
if (childIds.length === 0) return originalChildren;
try {
const tree = SortedGuildStore.getGuildsTree();
if (typeof tree?.getNode !== "function") {
return originalChildren;
}
const existingIds = new Set(originalChildren.map(child => child?.id?.toString()));
const childFolderNodes: GuildTreeNode[] = [];
for (const id of childIds) {
if (existingIds.has(id?.toString())) continue;
const node = tree.getNode(id);
if (node) {
childFolderNodes.push({
...node,
parentId: folderNode.id,
isBetterFoldersNested: true
});
}
}
if (childFolderNodes.length === 0) return originalChildren;
return [...childFolderNodes, ...originalChildren];
} catch {
return originalChildren;
}
},
getFolderNodeForRender(folderNode: GuildTreeNode): GuildTreeNode {
const children = this.augmentFolderChildren(folderNode, folderNode.children ?? []);
if (children === folderNode.children) return folderNode;
return {
...folderNode,
children
};
},
getFolderGuildsListClassName(
props: FolderRenderProps | undefined,
baseClassName: string
): string {
if (props?.isBetterFolders) return baseClassName;
const folderNode = props?.folderNode;
if (folderNode == null) return baseClassName;
const children = this.getFolderNodeForRender(folderNode)?.children;
if (!Array.isArray(children)) return baseClassName;
return children.some(child => child?.type === "folder")
? `${baseClassName} vc-betterFolders-nested-parent-list`
: baseClassName;
},
renderFolderChild(node: GuildTreeNode, posInSet: number, setSize: number): ReactNode | null {
if (node?.type !== "folder") return null;
try {
if (!FolderItem) return null;
return (
<FolderItem
folderNode={node}
aria-setsize={setSize}
aria-posinset={posInSet}
/>
);
} catch {
return null;
}
},
handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean {
if (dragItem.type !== "folder") return false;
if (!isCombine) {
unnestFolder(dragItem.nodeId?.toString());
return false;
}
if (targetNode.type !== "folder") return false;
const childId = dragItem.nodeId?.toString();
const parentId = targetNode.id?.toString();
try {
nestFolder(childId, parentId);
FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" });
return true;
} catch {
return false;
}
},
shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean {
if (noCombine) return false;
return targetNode.parentId == null || targetNode.type === "folder";
},
getFolderAcceptTypes(targetNode: GuildTreeNode): string[] {
const GUILD = "guild";
const FOLDER = "folder";
if (targetNode?.type === FOLDER) return [GUILD, FOLDER];
return [GUILD];
},
canDropOnFolder(dragItem: FolderDragItem, targetNode: GuildTreeNode, isCombine: boolean): boolean {
if (dragItem.nodeId === targetNode.id) return false;
if (dragItem.type === "folder" && targetNode.type === "folder") {
if (isCombine) return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
return targetNode.parentId == null;
}
if (isCombine && dragItem.type === "folder") return false;
if (dragItem.type === "folder" && targetNode.parentId != null) return false;
return true;
},
wrapGuildNodeComponent(node: GuildTreeNode, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
if (node.type === "folder") {
const mappedParentId = getParentFolderId(node.id);
if (mappedParentId != null && (node.parentId?.toString() !== mappedParentId || node.isBetterFoldersNested !== true)) {
return (
<div style={{ display: "none" }}>
{originalComponent()}
</div>
);
}
}
wrapGuildNodeComponent(node: any, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
if (
!isBetterFolders ||
node.type === "folder" && expandedFolderIds?.has(node.id) ||
node.type === "guild" && expandedFolderIds?.has(node.parentId)
node.type === "guild" && node.parentId != null && expandedFolderIds?.has(node.parentId)
) {
return originalComponent();
}
@ -353,12 +728,10 @@ export default definePlugin({
// can cause hang if intl message is not found
const serversIntlMsg = getIntlMessage("SERVERS");
if (!serversIntlMsg) {
new Logger("BetterFolders").error("Failed to get SERVERS intl message");
return true;
}
return child?.props?.["aria-label"] === serversIntlMsg;
} catch (e) {
console.error(e);
} catch {
return true;
}
};
@ -372,14 +745,13 @@ export default definePlugin({
try {
return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null);
} catch (e) {
console.error(e);
} catch {
return true;
}
};
},
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
if (!isBetterFolders) {
return true;
}

View file

@ -2,6 +2,11 @@
grid-area: betterFoldersSidebar
}
.vc-betterFolders-nested-parent-list {
height: auto !important;
padding-bottom: 6px !important;
}
/* These area names need to be hardcoded. Only betterFoldersSidebar is added by the plugin. */
.vc-betterFolders-sidebar-grid {
/* stylelint-disable-next-line value-keyword-case */

View file

@ -137,6 +137,28 @@ export default definePlugin({
],
predicate: () => settings.store.disableFade
},
{ // Disable fade animations for settings menu
find: '"data-mana-component":"layer-modal"',
replacement: [
{
match: /(\i)\.animated\.div(?=,\{"data-mana-component":"layer-modal")/,
replace: '"div"'
},
{
match: /(?<="data-mana-component":"layer-modal"[^}]*?)style:\i,/,
replace: "style:{},"
}
],
predicate: () => settings.store.disableFade
},
{ // Disable initial and exit delay for settings menu
find: "headerId:void 0,headerIdIsManaged:!1",
replacement: {
match: /let (\i)=300/,
replace: "let $1=0"
},
predicate: () => settings.store.disableFade
},
{ // Load menu TOC eagerly
find: "handleOpenSettingsContextMenu=",
replacement: {
@ -145,14 +167,12 @@ export default definePlugin({
},
predicate: () => settings.store.eagerLoad
},
{
// Settings cog context menu
{ // Settings cog context menu
find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}",
replacement: [
{
match: /=\[\];(\i)(?=\.forEach.{0,200}?"logout"===\i.{0,100}?(\i)\.get\(\i\))/,
replace: "=$self.wrapMap([]);$self.transformSettingsEntries($1,$2)",
predicate: () => settings.store.organizeMenu
},
{
match: /case \i\.\i\.DEVELOPER_OPTIONS:return \i;/,
@ -181,20 +201,7 @@ export default definePlugin({
},
transformSettingsEntries(list: SettingsEntry[], keyMap: Map<string, string>) {
const items = [] as TransformedSettingsEntry[];
for (const item of list) {
if (!item.label || item.predicate != null && !item.predicate()) continue;
if (item.section === "HEADER") {
keyMap.set(item.label, item.label);
items.push({ section: item.label, items: [] });
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
items.at(-1)?.items.push(item);
}
}
keyMap.set("Equicord", "Equicord");
const items: any[] = [];
const equicordItems: SettingsEntry[] = [
{ section: "EquicordSettings", label: "Settings" },
{ section: "EquicordPlugins", label: "Plugins" },
@ -211,15 +218,32 @@ export default definePlugin({
if (entry) equicordItems.push({ section, label: entry.title });
}
if (settings.store.organizeMenu) {
for (const item of list) {
if (!item.label || item.predicate != null && !item.predicate()) continue;
if (item.section === "HEADER") {
keyMap.set(item.label, item.label);
items.push({ section: item.label, items: [] });
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
items.at(-1)?.items.push(item);
}
}
keyMap.set("Equicord", "Equicord");
items.push({
section: "Equicord",
items: equicordItems
});
} else {
items.push(...list, ...equicordItems);
}
return items;
},
wrapMap(toWrap: TransformedSettingsEntry[]) {
if (!settings.store.organizeMenu) return toWrap;
// @ts-expect-error
toWrap.map = function (render: (item: SettingsEntry) => ReactElement<any>) {
return this

View file

@ -99,7 +99,7 @@ export default definePlugin({
...[
'"Message Username"', // Messages
"#{intl::COLLECTIBLES_NAMEPLATE_PREVIEW_A11Y}", // Nameplate preview
"#{intl::ayozFl::raw}", // Avatar preview
"#{intl::COLLECTIBLES_PROFILE_PREVIEW_A11Y}", // Avatar preview
].map(find => ({
find,
replacement: {

View file

@ -0,0 +1,170 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { Heading } from "@components/Heading";
import type { IPluginOptionComponentProps } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { TextInput, useState } from "@webpack/common";
import { settings } from ".";
type EmojiData = {
id?: string | null;
name: string;
};
type EmojiSelectPayload = {
id: string | null;
name: string;
animated?: boolean;
};
const StatusEmojiPicker = findComponentByCodeLazy("setCustomStatusEmoji", "setIsEmojiPickerOpen", "selectedDefaultStatus");
export const MAX_ADDITIONAL_REACT_EMOJIS = 8;
function getEmojiValue(emoji: EmojiData) {
return emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
}
function parseEmojiValue(value: string): EmojiSelectPayload | null {
const trimmed = value.trim();
if (!trimmed) return null;
const customEmoji = trimmed.match(/^:?([\w-]+):(\d+)$/);
if (customEmoji) {
return {
id: customEmoji[2],
name: customEmoji[1],
animated: false
};
}
return {
id: null,
name: trimmed,
animated: false
};
}
function parseEmojiList(value: string) {
return Array.from(new Set(
value
.split(/[\n,]/g)
.map(entry => entry.trim())
.filter(Boolean)
)).slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
}
export function ReactEmojiSetting({ setValue }: IPluginOptionComponentProps) {
const [emoji, setEmoji] = useState(settings.store.reactEmoji ?? "💀");
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
return (
<div>
<Heading>Select Emoji For Reactions</Heading>
<div
className="vc-message-click-actions-primary-emoji-picker"
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
>
<StatusEmojiPicker
customStatusEmoji={parseEmojiValue(emoji)}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
if (!selectedEmoji) {
setEmoji("");
setValue("");
setIsEmojiPickerOpen(false);
return;
}
const value = getEmojiValue(selectedEmoji);
setEmoji(value);
setValue(value);
setIsEmojiPickerOpen(false);
}}
selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
setIsEmojiPickerOpen={setIsEmojiPickerOpen}
defaultStatusVariant="small"
/>
</div>
</div>
);
}
export function AdditionalReactEmojisSetting({ setValue }: IPluginOptionComponentProps) {
const { addAdditionalReacts } = settings.use(["addAdditionalReacts"]);
const [emojiList, setEmojiList] = useState(settings.store.additionalReactEmojis ?? "");
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [pickerEmoji, setPickerEmoji] = useState<EmojiSelectPayload | null>(parseEmojiValue("😀"));
if (!addAdditionalReacts) return null;
return (
<div>
<Heading>Select Additional Emojis</Heading>
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 12,
maxWidth: 420
}}
>
<TextInput
value={emojiList}
placeholder={`comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS}`}
onChange={newValue => {
setEmojiList(newValue);
setValue(newValue);
const [first] = parseEmojiList(newValue);
setPickerEmoji(parseEmojiValue(first ?? "😀"));
}}
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
flex: 1
}}
/>
<div
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
display: "inline-flex",
transform: "scale(1.2)",
transformOrigin: "center"
}}
>
<StatusEmojiPicker
customStatusEmoji={pickerEmoji}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
if (!selectedEmoji) {
setIsEmojiPickerOpen(false);
return;
}
const value = getEmojiValue(selectedEmoji);
const parsed = parseEmojiList(emojiList);
const merged = parsed.includes(value)
? parsed
: [...parsed, value].slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
const nextValue = merged.join(", ");
setEmojiList(nextValue);
setValue(nextValue);
setPickerEmoji(selectedEmoji);
setIsEmojiPickerOpen(false);
}}
selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
setIsEmojiPickerOpen={setIsEmojiPickerOpen}
defaultStatusVariant="small"
/>
</div>
</div>
</div>
);
}

View file

@ -15,9 +15,14 @@ import type { Channel, Message } from "@vencord/discord-types";
import { ApplicationIntegrationType, MessageFlags } from "@vencord/discord-types/enums";
import { AuthenticationStore, Constants, EditMessageStore, FluxDispatcher, MessageActions, MessageTypeSets, PermissionsBits, PermissionStore, PinActions, RestAPI, Toasts, WindowStore } from "@webpack/common";
import { AdditionalReactEmojisSetting, MAX_ADDITIONAL_REACT_EMOJIS, ReactEmojiSetting } from "./ReactEmojiSetting";
type Modifier = "NONE" | "SHIFT" | "CTRL" | "ALT" | "BACKSPACE" | "DELETE";
type ClickAction = "NONE" | "DELETE" | "COPY_LINK" | "COPY_ID" | "COPY_CONTENT" | "COPY_USER_ID" | "EDIT" | "REPLY" | "REACT" | "OPEN_THREAD" | "OPEN_TAB" | "EDIT_REPLY" | "QUOTE" | "PIN";
const logger = new Logger("MessageClickActions");
const ADDITIONAL_REACTION_DELAY_MS = 300; // discord seems to rate limit this for 300ms but that might not be constant
const actions: { label: string; value: ClickAction; }[] = [
{ label: "None", value: "NONE" },
{ label: "Delete", value: "DELETE" },
@ -130,12 +135,7 @@ let mouseDownCount = 0;
let doubleClickDetected = false;
let secondMouseDownTime = 0;
const settings = definePluginSettings({
reactEmoji: {
type: OptionType.STRING,
description: "Emoji to use for react actions",
default: "💀"
},
export const settings = definePluginSettings({
singleClickAction: {
type: OptionType.SELECT,
description: "Action on single click (your messages)",
@ -190,6 +190,26 @@ const settings = definePluginSettings({
options: modifiers,
default: "NONE"
},
reactEmoji: {
type: OptionType.COMPONENT,
description: "Emoji to use for react actions.",
component: ReactEmojiSetting,
default: "💀"
},
addAdditionalReacts: {
type: OptionType.BOOLEAN,
description: "Also add additional configured reaction emojis",
default: false
},
additionalReactEmojis: {
type: OptionType.COMPONENT,
description: `Additional emojis to add when using React action (comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS})`,
component: AdditionalReactEmojisSetting,
get hidden() {
return !settings.store.addAdditionalReacts;
},
default: ""
},
disableInDms: {
type: OptionType.BOOLEAN,
description: "Disable all click actions in direct messages",
@ -215,7 +235,7 @@ const settings = definePluginSettings({
deferDoubleClickForTriple: {
type: OptionType.BOOLEAN,
description: "Delay double-click to allow triple-click actions (disables triple-click when off)",
default: true
default: false
},
selectionHoldTimeout: {
type: OptionType.NUMBER,
@ -246,6 +266,51 @@ function showWarning(message: string) {
});
}
function clearClickTimeouts() {
if (doubleClickTimeout) {
clearTimeout(doubleClickTimeout);
doubleClickTimeout = null;
}
if (singleClickTimeout) {
clearTimeout(singleClickTimeout);
singleClickTimeout = null;
}
}
function resetClickState() {
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
}
function normalizeEmoji(emoji: string): string | null {
const trimmed = emoji.trim();
if (!trimmed) return null;
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
if (customMatch) {
return `${customMatch[1]}:${customMatch[2]}`;
}
return trimmed;
}
function getConfiguredReactionEmojis() {
const baseEmoji = normalizeEmoji(settings.store.reactEmoji);
const configured = [baseEmoji];
if (settings.store.addAdditionalReacts) {
const extra = settings.store.additionalReactEmojis
.split(/[\n,]/g)
.map(normalizeEmoji)
.filter((emoji): emoji is string => Boolean(emoji))
.slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
configured.push(...extra);
}
return Array.from(new Set(configured.filter((emoji): emoji is string => Boolean(emoji))));
}
const canSend = (channel: Channel) =>
!channel.guild_id || PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel);
@ -260,19 +325,14 @@ const canReply = (msg: Message) =>
MessageTypeSets.REPLYABLE.has(msg.type) && !msg.hasFlag(MessageFlags.EPHEMERAL);
async function toggleReaction(channelId: string, messageId: string, emoji: string, channel: Channel, msg: Message) {
const trimmed = emoji.trim();
if (!trimmed) return;
const emojiParam = normalizeEmoji(emoji);
if (!emojiParam) return;
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
showWarning("Cannot react: Missing permissions");
return;
}
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
const emojiParam = customMatch
? `${customMatch[1]}:${customMatch[2]}`
: trimmed;
const hasReacted = msg.reactions?.some(r => {
const reactionEmoji = r.emoji.id
? `${r.emoji.name}:${r.emoji.id}`
@ -291,12 +351,47 @@ async function toggleReaction(channelId: string, messageId: string, emoji: strin
});
}
} catch (e) {
new Logger("MessageClickActions").error("Failed to toggle reaction:", e);
logger.error("Failed to toggle reaction:", e);
}
}
async function addReaction(channelId: string, messageId: string, emoji: string, channel: Channel) {
const emojiParam = normalizeEmoji(emoji);
if (!emojiParam) return;
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
showWarning("Cannot react: Missing permissions");
return;
}
try {
await RestAPI.put({
url: Constants.Endpoints.REACTION(channelId, messageId, emojiParam, "@me")
});
} catch (e) {
logger.error("Failed to add reaction:", e);
}
}
async function reactWithConfiguredEmojis(channel: Channel, msg: Message) {
const [primaryEmoji, ...additionalEmojis] = getConfiguredReactionEmojis();
if (!primaryEmoji) return;
await toggleReaction(channel.id, msg.id, primaryEmoji, channel, msg);
for (const emoji of additionalEmojis) {
await new Promise<void>(resolve => setTimeout(resolve, ADDITIONAL_REACTION_DELAY_MS));
await addReaction(channel.id, msg.id, emoji, channel);
}
}
function getMessageLink(msg: Message, channel: Channel) {
const guildId = channel.guild_id ?? "@me";
return `${window.location.origin}/channels/${guildId}/${channel.id}/${msg.id}`;
}
function copyLink(msg: Message, channel: Channel) {
copyWithToast(`https://discord.com/channels/${channel.guild_id ?? "@me"}/${channel.id}/${msg.id}`, "Link copied!");
copyWithToast(getMessageLink(msg, channel), "Link copied!");
}
function togglePin(channel: Channel, msg: Message) {
@ -343,9 +438,7 @@ function quoteMessage(channel: Channel, msg: Message) {
}
function openInNewTab(msg: Message, channel: Channel) {
const guildId = channel.guild_id ?? "@me";
const link = `https://discord.com/channels/${guildId}/${channel.id}/${msg.id}`;
VencordNative.native.openExternal(link);
VencordNative.native.openExternal(getMessageLink(msg, channel));
}
function openInThread(msg: Message, channel: Channel) {
@ -462,7 +555,7 @@ async function executeAction(
break;
case "REACT":
await toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg);
await reactWithConfiguredEmojis(channel, msg);
event.preventDefault();
break;
@ -484,7 +577,7 @@ async function executeAction(
export default definePlugin({
name: "MessageClickActions",
description: "Customize click actions on messages.",
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite],
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite, EquicordDevs.omaw],
isModified: true,
settings,
@ -502,18 +595,9 @@ export default definePlugin({
document.removeEventListener("mousedown", onMouseDown);
WindowStore.removeChangeListener(focusChanged);
if (doubleClickTimeout) {
clearTimeout(doubleClickTimeout);
doubleClickTimeout = null;
}
if (singleClickTimeout) {
clearTimeout(singleClickTimeout);
singleClickTimeout = null;
}
clearClickTimeouts();
pendingDoubleClickAction = null;
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
},
onMessageClick(msg, channel, event) {
@ -547,9 +631,7 @@ export default definePlugin({
if (Date.now() - lastMouseDownTime > settings.store.selectionHoldTimeout) {
pressedModifiers.clear();
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
@ -560,9 +642,7 @@ export default definePlugin({
if (isTripleClick) {
if (!settings.store.deferDoubleClickForTriple) {
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
if (doubleClickTimeout) {
@ -576,9 +656,7 @@ export default definePlugin({
pressedModifiers.clear();
}
doubleClickFired = false;
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
@ -628,9 +706,7 @@ export default definePlugin({
event.preventDefault();
}
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
@ -642,9 +718,7 @@ export default definePlugin({
executeAction(singleClickAction, msg, channel, event);
pressedModifiers.clear();
}
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
};
const canDoubleClickWithCurrentModifier =

View file

@ -0,0 +1,7 @@
.vc-message-click-actions-primary-emoji-picker {
margin-top: 8px;
margin-left: 6px;
display: inline-flex;
transform: scale(1.3);
transform-origin: top left;
}

View file

@ -20,7 +20,7 @@ import { classNameFactory, ModalCloseButton, ModalContent, ModalFooter, ModalHea
import definePlugin, { OptionType } from "@utils/types";
import { GuildMember, Message, User } from "@vencord/discord-types";
import { findByCodeLazy, findStoreLazy } from "@webpack";
import { ChannelStore, GuildMemberStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
import { ChannelStore, GuildMemberStore, GuildRoleStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
import { JSX } from "react";
const SMYNC = classNameFactory();
@ -112,9 +112,16 @@ function resolveColor(
savedColor: string,
canUseGradient: boolean,
inGuild: boolean,
forceDefault: boolean
ircColorsEnabled: boolean,
isHovering: boolean,
): Record<string, any> | null {
if (!savedColor.trim()) { return null; }
const defaultColor = getComputedStyle(document.documentElement).getPropertyValue("--text-strong").trim() || null;
if (!defaultColor) { return null; }
savedColor = savedColor.trim() || defaultColor;
const isRoleColor = savedColor.toLowerCase().includes("role");
const forceDefault = !inGuild && !ircColorsEnabled && (isRoleColor ? !isHovering : false);
let gradient: any = null;
let primaryColor: any = null;
@ -123,9 +130,8 @@ function resolveColor(
let primaryAdjusted: any = null;
let secondaryAdjusted: any = null;
let tertiaryAdjusted: any = null;
const defaultColor = toCSS("var(--text-strong)");
if (savedColor.toLowerCase().includes("role")) {
if (isRoleColor) {
const percentage = roleColorPattern.exec(savedColor)?.[1] || "";
if (percentage && isNaN(parseInt(percentage))) return null;
@ -474,7 +480,9 @@ function renderUsername(
const ircColorsEnabled = isPluginEnabled(ircColors.name);
const authorColorStrings = colorStrings || (author as any)?.colorStrings || null;
const authorTopRole = (author as GuildMember)?.guildId && (author as any)?.highestRoleId;
const authorTopRoleColor = authorTopRole && GuildRoleStore.getRole((author as GuildMember).guildId, authorTopRole)?.colorStrings;
const authorColorStrings = colorStrings || (author as any)?.colorStrings || authorTopRoleColor || null;
const authorDisplayNameStyles = (!inGuild && !ircColorsEnabled && (author as any)?.displayNameStyles) || null;
const effectType = authorDisplayNameStyles ? getEffectType(authorDisplayNameStyles.effectId) : null;
const effectCSSVars = authorDisplayNameStyles ? computeEffectCSSVars(authorDisplayNameStyles) : {};
@ -482,20 +490,19 @@ function renderUsername(
const needsEffectDataAttr = effectType === "neon" || effectType === "toon" || effectType === "pop";
const shouldShowEffect = hasEffect && isHovering;
const shouldAnimateEffect = shouldShowEffect && !AccessibilityStore.useReducedMotion;
const shouldUseDMDefault = !inGuild && !ircColorsEnabled && !isHovering;
const canUseGradient = ((author as GuildMember)?.guildId ? (GuildStore.getGuild((author as GuildMember).guildId) ?? {}).premiumFeatures?.features.includes("ENHANCED_ROLE_COLORS") : !inGuild);
const useTopRoleStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topRoleStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, shouldUseDMDefault) : null;
const hasGradient = !!topRoleStyle?.gradient && Object.keys(topRoleStyle.gradient).length > 0;
const useTopStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const hasGradient = !!topStyle?.gradient && Object.keys(topStyle.gradient).length > 0;
const textMutedValue = getComputedStyle(document.documentElement)?.getPropertyValue("--text-muted")?.trim() || "#72767d";
const options = splitTemplate(includedNames);
const resolvedUsernameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, usernameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedDisplayNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, displayNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedNicknameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, nicknameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedFriendNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, friendNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedCustomNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, customNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedUsernameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, usernameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedDisplayNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, displayNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedNicknameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, nicknameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedFriendNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, friendNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedCustomNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, customNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const affixColor = { color: textMutedValue, "-webkit-text-fill-color": textMutedValue, isolation: "isolate", "white-space": "pre", "font-family": "var(--font-primary)", "letter-spacing": "normal" };
const { username, display, nick, friend, custom } = getProcessedNames(author, truncateAllNamesWithStreamerMode, discriminators, inGuild, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages);
@ -642,7 +649,7 @@ function renderUsername(
// Only mentions and reactions popouts should patch in the gradient glow or else a double glow will appear on messages.
const hoveringClass = (isHovering ? " smyn-gradient-hovered" : "");
const gradientClasses = useTopRoleStyle
const gradientClasses = useTopStyle
? "smyn-gradient smyn-gradient-inherit-bg" + hoveringClass
: "smyn-gradient smyn-gradient-unset-bg" + hoveringClass;
@ -674,7 +681,7 @@ function renderUsername(
<span
style={{
...topLevelStyle,
...(topRoleStyle?.normal.original || {})
...(topStyle?.normal.original || {})
}}
className="smyn-container"
>
@ -683,7 +690,7 @@ function renderUsername(
<span
className={SMYNC(firstGroupClasses, { [gradientClasses]: shouldGradientGlow })}
data-text={shouldGradientGlow ? firstDataText : undefined}
style={(shouldGradientGlow && useTopRoleStyle && topRoleStyle ? topRoleStyle.gradient.animated : undefined) as React.CSSProperties}
style={(shouldGradientGlow && useTopStyle && topStyle ? topStyle.gradient.animated : undefined) as React.CSSProperties}
>
<span
className={SMYNC(firstNameClasses, {
@ -694,12 +701,12 @@ function renderUsername(
data-username-with-effects={needsEffectDataAttr && shouldShowEffect ? first.name : undefined}
style={shouldShowEffect
? undefined
: topRoleStyle ?
shouldAnimateGradients && topRoleStyle.gradient
? topRoleStyle.gradient.animated
: topRoleStyle.gradient
? topRoleStyle.gradient.static.original
: topRoleStyle.normal.original
: topStyle ?
shouldAnimateGradients && topStyle.gradient
? topStyle.gradient.animated
: topStyle.gradient
? topStyle.gradient.static.original
: topStyle.normal.original
: undefined
}>
{first.wrapped}</span>
@ -994,7 +1001,7 @@ const settings = definePluginSettings({
ignoreGradients: {
type: OptionType.BOOLEAN,
default: true,
description: "For the non-primary names, if the role has a gradient or nitro effect, ignore it in favor of the color set below.",
description: "For the non-primary names, if the role has a gradient and the color below is set to \"Role+-#\", use the primary color instead of the whole gradient, and if it has a nitro effect, ignore it entirely."
},
animateGradients: {
type: OptionType.BOOLEAN,
@ -1024,31 +1031,31 @@ const settings = definePluginSettings({
},
customNameColor: {
type: OptionType.STRING,
description: "The color to use for the custom name you assigned a user if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the custom name you assigned a user if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
friendNameColor: {
type: OptionType.STRING,
description: "The color to use for a friend's nickname if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for a friend's nickname if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
nicknameColor: {
type: OptionType.STRING,
description: "The color to use for the nickname if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the nickname if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
displayNameColor: {
type: OptionType.STRING,
description: "The color to use for the display name if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the display name if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
usernameColor: {
type: OptionType.STRING,
description: "The color to use for the username if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the username if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},

View file

@ -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 {

View file

@ -37,7 +37,7 @@ function toggleStreamerMode({ streamKey }: StreamEvent, value: boolean) {
export default definePlugin({
name: "StreamerModeOn",
description: "Automatically enables streamer mode when you start streaming in Discord",
authors: [Devs.Kodarru],
authors: [Devs.IcedMarina],
flux: {
STREAM_CREATE: d => toggleStreamerMode(d, true),
STREAM_DELETE: d => toggleStreamerMode(d, false)

View file

@ -225,6 +225,7 @@ export const DeeplLanguages = {
"it": "Italian",
"ja": "Japanese",
"jv": "Javanese",
"ko": "Korean",
"pam": "Kapampangan",
"kk": "Kazakh",
"kmr": "Kurdish (Kurmanji)",

View file

@ -15,7 +15,7 @@ export default definePlugin({
authors: [Devs.puv],
patches: [
{
find: "#{intl::LgCPMt::raw}",
find: "#{intl::VOICE_MESSAGES_PLAYBACK_RATE_LABEL}",
replacement: {
match: /(?<=onVolumeHide:\i\}\))/,
replace: ",$self.renderDownload(arguments[0].src)"

View file

@ -55,16 +55,21 @@ export default definePlugin({
patches: [
// Change the max volume for sliders to allow for values above 200
...[
"#{intl::USER_VOLUME}",
"currentVolume:"
].map(find => ({
find,
{
find: "#{intl::USER_VOLUME}",
replacement: {
match: /(?<=maxValue:)\i\.\i\?(\i\.\i|\d+?):(\i\.\i|\d+?)(?=,)/,
replace: (_, higherMaxVolume, minorMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
match: /(?<=maxValue:)\i\.isPlatformEmbedded\?(\i\.\i):\i\.\i(?=,)/,
replace: (_, higherMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
}
})),
},
// Change the max volume for sliders to allow for values above 200
{
find: "currentVolume:",
replacement: {
match: /(?<=maxValue:)\i\.\i\?(\d+?):\d+?(?=,)/,
replace: (_, higherMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
}
},
// Patches needed for web/vesktop
{
find: "streamSourceNode",

View file

@ -466,7 +466,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
nin0dev: {
name: "nin0dev",
id: 886685857560539176n
id: 1395533040914141235n
},
Elvyra: {
name: "Elvyra",
@ -496,9 +496,9 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "puv",
id: 469441552251355137n
},
Kodarru: {
name: "Kodarru",
id: 785227396218748949n
IcedMarina: {
name: "icedmarina",
id: 594406131670188042n
},
nakoyasha: {
name: "nakoyasha",
@ -1255,7 +1255,11 @@ export const EquicordDevs = Object.freeze({
davri: {
name: "Davri",
id: 457579346282938368n,
}
},
yash: {
name: "yash",
id: 889150838658977874n,
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly

View file

@ -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;
/**

View file

@ -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 === [", {
@ -250,3 +250,7 @@ export const MessageTypeSets: t.MessageTypeSets = findByPropsLazy("REPLYABLE", "
export const fetchApplicationsRPC = findByCodeLazy('"Invalid Origin"', ".application");
export const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof t.CloudUpload;
export const URLUtils: t.URLUtils = findByPropsLazy("URL_REGEX", "makeUrl", "isDiscordUrl");
export const Humanize: t.Humanize = findByPropsLazy("filesize", "relativeTime", "ordinal");
export const EmojiUtils: t.EmojiUtils = findByPropsLazy("getEmojiColors", "getURL");