BetterForwards: bug fixes and improvements (#1286)

* BetterForwards: bug fixes, improvements

* BetterForwards: unknown channel fallback

* BetterForwards: Guild and channel preload, bug fixes

* BetterForwards: DM/GDM icons

* BetterForwards: remove unnecessary condition

* BetterForwards: hide current server

* Update stores.ts

* Clean up imports and type definitions in components.tsx

Removed unused imports and type definitions for better code clarity.

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Davri 2026-08-02 05:16:50 +02:00 • committed by GitHub
parent bc6abcbda6
commit d6aa5a0325
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 182 additions and 85 deletions

View file

@ -332,6 +332,8 @@ export class Channel extends DiscordRecord {
isMultiUserDM(): boolean;
/** Whether the channel is marked as NSFW. */
isNSFW(): boolean;
/** Whether the channel details are obfuscated */
isObfuscated(): boolean;
/**
* Checks if the given user is the channel owner.
* @param userId User ID to check.

View file

@ -0,0 +1,13 @@
import { FluxStore, Guild } from "..";
export interface BasicGuild extends Pick<Guild, "id" | "name" | "description" | "icon" | "splash" | "features"> {
discovery_splash: Guild["discoverySplash"];
home_header: Guild["homeHeader"];
}
export class BasicGuildStore extends FluxStore {
getGuild(guildId: string): BasicGuild;
getGuildOrStatus(guildId: string): BasicGuild | { type: "loading"; } | { type: "failed"; } | undefined;
isGuildFetching(guildId: string): boolean;
getVersion(): number;
}

View file

@ -9,6 +9,7 @@ export * from "./ApplicationStreamPreviewStore";
export * from "./AuthenticationStore";
export * from "./AuthorizedAppsStore";
export * from "./AuthSessionsStore";
export * from "./BasicGuildStore";
export * from "./CallStore";
export * from "./ChannelMemberStore";
export * from "./ChannelRTCStore";

View file

@ -8,11 +8,12 @@ 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 { getGuildAcronym, getIntlMessage } from "@utils/discord";
import { getUserAvatarUrl } from "@utils/misc";
import { BasicGuild, Guild, MessageAttachment } from "@vencord/discord-types";
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 { BasicGuildStore, ChannelActionCreators, ChannelStore, DateUtils, GuildStore, IconUtils, Popout, React, RelationshipStore, SelectedGuildStore, SnowflakeUtils, useEffect, useMemo, useRef, UserStore, useStateFromStores } from "@webpack/common";
import { PropsWithChildren, ReactNode } from "react";
import { cl, ForwardOptionsContext, ForwardOptionsState } from ".";
@ -20,25 +21,40 @@ type AttachmentType = "IMAGE" | "VIDEO" | "CLIP" | "AUDIO" | "VISUAL_PLACEHOLDER
const tagClasses = findCssClassesLazy("tagList", "tagGroup", "tag");
const ServerProfileComponent = findComponentByCodeLazy("{guildProfile:", "GUILD_PROFILE");
const getAttachmentType: (attachment: MessageAttachment, inlineAttachmentMedia?: boolean) => AttachmentType = findByCodeLazy('"PLAINTEXT_PREVIEW":"OTHER"');
const getAttachmentType = findByCodeLazy('"PLAINTEXT_PREVIEW":"OTHER"');
const formatChannelName = findByCodeLazy("#{intl::NO_ACCESS}", "isObfuscated()");
const getChannelIcon = findByCodeLazy("textFocused:", "isGameInvitesChannel()");
const navigateTo = findByCodeLazy('getConfig({location:"channel_mention"})');
const fetchBasicGuild = findByCodeLazy('type:"BASIC_GUILD_FETCH_SUCCESS"');
export function GuildName({ guildId }: { guildId: string; }) {
const guild = useStateFromStores(
[GuildStore, SelectedGuildStore],
() => {
const current = SelectedGuildStore.getGuildId();
return current !== guildId ? GuildStore.getGuild(guildId) : null;
},
const currentGuildId = useStateFromStores([SelectedGuildStore], () => SelectedGuildStore.getGuildId(), []);
const guild: Guild | BasicGuild | undefined = useStateFromStores(
[GuildStore, BasicGuildStore],
() => GuildStore.getGuild(guildId) ?? BasicGuildStore.getGuild(guildId),
[guildId]
);
const icon = useMemo(() => {
if (!guild?.icon) return null;
return IconUtils.getGuildIconURL({ id: guildId, icon: guild.icon, canAnimate: true, size: 16 });
}, [guildId, guild?.icon]);
if (!guild) return null;
return guild.icon ? (
<img
src={IconUtils.getGuildIconURL({ ...guild, canAnimate: true, size: 16 })}
alt={`Server icon for ${guild.name}`}
className={cl("guild-icon")}
/>
) : (
<div className={cl("guild-acronym")}>{getGuildAcronym(guild)}</div>
);
}, [guild]);
const guildDivRef = useRef(null);
useEffect(() => void fetchBasicGuild(guildId), [guildId]);
return (
guild && (
currentGuildId !== guildId && (
<Popout
position="top"
renderPopout={() => <ServerProfileComponent guildId={guildId} />}
@ -46,11 +62,11 @@ export function GuildName({ guildId }: { guildId: string; }) {
>
{popoutProps => (
<div ref={guildDivRef} className={cl("footer-element")} {...popoutProps}>
{icon && <img src={icon} alt={`Server icon for ${guild.name}`} className={cl("guild-icon")} />}
{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)" />
<RightArrow width={12} height={12} fill="currentColor" />
</div>
)}
</Popout>
@ -59,45 +75,61 @@ export function GuildName({ guildId }: { guildId: string; }) {
}
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]
const channel = useStateFromStores([ChannelStore], () => ChannelStore.getChannel(channelId), [channelId]);
const name: ReactNode = useStateFromStores(
[UserStore, RelationshipStore],
() =>
channel ? (
formatChannelName(channel, UserStore, RelationshipStore, false, false)
) : (
<i>{guildId ? getIntlMessage("UNKNOWN_CHANNEL").toLowerCase() : getIntlMessage("UNKNOWN_USER")}</i>
),
[channel, guildId]
);
const icon = useMemo(() => {
if (channel?.isDM()) {
const [user] = channel.recipients.map(UserStore.getUser).filter(Boolean);
if (user)
return (
<img
src={getUserAvatarUrl(user, guildId, true, 16)}
alt={`DM icon for ${name}`}
className={cl("user-icon")}
/>
);
}
if (channel?.isGroupDM()) {
return (
<img
src={IconUtils.getChannelIconURL({
...channel,
applicationId: channel.getApplicationId(),
size: 16
})}
alt={`Group DM icon for ${name}`}
className={cl("user-icon")}
/>
);
}
const Icon = (channel && getChannelIcon(channel)) ?? (guildId ? iconsModule.TextIcon : iconsModule.AtIcon);
return <Icon size="xs" color="currentColor" />;
}, [channel, guildId, name]);
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>
)
<div
className={cl("footer-element")}
onClick={() => navigateTo(guildId ?? "@me", channelId, messageId)}
onMouseEnter={() => ChannelActionCreators.preload(guildId ?? "@me", channelId)}
>
{icon}
<BaseText size="sm" weight="medium" className={cl("footer-text")}>
{name}
</BaseText>
<RightArrow width={12} height={12} fill="currentColor" />
</div>
);
}
@ -118,18 +150,19 @@ export function Timestamp({ snowflake }: { snowflake: string; }) {
export function ForwardPicker() {
const state = React.useContext(ForwardOptionsContext);
const { message } = state;
if (state.message.embeds.length + state.message.attachments.length === 0) return null;
if (!message || message.embeds.length + 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} />}
{message.attachments.length > 0 && <AttachmentPicker {...state} message={message} />}
{message.embeds.length > 0 && <EmbedPicker {...state} message={message} />}
</Flex>
);
}
export function EmbedPicker({ message, opts, setOpts, hasOpts, defaultOpts }: ForwardOptionsState) {
export function EmbedPicker({ message, opts, setOpts, hasOpts, defaultOpts }: Required<ForwardOptionsState>) {
const embeds = useMemo(() => {
let id = 0;
return message.embeds.map(({ rawTitle, rawDescription, image, images = image ? [image] : [], video }, i) => {
@ -158,7 +191,7 @@ export function EmbedPicker({ message, opts, setOpts, hasOpts, defaultOpts }: Fo
const { EmbedIcon, ImageIcon } = iconsModule;
return embeds.map(({ title, subEmbeds }) => (
return embeds?.map(({ title, subEmbeds }) => (
<Flex gap={4} flexDirection="column" key={subEmbeds[0].id}>
<BaseText
size="sm"
@ -176,7 +209,7 @@ export function EmbedPicker({ message, opts, setOpts, hasOpts, defaultOpts }: Fo
key={id}
id={id}
source={hasOpts ? (opts.onlyEmbedIndices ?? []) : defaultOpts.onlyEmbedIndices}
onChange={data => setOpts(prev => ({ ...prev, onlyEmbedIndices: data }))}
onChange={onlyEmbedIndices => setOpts(prev => ({ ...prev, onlyEmbedIndices }))}
disabled={!hasOpts}
>
{Icon && <Icon size="xs" style={{ flexShrink: 0 }} />}
@ -189,7 +222,7 @@ export function EmbedPicker({ message, opts, setOpts, hasOpts, defaultOpts }: Fo
));
}
export function AttachmentPicker({ message, opts, setOpts, hasOpts, defaultOpts }: ForwardOptionsState) {
export function AttachmentPicker({ message, opts, setOpts, hasOpts, defaultOpts }: Required<ForwardOptionsState>) {
return (
<TagContainer>
{message.attachments.map(attachment => (
@ -197,7 +230,7 @@ export function AttachmentPicker({ message, opts, setOpts, hasOpts, defaultOpts
key={attachment.id}
id={attachment.id}
source={hasOpts ? opts.onlyAttachmentIds ?? [] : defaultOpts.onlyAttachmentIds}
onChange={data => setOpts(prev => ({ ...prev, onlyAttachmentIds: data }))}
onChange={onlyAttachmentIds => setOpts(prev => ({ ...prev, onlyAttachmentIds }))}
disabled={!hasOpts}
>
<AttachmentIcon attachment={attachment} />
@ -219,7 +252,7 @@ function Tag<T>({ id, children, source, onChange, disabled }: { id: T; source: T
<div
className={tagClasses.tag}
data-selection-mode="multiple"
data-selected={!disabled &&selected ? "true" : undefined}
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}
@ -229,7 +262,7 @@ function Tag<T>({ id, children, source, onChange, disabled }: { id: T; source: T
);
}
const icons: Partial<Record<AttachmentType, string>> = {
const attachmentIcons: Partial<Record<AttachmentType, string>> = {
IMAGE: "Image",
VIDEO: "Video",
CLIP: "Clips",
@ -240,7 +273,7 @@ const icons: Partial<Record<AttachmentType, string>> = {
function AttachmentIcon({ attachment }: { attachment: MessageAttachment; }) {
const Icon = useMemo(() => {
const type = getAttachmentType(attachment, true);
return iconsModule[(icons[type] ?? "ImageFile") + "Icon"];
return iconsModule[(attachmentIcons[type] ?? "ImageFile") + "Icon"];
}, [attachment]);
return Icon && <Icon size="xs" style={{ flexShrink: 0 }} />;

View file

@ -34,16 +34,15 @@ export interface ForwardOptionsState {
setOpts: Dispatch<SetStateAction<ForwardOptions>>;
defaultOpts: Required<ForwardOptions>;
hasOpts: boolean;
message: Message;
message?: Message;
}
export const ForwardOptionsContext = proxyLazyWebpack(() =>
React.createContext<ForwardOptionsState & { message: Message }>({
React.createContext<ForwardOptionsState>({
opts: {},
setOpts: () => {},
setOpts: () => { },
defaultOpts: { onlyAttachmentIds: [], onlyEmbedIndices: [] },
hasOpts: false,
message: {} as Message
})
);
@ -159,7 +158,7 @@ export default definePlugin({
}
],
async sendForward(additionalMessage: string | null, channels: { id: string; type: string }[], message: Message, options: ForwardOptions) {
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} `;
@ -199,7 +198,7 @@ export default definePlugin({
ignore = !!event?.shiftKey;
},
renderForwardFooter({ message }: { message: Message }) {
renderForwardFooter({ message }: { message: Message; }) {
if (!message.messageReference) return null;
const { guild_id, channel_id, message_id } = message.messageReference;
@ -215,8 +214,9 @@ export default definePlugin({
);
},
useProps(props: { message: Message; forwardOptions?: ForwardOptions }) {
const message = props.message.messageSnapshots[0]?.message ?? props.message;
useProps(props: { message?: Message; forwardOptions?: ForwardOptions; }) {
// Message is undefined in certain forward contexts (e.g. forwarding an item from the shop)
const message = props.message?.messageSnapshots[0]?.message ?? props.message;
const [opts, setOpts] = useState(() => {
if (!props.forwardOptions || !props.forwardOptions.onlyEmbedIndices)
@ -226,18 +226,18 @@ export default definePlugin({
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
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);
.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)
onlyAttachmentIds: message?.attachments.map(a => a.id) ?? [],
onlyEmbedIndices: message?.embeds.flatMap(e => e.images ?? [{}]).map((_, i) => i) ?? []
}),
[message]
);
@ -272,7 +272,7 @@ export default definePlugin({
<ErrorBoundary noop>
<ForwardOptionsContext.Provider value={state}>
{children}
{message.embeds.length + message.attachments.length > 0 && (
{message && 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>

View file

@ -8,10 +8,32 @@
color: var(--text-muted);
}
.vc-betterforwards-guild-icon {
margin-block: 0;
border-radius: 4px;
.vc-betterforwards-guild-icon,
.vc-betterforwards-user-icon,
.vc-betterforwards-guild-acronym {
margin-right: 2px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.vc-betterforwards-guild-icon {
border-radius: 4px;
}
.vc-betterforwards-user-icon {
border-radius: 9999px;
}
.vc-betterforwards-guild-acronym {
line-height: 16px;
text-align: center;
overflow: hidden;
font-size: 9.6px;
border-radius: 4px;
white-space: nowrap;
background-color: var(--background-base-lower);
color: var(--text-default);
}
.vc-betterforwards-footer-element {
@ -39,6 +61,12 @@
padding: 1px 0 1px 2px;
border-radius: 4px;
vertical-align: middle;
color: var(--text-muted);
svg,
img {
flex-shrink: 0;
}
}
}
@ -48,6 +76,26 @@
text-overflow: ellipsis;
}
[class^="forwardPreview"] > [class^="contentWrapper"] {
overflow: hidden;
}
[class^="forwardPreview"] {
height: fit-content;
> [class^="contentWrapper"] {
max-height: 250px;
overflow-y: auto;
scrollbar-width: thin;
> [class^="forwardPreviewMessage"] {
flex-shrink: 0;
}
[class^="tagGroup"] > [class^="tag"] {
text-wrap: nowrap !important;
min-width: 0;
.vc-text-base {
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}

View file

@ -107,6 +107,7 @@ export let ReferencedMessageStore: t.ReferencedMessageStore;
export let SessionsStore: t.SessionsStore;
export let GuildAvailabilityStore: t.GuildAvailabilityStore;
export let UserGuildJoinRequestStore: t.UserGuildJoinRequestStore;
export let BasicGuildStore: t.BasicGuildStore;
/**
* @see jsdoc of {@link t.useStateFromStores}
@ -179,14 +180,13 @@ waitForStore("AuthSessionsStore", m => AuthSessionsStore = m);
waitForStore("ClientThemesBackgroundStore", m => ClientThemesBackgroundStore = m);
waitForStore("ConnectedAccountsStore", m => ConnectedAccountsStore = m);
waitForStore("ChannelMemberStore", m => ChannelMemberStore = m);
// comeback
waitForStore("ThreadMemberListStore", m => ThreadMemberListStore = m);
waitForStore("CollapsedVoiceChannelStore", m => CollapsedVoiceChannelStore = m);
waitForStore("ReferencedMessageStore", m => ReferencedMessageStore = m);
// comeback
waitForStore("SessionsStore", m => SessionsStore = m);
waitForStore("GuildAvailabilityStore", m => GuildAvailabilityStore = m);
waitForStore("UserGuildJoinRequestStore", m => UserGuildJoinRequestStore = m);
waitForStore("BasicGuildStore", m => BasicGuildStore = m);
waitForStore("ThemeStore", m => {
ThemeStore = m;
// Importing this directly causes all webpack commons to be imported, which can easily cause circular dependencies.