diff --git a/src/plugins/moreUserTags/consts.ts b/src/plugins/moreUserTags/consts.ts new file mode 100644 index 0000000000..8542f3d98d --- /dev/null +++ b/src/plugins/moreUserTags/consts.ts @@ -0,0 +1,64 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findByCodeLazy, findByPropsLazy, findLazy } from "@webpack"; +import { GuildStore } from "@webpack/common"; +import { RC } from "@webpack/types"; +import { Channel, Guild, Message, User } from "discord-types/general"; + +import type { ITag } from "./types"; + +export const isWebhook = (message: Message, user: User) => !!message?.webhookId && user.isNonUserBot(); +export const tags = [ + { + name: "WEBHOOK", + displayName: "Webhook", + description: "Messages sent by webhooks", + condition: isWebhook + }, { + name: "OWNER", + displayName: "Owner", + description: "Owns the server", + condition: (_, user, channel) => GuildStore.getGuild(channel?.guild_id)?.ownerId === user.id + }, { + name: "ADMINISTRATOR", + displayName: "Admin", + description: "Has the administrator permission", + permissions: ["ADMINISTRATOR"] + }, { + name: "MODERATOR_STAFF", + displayName: "Staff", + description: "Can manage the server, channels or roles", + permissions: ["MANAGE_GUILD", "MANAGE_CHANNELS", "MANAGE_ROLES"] + }, { + name: "MODERATOR", + displayName: "Mod", + description: "Can manage messages or kick/ban people", + permissions: ["MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS"] + }, { + name: "VOICE_MODERATOR", + displayName: "VC Mod", + description: "Can manage voice chats", + permissions: ["MOVE_MEMBERS", "MUTE_MEMBERS", "DEAFEN_MEMBERS"] + }, { + name: "CHAT_MODERATOR", + displayName: "Chat Mod", + description: "Can timeout people", + permissions: ["MODERATE_MEMBERS"] + } +] as const satisfies ITag[]; + +export const Tag = findLazy(m => m.Types?.[0] === "BOT") as RC<{ type?: number | null, className?: string, useRemSizes?: boolean; }> & { Types: Record; }; +export const classNames = findByPropsLazy("botTagCompact"); + +// PermissionStore.computePermissions will not work here since it only gets permissions for the current user +export const computePermissions: (options: { + user?: { id: string; } | string | null; + context?: Guild | Channel | null; + overwrites?: Channel["permissionOverwrites"] | null; + checkElevated?: boolean /* = true */; + excludeGuildPermissions?: boolean /* = false */; +}) => bigint = findByCodeLazy(".getCurrentUser()", ".computeLurkerPermissionsAllowList()"); diff --git a/src/plugins/moreUserTags/index.tsx b/src/plugins/moreUserTags/index.tsx new file mode 100644 index 0000000000..e7f54b0b71 --- /dev/null +++ b/src/plugins/moreUserTags/index.tsx @@ -0,0 +1,186 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { getIntlMessage } from "@utils/discord"; +import definePlugin from "@utils/types"; +import { ChannelStore, GuildStore, PermissionsBits } from "@webpack/common"; +import { Channel, Message, User } from "discord-types/general"; + +import { computePermissions, isWebhook, Tag, tags } from "./consts"; +import { settings } from "./settings"; + +const genTagTypes = () => { + let i = 100; + const obj = {}; + + for (const { name } of tags) { + obj[name] = ++i; + obj[i] = name; + obj[`${name}-BOT`] = ++i; + obj[i] = `${name}-BOT`; + obj[`${name}-OP`] = ++i; + obj[i] = `${name}-OP`; + } + + return obj; +}; + +export default definePlugin({ + name: "MoreUserTags", + description: "Adds tags for webhooks and moderative roles (owner, admin, etc.)", + authors: [Devs.Cyn, Devs.TheSun, Devs.RyanCaoDev, Devs.LordElias, Devs.AutumnVN, Devs.hen], + settings, + patches: [ + // Render Tags in messages + // Maybe there is a better way to catch this horror + { + find: ".isVerifiedBot(),hideIcon:", + replacement: { + match: /(?<=let (\i).{1,500}.isSystemDM.{0,350}),null==(\i\))(?=.{1,30}?null:)/, + replace: + ",($1=$self.getTag({...arguments[0],isChat:true,origType:$1}))$&", + }, + }, + // Make discord actually use our tags + { + find: ".STAFF_ONLY_DM:", + replacement: { + match: /(?<=type:(\i).{10,1000}.REMIX.{10,100})default:(\i)=/, + replace: "default:$2=$self.getTagText($self.localTags[$1]);", + }, + }, + // Member list + // In the current state it makes smth like + // null != U && U && ($1=blahblahblah) + { + find: ".lostPermission)", + replacement: { + match: /(?<=return .{0,20})\.bot?(?=.{0,100}type:(\i))/, + replace: "&& ($1=$self.getTag({...arguments[0],isChat:false,origType:$1}))" + } + }, + + // Next both 2 patches are goint together + // First one passes down the react dom channelId which is required to get tag + // Second one actually gets/displays it + { + find: ".hasAvatarForGuild(null==", + replacement: { + match: /user:\i,(?=.{0,50}.BITE_SIZE)/, + replace: "$&channelId:arguments[0].channelId," + }, + }, + { + find: ".clickableUsername", + replacement: { + match: /null!=(\i)(?=.{0,100}type:\i)/, + replace: "($1=$self.getTag({...arguments[0],isChat:false,origType:$1}),$1!==null)" + } + } + ], + localTags: genTagTypes(), + + getTagText(passedTagName: string) { + if (!passedTagName) return getIntlMessage("APP_TAG"); + const [tagName, variant] = passedTagName.split("-"); + + const tag = tags.find(({ name }) => tagName === name); + if (!tag) return getIntlMessage("APP_TAG"); + + if (variant === "BOT" && tagName !== "WEBHOOK" && this.settings.store.dontShowForBots) return getIntlMessage("APP_TAG"); + + const tagText = settings.store.tagSettings?.[tag.name]?.text || tag.displayName; + switch (variant) { + case "OP": + return `${getIntlMessage("BOT_TAG_FORUM_ORIGINAL_POSTER")} • ${tagText}`; + case "BOT": + return `${getIntlMessage("APP_TAG")} • ${tagText}`; + default: + return tagText; + } + }, + + getTag({ + message, user, channelId, origType, isChat, channel + }: { + message?: Message, + user?: User & { isClyde(): boolean; }, + channel?: Channel & { isForumPost(): boolean; isMediaPost(): boolean; }, + channelId?: string; + origType?: number; + isChat?: boolean; + }): number | null { + user ??= message?.author as any; + + if (!user) + return null; + if (isChat && user.id === "1") + return Tag.Types.OFFICIAL; + if (user.isClyde()) + return Tag.Types.AI; + + let type = typeof origType === "number" ? origType : null; + + channelId ??= message?.channel_id as any; + channel ??= ChannelStore.getChannel(channelId!) as any; + if (!channel) return type; + + const settings = this.settings.store; + const perms = this.getPermissions(user, channel); + + for (const tag of tags) { + if (isChat && !settings.tagSettings[tag.name].showInChat) + continue; + if (!isChat && !settings.tagSettings[tag.name].showInNotChat) + continue; + + // If the owner tag is disabled, and the user is the owner of the guild, + // avoid adding other tags because the owner will always match the condition for them + if ( + (tag.name !== "OWNER" && + GuildStore.getGuild(channel?.guild_id)?.ownerId === + user.id && + isChat && + !settings.tagSettings.OWNER.showInChat) || + (!isChat && + !settings.tagSettings.OWNER.showInNotChat) + ) + continue; + + if ("permissions" in tag ? + tag.permissions.some(perm => perms.includes(perm)) : + tag.condition(message!, user, channel)) { + if ((channel.isForumPost() || channel.isMediaPost()) && channel.ownerId === user.id) + type = this.localTags[`${tag.name}-OP`]; + + else if ( + user.bot && + !isWebhook(message!, user) && + !settings.dontShowBotTag + ) + type = this.localTags[`${tag.name}-BOT`]; + + else type = this.localTags[tag.name]; + break; + } + } + + return type; + }, + getPermissions(user: User, channel: Channel): string[] { + const guild = GuildStore.getGuild(channel?.guild_id); + if (!guild) return []; + + const permissions = computePermissions({ user, context: guild, overwrites: channel.permissionOverwrites }); + return Object.entries(PermissionsBits) + .map(([perm, permInt]) => + permissions & permInt ? perm : "" + ) + .filter(Boolean); + }, +}); + diff --git a/src/plugins/moreUserTags/settings.tsx b/src/plugins/moreUserTags/settings.tsx new file mode 100644 index 0000000000..e0cff67fc2 --- /dev/null +++ b/src/plugins/moreUserTags/settings.tsx @@ -0,0 +1,83 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Margins } from "@utils/margins"; +import { OptionType } from "@utils/types"; +import { Card, Flex, Forms, Switch, TextInput, Tooltip } from "@webpack/common"; + +import { Tag, tags } from "./consts"; +import { TagSettings } from "./types"; + + +const defaultSettings = Object.fromEntries( + tags.map(({ name, displayName }) => [name, { text: displayName, showInChat: true, showInNotChat: true }]) +) as TagSettings; + +function SettingsComponent() { + const tagSettings = settings.store.tagSettings ??= defaultSettings; + + return ( + + {tags.map(t => ( + + + + {({ onMouseEnter, onMouseLeave }) => ( +
+ {t.displayName} Tag +
+ )} +
+
+ + tagSettings[t.name].text = v} + className={Margins.bottom16} + /> + + tagSettings[t.name].showInChat = v} + hideBorder + > + Show in messages + + + tagSettings[t.name].showInNotChat = v} + hideBorder + > + Show in member list and profiles + +
+ ))} +
+ ); +} + +export const settings = definePluginSettings({ + dontShowForBots: { + description: "Don't show extra tags for bots (excluding webhooks)", + type: OptionType.BOOLEAN + }, + dontShowBotTag: { + description: "Only show extra tags for bots / Hide [BOT] text", + type: OptionType.BOOLEAN + }, + tagSettings: { + type: OptionType.COMPONENT, + component: SettingsComponent, + description: "fill me" + } +}); diff --git a/src/plugins/moreUserTags/types.ts b/src/plugins/moreUserTags/types.ts new file mode 100644 index 0000000000..40d8ac7d37 --- /dev/null +++ b/src/plugins/moreUserTags/types.ts @@ -0,0 +1,32 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { Permissions } from "@webpack/types"; +import type { Channel, Message, User } from "discord-types/general"; + +import { tags } from "./consts"; + +export type ITag = { + // name used for identifying, must be alphanumeric + underscores + name: string; + // name shown on the tag itself, can be anything probably; automatically uppercase'd + displayName: string; + description: string; +} & ({ + permissions: Permissions[]; +} | { + condition?(message: Message | null, user: User, channel: Channel): boolean; +}); + +export interface TagSetting { + text: string; + showInChat: boolean; + showInNotChat: boolean; +} + +export type TagSettings = { + [k in typeof tags[number]["name"]]: TagSetting; +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e758259125..5b93efbf8b 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -579,6 +579,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "jamesbt365", id: 158567567487795200n, }, + hen: { + id: 279266228151779329n, + name: "Hen" + } } satisfies Record); // iife so #__PURE__ works correctly