BetterForwards overhaul (#1184)
* BetterForwards: overhaul * BetterForwards: fix formatting * BetterForwards: fix embed counter * Update src/equicordplugins/betterForwards/components.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * BetterForwards: reorder imports * BetterForwards: non-null assertion * BetterForwards: fix footer rendering * BetterForwards: Improve forward UI * BetterForwards: Various bug fixes * BetterForwards: Minor changes * BetterForwards: Minor changes * BetterForwards: Improved message formatting * BetterForwards: nah actually delete the readme, no one reads this anyway * BetterForwards: Fix styles * Update components.tsx * BetterForwards: Errorboundary * BetterForwards: Another errorboundary --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
665e43f048
commit
e3277bfea2
6 changed files with 597 additions and 136 deletions
|
|
@ -1,13 +0,0 @@
|
|||
# Better Forwards
|
||||
|
||||
# Dont Follow Forwards
|
||||
|
||||
Don't follow forwarded messages after sending them.
|
||||
|
||||
## Before
|
||||
|
||||

|
||||
|
||||
## After
|
||||
|
||||

|
||||
247
src/equicordplugins/betterForwards/components.tsx
Normal file
247
src/equicordplugins/betterForwards/components.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
* 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 { Flex, FlexProps } from "@components/Flex";
|
||||
import { RightArrow } from "@components/Icons";
|
||||
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
|
||||
import { MessageAttachment } from "@vencord/discord-types";
|
||||
import { ChannelType } from "@vencord/discord-types/enums";
|
||||
import { findByCodeLazy, findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
|
||||
import { ChannelStore, DateUtils, GuildStore, IconUtils, match, NavigationRouter, Popout, React, SelectedGuildStore, SnowflakeUtils, useMemo, useRef, UserStore, useStateFromStores } from "@webpack/common";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
import { cl, ForwardOptionsContext, ForwardOptionsState } from ".";
|
||||
|
||||
type AttachmentType = "IMAGE" | "VIDEO" | "CLIP" | "AUDIO" | "VISUAL_PLACEHOLDER" | "PLAINTEXT_PREVIEW" | "OTHER" | "INVALID";
|
||||
|
||||
const tagClasses = findCssClassesLazy("tagList", "tagGroup", "tag");
|
||||
const ServerProfileComponent = findComponentByCodeLazy("{guildProfile:", "GUILD_PROFILE");
|
||||
const getAttachmentType: (attachment: MessageAttachment, inlineAttachmentMedia?: boolean) => AttachmentType = findByCodeLazy('"PLAINTEXT_PREVIEW":"OTHER"');
|
||||
|
||||
export function GuildName({ guildId }: { guildId: string; }) {
|
||||
const guild = useStateFromStores(
|
||||
[GuildStore, SelectedGuildStore],
|
||||
() => {
|
||||
const current = SelectedGuildStore.getGuildId();
|
||||
return current !== guildId ? GuildStore.getGuild(guildId) : null;
|
||||
},
|
||||
[guildId]
|
||||
);
|
||||
const icon = useMemo(() => {
|
||||
if (!guild?.icon) return null;
|
||||
return IconUtils.getGuildIconURL({ id: guildId, icon: guild.icon, canAnimate: true, size: 16 });
|
||||
}, [guildId, guild?.icon]);
|
||||
const guildDivRef = useRef(null);
|
||||
|
||||
return (
|
||||
guild && (
|
||||
<Popout
|
||||
position="top"
|
||||
renderPopout={() => <ServerProfileComponent guildId={guildId} />}
|
||||
targetElementRef={guildDivRef}
|
||||
>
|
||||
{popoutProps => (
|
||||
<div ref={guildDivRef} className={cl("footer-element")} {...popoutProps}>
|
||||
{icon && <img src={icon} alt={`Server icon for ${guild.name}`} className={cl("guild-icon")} />}
|
||||
<BaseText size="sm" weight="medium" className={cl("footer-text")}>
|
||||
{guild ? guild.name : "View server"}
|
||||
</BaseText>
|
||||
<RightArrow width={12} height={12} fill="var(--text-muted)" />
|
||||
</div>
|
||||
)}
|
||||
</Popout>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelName({ guildId, channelId, messageId }: { guildId?: string; channelId: string; messageId: string; }) {
|
||||
const name = useStateFromStores(
|
||||
[ChannelStore, UserStore],
|
||||
() => {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel) return null;
|
||||
|
||||
return match(channel.type)
|
||||
.with(ChannelType.DM, () => {
|
||||
const user = UserStore.getUser(channel.recipients[0]);
|
||||
return `@${user.globalName || user.username}`;
|
||||
})
|
||||
.with(ChannelType.GROUP_DM, () => {
|
||||
if (channel.name) return channel.name;
|
||||
const users = channel.recipients.map(r => UserStore.getUser(r));
|
||||
return users.map(u => u.globalName || u.username).join(", ");
|
||||
})
|
||||
.with(
|
||||
ChannelType.ANNOUNCEMENT_THREAD,
|
||||
ChannelType.PRIVATE_THREAD,
|
||||
ChannelType.PUBLIC_THREAD,
|
||||
() => channel.name
|
||||
)
|
||||
.otherwise(() => `#${channel.name}`);
|
||||
},
|
||||
[channelId]
|
||||
);
|
||||
|
||||
return (
|
||||
name && (
|
||||
<div
|
||||
className={cl("footer-element")}
|
||||
onClick={() => NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/${messageId}`)}
|
||||
>
|
||||
<BaseText size="sm" weight="medium" className={cl("footer-text")}>
|
||||
{name}
|
||||
</BaseText>
|
||||
<RightArrow width={12} height={12} fill="var(--text-muted)" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function Timestamp({ snowflake }: { snowflake: string; }) {
|
||||
const formatted = useMemo(
|
||||
() => DateUtils.calendarFormat(new Date(SnowflakeUtils.extractTimestamp(snowflake))),
|
||||
[snowflake]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cl("footer-element")} style={{ pointerEvents: "none" }}>
|
||||
<BaseText size="sm" weight="medium" className={cl("footer-text")}>
|
||||
{formatted}
|
||||
</BaseText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForwardPicker() {
|
||||
const state = React.useContext(ForwardOptionsContext);
|
||||
|
||||
if (state.message.embeds.length + state.message.attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flex gap={12} flexDirection="column">
|
||||
{state.message.attachments.length > 0 && <AttachmentPicker {...state} />}
|
||||
{state.message.embeds.length > 0 && <EmbedPicker {...state} />}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbedPicker({ message, opts, setOpts, hasOpts, defaultOpts }: ForwardOptionsState) {
|
||||
const embeds = useMemo(() => {
|
||||
let id = 0;
|
||||
return message.embeds.map(({ rawTitle, rawDescription, image, images = image ? [image] : [], video }, i) => {
|
||||
const current = {
|
||||
title: rawTitle?.trim() || rawDescription?.trim() || `Embed ${i + 1}`,
|
||||
subEmbeds: [] as { id: number; name: string; isMainEmbed: boolean; }[]
|
||||
};
|
||||
|
||||
if (images.length > 0) {
|
||||
// The "main" embed is the first embed with the same url (in 99% cases), which is used for displaying embed metadata (title, description, etc).
|
||||
// It's only possible to tell it apart in the raw API message source since the client groups all related embeds together.
|
||||
current.subEmbeds = images.map((image, si) => ({
|
||||
id: id++,
|
||||
name: `${si === 0 ? "Embed + " : ""}Image ${images.length > 1 ? `${si + 1} ` : ""}(${image!.width} x ${image!.height})`,
|
||||
isMainEmbed: si === 0
|
||||
}));
|
||||
} else if (video) {
|
||||
current.subEmbeds = [{ id: id++, name: "Embed + Video", isMainEmbed: true }];
|
||||
} else {
|
||||
current.subEmbeds = [{ id: id++, name: "Embed", isMainEmbed: true }];
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
}, [message]);
|
||||
|
||||
const { EmbedIcon, ImageIcon } = iconsModule;
|
||||
|
||||
return embeds.map(({ title, subEmbeds }) => (
|
||||
<Flex gap={4} flexDirection="column" key={subEmbeds[0].id}>
|
||||
<BaseText
|
||||
size="sm"
|
||||
color="text-subtle"
|
||||
className={cl("embed-name")}
|
||||
style={{ opacity: !hasOpts ? 0.5 : undefined }}
|
||||
>
|
||||
{title}
|
||||
</BaseText>
|
||||
<TagContainer>
|
||||
{subEmbeds.map(({ id, name, isMainEmbed }) => {
|
||||
const Icon = isMainEmbed ? EmbedIcon : ImageIcon;
|
||||
return (
|
||||
<Tag
|
||||
key={id}
|
||||
id={id}
|
||||
source={hasOpts ? (opts.onlyEmbedIndices ?? []) : defaultOpts.onlyEmbedIndices}
|
||||
onChange={data => setOpts(prev => ({ ...prev, onlyEmbedIndices: data }))}
|
||||
disabled={!hasOpts}
|
||||
>
|
||||
{Icon && <Icon size="xs" style={{ flexShrink: 0 }} />}
|
||||
<BaseText size="sm">{name}</BaseText>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</TagContainer>
|
||||
</Flex>
|
||||
));
|
||||
}
|
||||
|
||||
export function AttachmentPicker({ message, opts, setOpts, hasOpts, defaultOpts }: ForwardOptionsState) {
|
||||
return (
|
||||
<TagContainer>
|
||||
{message.attachments.map(attachment => (
|
||||
<Tag
|
||||
key={attachment.id}
|
||||
id={attachment.id}
|
||||
source={hasOpts ? opts.onlyAttachmentIds ?? [] : defaultOpts.onlyAttachmentIds}
|
||||
onChange={data => setOpts(prev => ({ ...prev, onlyAttachmentIds: data }))}
|
||||
disabled={!hasOpts}
|
||||
>
|
||||
<AttachmentIcon attachment={attachment} />
|
||||
<BaseText size="sm">{attachment.filename}</BaseText>
|
||||
</Tag>
|
||||
))}
|
||||
</TagContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TagContainer(props: FlexProps) {
|
||||
return <Flex gap={8} flexWrap="wrap" className={tagClasses.tagGroup} data-layout="inline" {...props} />;
|
||||
}
|
||||
|
||||
function Tag<T>({ id, children, source, onChange, disabled }: { id: T; source: T[]; onChange: (data: T[]) => void; disabled?: boolean; } & PropsWithChildren) {
|
||||
const selected = useMemo(() => source.includes(id), [source, id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tagClasses.tag}
|
||||
data-selection-mode="multiple"
|
||||
data-selected={!disabled &&selected ? "true" : undefined}
|
||||
onClick={() => onChange(selected ? source.filter(x => x !== id) : [...source, id])}
|
||||
style={{ textWrap: "wrap", opacity: disabled ? .5 : undefined }}
|
||||
inert={disabled}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const icons: Partial<Record<AttachmentType, string>> = {
|
||||
IMAGE: "Image",
|
||||
VIDEO: "Video",
|
||||
CLIP: "Clips",
|
||||
AUDIO: "Music",
|
||||
PLAINTEXT_PREVIEW: "A"
|
||||
};
|
||||
|
||||
function AttachmentIcon({ attachment }: { attachment: MessageAttachment; }) {
|
||||
const Icon = useMemo(() => {
|
||||
const type = getAttachmentType(attachment, true);
|
||||
return iconsModule[(icons[type] ?? "ImageFile") + "Icon"];
|
||||
}, [attachment]);
|
||||
|
||||
return Icon && <Icon size="xs" style={{ flexShrink: 0 }} />;
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings, migratePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { sendMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Channel, Message } from "@vencord/discord-types";
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
let ignore = false;
|
||||
|
||||
// Taken From Signature :)
|
||||
const settings = definePluginSettings({
|
||||
forwardPreface: {
|
||||
description: "What should forwarded from be prefaced with",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: ">", value: ">", default: true },
|
||||
{ label: "-#", value: "-#" }
|
||||
]
|
||||
},
|
||||
dontFollowForwards: {
|
||||
description: "After forwarding a single message, don't jump to it. Hold shift to ignore this behavior",
|
||||
displayName: "Don't Follow Forwards",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
restartNeeded: true,
|
||||
}
|
||||
});
|
||||
|
||||
migratePluginSettings("BetterForwards", "ForwardAnywhere");
|
||||
export default definePlugin({
|
||||
name: "BetterForwards",
|
||||
description: "If a forward fails, send it as a normal message. Also allows nsfw forwards. See settings for various other improvements to forwarding.",
|
||||
tags: ["Chat", "Utility"],
|
||||
authors: [Devs.thororen, Devs.sadan],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::MESSAGE_FORWARDING_NSFW_NOT_ALLOWED}",
|
||||
replacement: {
|
||||
match: /(\{if\().{0,50}(\)return.{0,25}#{intl::MESSAGE_FORWARDING_NSFW_NOT_ALLOWED})/,
|
||||
replace: "$1false$2",
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "#{intl::MESSAGE_ACTION_FORWARD_TO}",
|
||||
replacement: {
|
||||
match: /(?<=let (\i)=.{0,25}rejected.{0,25}\);)(?=.{0,25}message:(\i))/,
|
||||
replace: "if ($1) return $self.sendForward($1,$2);",
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "#{intl::MESSAGE_FORWARD_MESSAGE_PLACEHOLDER}",
|
||||
predicate: () => settings.store.dontFollowForwards,
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=transitionToDestination:)(1===\i\.length)(?=,|\})/,
|
||||
replace: "$self.shouldTransition($1)"
|
||||
},
|
||||
{
|
||||
// there are two useCallbacks with clearDraft in this module
|
||||
// we need to anchor to the one that is used as an onClick handler
|
||||
match: /((\i)=\i\.useCallback\(\()(\)=>\{)(null!=\i&&\i\.\i\.clearDraft)(?=.{1500,2000}onClick:\2)/,
|
||||
replace: (_, beforeParen, _1, beforeBody, body) => `${beforeParen}vencordArg1${beforeBody}$self.setShift(vencordArg1);${body}`
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
sendForward(channels: Channel[], message: Message) {
|
||||
const chunkSize = 5;
|
||||
channels.forEach(c => {
|
||||
if (message.attachments.length) {
|
||||
for (let i = 0; i < message.attachments.length; i += chunkSize) {
|
||||
const group = message.attachments.slice(i, i + chunkSize);
|
||||
const text = `${message.content}\nAttachments:\n${group.map(a => a.url).join("\n")}\n${settings.store.forwardPreface} Forwarded from <#${message.channel_id}>`;
|
||||
sendMessage(c.id, { content: text });
|
||||
}
|
||||
} else {
|
||||
sendMessage(c.id, {
|
||||
content: `${message.content}\n${settings.store.forwardPreface} Forwarded from <#${message.channel_id}>`
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
shouldTransition(origCond: boolean): boolean {
|
||||
return ignore ? origCond : false;
|
||||
},
|
||||
|
||||
setShift(event: MouseEvent | undefined) {
|
||||
ignore = !!event?.shiftKey;
|
||||
}
|
||||
});
|
||||
297
src/equicordplugins/betterForwards/index.tsx
Normal file
297
src/equicordplugins/betterForwards/index.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings, migratePluginSettings } from "@api/Settings";
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { InfoIcon } from "@components/Icons";
|
||||
import { Margins } from "@components/margins";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { sendMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { proxyLazyWebpack } from "@webpack";
|
||||
import { ChannelActionCreators, ChannelStore, Checkbox, React, Tooltip, useMemo, useState } from "@webpack/common";
|
||||
import { Dispatch, MouseEvent, ReactNode, SetStateAction } from "react";
|
||||
|
||||
import { ChannelName, ForwardPicker, GuildName, Timestamp } from "./components";
|
||||
import managedStyle from "./style.css?managed";
|
||||
|
||||
export const cl = classNameFactory("vc-betterforwards-");
|
||||
|
||||
export interface ForwardOptions {
|
||||
onlyEmbedIndices?: number[];
|
||||
onlyAttachmentIds?: string[];
|
||||
}
|
||||
|
||||
export interface ForwardOptionsState {
|
||||
opts: ForwardOptions;
|
||||
setOpts: Dispatch<SetStateAction<ForwardOptions>>;
|
||||
defaultOpts: Required<ForwardOptions>;
|
||||
hasOpts: boolean;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export const ForwardOptionsContext = proxyLazyWebpack(() =>
|
||||
React.createContext<ForwardOptionsState & { message: Message }>({
|
||||
opts: {},
|
||||
setOpts: () => {},
|
||||
defaultOpts: { onlyAttachmentIds: [], onlyEmbedIndices: [] },
|
||||
hasOpts: false,
|
||||
message: {} as Message
|
||||
})
|
||||
);
|
||||
|
||||
let ignore = false;
|
||||
const getId = ({ id, type }: { id: string; type: string; }) => {
|
||||
if (type !== "user") return id;
|
||||
return (
|
||||
ChannelStore.getDMFromUserId(id) ??
|
||||
(ChannelActionCreators.getOrEnsurePrivateChannel(id) as Promise<string | void>)
|
||||
);
|
||||
};
|
||||
|
||||
// Taken From Signature :)
|
||||
const settings = definePluginSettings({
|
||||
resendOnFail: {
|
||||
description: "This will attempt to resend a forwarded message if the forward fails. Could cause unintentional pings or text spam. Bypasses NSFW restrictions.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
},
|
||||
forwardPreface: {
|
||||
description: "What should the forwarded message be prefaced with",
|
||||
type: OptionType.SELECT,
|
||||
hidden: () => !settings.store.resendOnFail,
|
||||
options: [
|
||||
{ label: ">", value: ">", default: true },
|
||||
{ label: "-#", value: "-#" }
|
||||
]
|
||||
},
|
||||
dontFollowForwards: {
|
||||
description: "After forwarding a single message, don't jump to it. Hold shift to ignore this behavior.",
|
||||
displayName: "Don't Follow Forwards",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
restartNeeded: true
|
||||
},
|
||||
selfForward: {
|
||||
description: "Show the current channel in the forward list popup.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
restartNeeded: true
|
||||
}
|
||||
});
|
||||
|
||||
migratePluginSettings("BetterForwards", "ForwardAnywhere");
|
||||
export default definePlugin({
|
||||
name: "BetterForwards",
|
||||
description: "Message forward utilities including NSFW bypass and UI improvements.",
|
||||
tags: ["Chat", "Utility"],
|
||||
searchTerms: ["selfForward", "betterForwardMeta"],
|
||||
authors: [Devs.thororen, Devs.sadan, Devs.nin0dev, EquicordDevs.VillainsRule, EquicordDevs.davri],
|
||||
settings,
|
||||
managedStyle,
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::MESSAGE_FORWARDING_NSFW_NOT_ALLOWED}",
|
||||
predicate: () => settings.store.resendOnFail,
|
||||
replacement: {
|
||||
match: /(\{if\().{0,50}(\)return.{0,25}#{intl::MESSAGE_FORWARDING_NSFW_NOT_ALLOWED})/,
|
||||
replace: "$1false$2"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "#{intl::MESSAGE_ACTION_FORWARD_TO}",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=hasContextMessage:null!=(\i)&&.{100,150}?let (\i)=.{0,25}rejected.{0,25}\);)(?=.{0,25}message:(\i))/,
|
||||
replace: (_, additionalMessage, channels, message) =>
|
||||
`if(${channels}.length>0)return await $self.sendForward(${additionalMessage},${channels},${message},__state.opts);`,
|
||||
predicate: () => settings.store.resendOnFail
|
||||
},
|
||||
{
|
||||
match: /(?<=source:\i,)\.\.\.(\i)\}=(\i),/,
|
||||
replace: "__state,...$1}=$self.useProps($2),"
|
||||
},
|
||||
{
|
||||
match: /\(0,\i\.jsx\)\(\i,\{message:\i,forwardOptions:\i,channel:\i\}\)/,
|
||||
replace: "$self.renderWrapper(__state,$&)"
|
||||
},
|
||||
{
|
||||
match: /(?<=#{intl::CHECKPOINT_2025}.{50,100}?)\i>0&&\(.{200,250}?\}\)\]\}\)/,
|
||||
replace: "$self.renderForwardPicker()"
|
||||
},
|
||||
{
|
||||
match: /(?<=transitionToDestination:)(1===\i\.length)(?=,|\})/,
|
||||
replace: "$self.shouldTransition($1)",
|
||||
predicate: () => settings.store.dontFollowForwards
|
||||
},
|
||||
{
|
||||
// there are two useCallbacks with clearDraft in this module
|
||||
// we need to anchor to the one that is used as an onClick handler
|
||||
match: /((\i)=\i\.useCallback\(\()(\)=>\{)(null!=\i&&\i\.\i\.clearDraft)(?=.{1500,2000}onClick:\2)/,
|
||||
replace: (_, beforeParen, _1, beforeBody, body) =>
|
||||
`${beforeParen}vencordArg1${beforeBody}$self.setShift(vencordArg1);${body}`,
|
||||
predicate: () => settings.store.dontFollowForwards
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: 'location:"ForwardFooter"',
|
||||
replacement: {
|
||||
match: /let{message:\i,snapshot:\i,index:\i}=(\i)/,
|
||||
replace: "return $self.renderForwardFooter($1);$&"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: ".getChannelHistory(),",
|
||||
predicate: () => settings.store.selfForward,
|
||||
replacement: {
|
||||
match: /\i.id\]/,
|
||||
replace: "]"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
async sendForward(additionalMessage: string | null, channels: { id: string; type: string }[], message: Message, options: ForwardOptions) {
|
||||
const contentMessage = message.messageSnapshots[0]?.message ?? message;
|
||||
|
||||
const newLine = `\n${settings.store.forwardPreface} `;
|
||||
const prefix = `${newLine}*Forwarded from <#${message.channel_id}>*${newLine}${contentMessage.content.trim().replaceAll("\n", newLine)}`;
|
||||
const suffix = additionalMessage ? `\n${additionalMessage.trim()}` : "";
|
||||
|
||||
const attIds = options.onlyAttachmentIds;
|
||||
const attachments = attIds
|
||||
? contentMessage.attachments.filter(a => attIds.includes(a.id))
|
||||
: contentMessage.attachments;
|
||||
|
||||
const ids = (await Promise.all(channels.map(getId))).filter(Boolean) as string[];
|
||||
|
||||
const chunkSize = 5;
|
||||
ids.forEach(id => {
|
||||
if (attachments.length > 0) {
|
||||
for (let i = 0; i < attachments.length; i += chunkSize) {
|
||||
const group = attachments.slice(i, i + chunkSize);
|
||||
|
||||
let text = i === 0 ? `${prefix}${newLine}Attachments:${newLine}` : newLine;
|
||||
text += `${group.map(a => a.url).join(newLine)}`;
|
||||
if (i + chunkSize >= attachments.length) text += suffix;
|
||||
|
||||
sendMessage(id, { content: text });
|
||||
}
|
||||
} else {
|
||||
sendMessage(id, { content: prefix + suffix });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
shouldTransition(origCond: boolean): boolean {
|
||||
return ignore ? origCond : false;
|
||||
},
|
||||
|
||||
setShift(event: MouseEvent | undefined) {
|
||||
ignore = !!event?.shiftKey;
|
||||
},
|
||||
|
||||
renderForwardFooter({ message }: { message: Message }) {
|
||||
if (!message.messageReference) return null;
|
||||
|
||||
const { guild_id, channel_id, message_id } = message.messageReference;
|
||||
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<div className={cl("footer")}>
|
||||
{guild_id && <GuildName guildId={guild_id} />}
|
||||
<ChannelName messageId={message_id} channelId={channel_id} guildId={guild_id} />
|
||||
<Timestamp snowflake={message_id} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
|
||||
useProps(props: { message: Message; forwardOptions?: ForwardOptions }) {
|
||||
const message = props.message.messageSnapshots[0]?.message ?? props.message;
|
||||
|
||||
const [opts, setOpts] = useState(() => {
|
||||
if (!props.forwardOptions || !props.forwardOptions.onlyEmbedIndices)
|
||||
return props.forwardOptions ?? ({} as ForwardOptions);
|
||||
|
||||
let id = 0;
|
||||
const embedsIds = new Set(props.forwardOptions.onlyEmbedIndices as number[]);
|
||||
|
||||
// Discord incorrectly assumes that embed indices directly map to whole embeds, this is an attempt to fix that
|
||||
const onlyEmbedIndices = message.embeds
|
||||
.flatMap((e, i) => e.images?.map(() => ({ id: id++, eId: i })) ?? { id: id++, eId: i })
|
||||
.filter(({ eId }) => embedsIds.has(eId))
|
||||
.map(({ id }) => id);
|
||||
|
||||
return { ...props.forwardOptions, onlyEmbedIndices };
|
||||
});
|
||||
|
||||
const defaultOpts = useMemo(
|
||||
() => ({
|
||||
onlyAttachmentIds: message.attachments.map(a => a.id),
|
||||
onlyEmbedIndices: message.embeds.flatMap(e => e.images ?? [{}]).map((_, i) => i)
|
||||
}),
|
||||
[message]
|
||||
);
|
||||
|
||||
const hasOpts = !!opts.onlyAttachmentIds || !!opts.onlyEmbedIndices;
|
||||
|
||||
const forwardOptions = useMemo(() => {
|
||||
if (!hasOpts) return opts;
|
||||
|
||||
const fixed = { onlyAttachmentIds: [], onlyEmbedIndices: [], ...opts };
|
||||
|
||||
// Server-side validation can be bypassed by specifying a fake attachment id
|
||||
if (fixed.onlyAttachmentIds.length + fixed.onlyEmbedIndices.length === 0) fixed.onlyAttachmentIds = ["0"];
|
||||
|
||||
// If the embed indices are in the incorrect order, embed metadata could get stripped out by the client
|
||||
fixed.onlyEmbedIndices = fixed.onlyEmbedIndices.toSorted((a, b) => a - b);
|
||||
|
||||
return fixed;
|
||||
}, [opts, hasOpts]);
|
||||
|
||||
const state = useMemo(
|
||||
() => ({ message, opts, setOpts, defaultOpts, hasOpts }),
|
||||
[message, opts, defaultOpts, hasOpts]
|
||||
);
|
||||
return { ...props, forwardOptions, __state: state };
|
||||
},
|
||||
|
||||
renderWrapper(state: ForwardOptionsState, children: ReactNode) {
|
||||
const { message, hasOpts, setOpts, defaultOpts } = state;
|
||||
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<ForwardOptionsContext.Provider value={state}>
|
||||
{children}
|
||||
{message.embeds.length + message.attachments.length > 0 && (
|
||||
<Flex className={Margins.top16}>
|
||||
<Checkbox value={!hasOpts} onChange={() => setOpts(!hasOpts ? defaultOpts : {})} size={20}>
|
||||
<BaseText size="sm">Forward everything</BaseText>
|
||||
</Checkbox>
|
||||
<Tooltip text="Message text will not be forwarded when this option is disabled">
|
||||
{props => <InfoIcon {...props} color="var(--text-muted)" width={20} height={20} />}
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
</ForwardOptionsContext.Provider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
|
||||
renderForwardPicker() {
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<ForwardPicker />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
});
|
||||
53
src/equicordplugins/betterForwards/style.css
Normal file
53
src/equicordplugins/betterForwards/style.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.vc-betterforwards-footer {
|
||||
margin-top: 3px;
|
||||
margin-left: -2px;
|
||||
|
||||
.vc-betterforwards-footer-text {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vc-betterforwards-guild-icon {
|
||||
margin-block: 0;
|
||||
border-radius: 4px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.vc-betterforwards-footer-element {
|
||||
&:hover {
|
||||
.vc-betterforwards-footer-text {
|
||||
color: var(--interactive-text-hover);
|
||||
}
|
||||
|
||||
svg path {
|
||||
fill: var(--interactive-text-hover) !important;
|
||||
}
|
||||
|
||||
background-color: var(--interactive-background-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 1px 0 1px 2px;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-betterforwards-embed-name {
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[class^="forwardPreview"] > [class^="contentWrapper"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 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: "SelfForward",
|
||||
description: "Adds the current channel to the forward list popup",
|
||||
tags: ["Utility"],
|
||||
authors: [EquicordDevs.VillainsRule],
|
||||
patches: [
|
||||
{
|
||||
find: ".getChannelHistory(),",
|
||||
replacement: [{
|
||||
match: /\i.id\]/,
|
||||
replace: "]"
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue