diff --git a/src/equicordplugins/betterForwards/README.md b/src/equicordplugins/betterForwards/README.md deleted file mode 100644 index d38a56f96..000000000 --- a/src/equicordplugins/betterForwards/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Better Forwards - -# Dont Follow Forwards - -Don't follow forwarded messages after sending them. - -## Before - -![before](https://github.com/user-attachments/assets/13187c7c-37ea-41cc-bfbc-98c9f517c69b) - -## After - -![after](https://github.com/user-attachments/assets/1b3972d9-fa8e-47c5-a39f-94f4963d94fa) diff --git a/src/equicordplugins/betterForwards/components.tsx b/src/equicordplugins/betterForwards/components.tsx new file mode 100644 index 000000000..5c1b3c539 --- /dev/null +++ b/src/equicordplugins/betterForwards/components.tsx @@ -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 && ( + } + targetElementRef={guildDivRef} + > + {popoutProps => ( +
+ {icon && {`Server} + + {guild ? guild.name : "View server"} + + +
+ )} +
+ ) + ); +} + +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 && ( +
NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/${messageId}`)} + > + + {name} + + +
+ ) + ); +} + +export function Timestamp({ snowflake }: { snowflake: string; }) { + const formatted = useMemo( + () => DateUtils.calendarFormat(new Date(SnowflakeUtils.extractTimestamp(snowflake))), + [snowflake] + ); + + return ( +
+ + {formatted} + +
+ ); +} + +export function ForwardPicker() { + const state = React.useContext(ForwardOptionsContext); + + if (state.message.embeds.length + state.message.attachments.length === 0) return null; + + return ( + + {state.message.attachments.length > 0 && } + {state.message.embeds.length > 0 && } + + ); +} + +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 }) => ( + + + {title} + + + {subEmbeds.map(({ id, name, isMainEmbed }) => { + const Icon = isMainEmbed ? EmbedIcon : ImageIcon; + return ( + setOpts(prev => ({ ...prev, onlyEmbedIndices: data }))} + disabled={!hasOpts} + > + {Icon && } + {name} + + ); + })} + + + )); +} + +export function AttachmentPicker({ message, opts, setOpts, hasOpts, defaultOpts }: ForwardOptionsState) { + return ( + + {message.attachments.map(attachment => ( + setOpts(prev => ({ ...prev, onlyAttachmentIds: data }))} + disabled={!hasOpts} + > + + {attachment.filename} + + ))} + + ); +} + +function TagContainer(props: FlexProps) { + return ; +} + +function Tag({ 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 ( +
onChange(selected ? source.filter(x => x !== id) : [...source, id])} + style={{ textWrap: "wrap", opacity: disabled ? .5 : undefined }} + inert={disabled} + > + {children} +
+ ); +} + +const icons: Partial> = { + 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 && ; +} diff --git a/src/equicordplugins/betterForwards/index.ts b/src/equicordplugins/betterForwards/index.ts deleted file mode 100644 index e6f407060..000000000 --- a/src/equicordplugins/betterForwards/index.ts +++ /dev/null @@ -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; - } -}); diff --git a/src/equicordplugins/betterForwards/index.tsx b/src/equicordplugins/betterForwards/index.tsx new file mode 100644 index 000000000..8b23798a9 --- /dev/null +++ b/src/equicordplugins/betterForwards/index.tsx @@ -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>; + defaultOpts: Required; + hasOpts: boolean; + message: Message; +} + +export const ForwardOptionsContext = proxyLazyWebpack(() => + React.createContext({ + 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) + ); +}; + +// 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 ( + +
+ {guild_id && } + + +
+
+ ); + }, + + 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 ( + + + {children} + {message.embeds.length + message.attachments.length > 0 && ( + + setOpts(!hasOpts ? defaultOpts : {})} size={20}> + Forward everything + + + {props => } + + + )} + + + ); + }, + + renderForwardPicker() { + return ( + + + + ); + } +}); diff --git a/src/equicordplugins/betterForwards/style.css b/src/equicordplugins/betterForwards/style.css new file mode 100644 index 000000000..beb364cc6 --- /dev/null +++ b/src/equicordplugins/betterForwards/style.css @@ -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; +} \ No newline at end of file diff --git a/src/equicordplugins/selfForward/index.ts b/src/equicordplugins/selfForward/index.ts deleted file mode 100644 index 09672018c..000000000 --- a/src/equicordplugins/selfForward/index.ts +++ /dev/null @@ -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: "]" - }] - } - ] -});