From 36d7d2a621aeb251ddbaaf17bebb2482f3122192 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sun, 2 Aug 2026 19:20:42 -0400 Subject: [PATCH] Add Custom User Tags: create your own tags and assign them to specific users --- README.md | 1 + plugins/custom-user-tags/manifest.json | 8 ++ plugins/custom-user-tags/src/index.ts | 25 ++++++ .../custom-user-tags/src/lib/resolveTag.ts | 20 +++++ plugins/custom-user-tags/src/lib/tags.ts | 24 ++++++ plugins/custom-user-tags/src/patches/chat.ts | 29 +++++++ .../custom-user-tags/src/patches/details.tsx | 77 ++++++++++++++++++ plugins/custom-user-tags/src/patches/name.tsx | 53 +++++++++++++ plugins/custom-user-tags/src/patches/tag.tsx | 23 ++++++ plugins/custom-user-tags/src/ui/Settings.tsx | 78 ++++++++++++++++++ .../src/ui/TagEditorAlert.tsx | 79 +++++++++++++++++++ site/meta.json | 17 ++++ 12 files changed, 434 insertions(+) create mode 100644 plugins/custom-user-tags/manifest.json create mode 100644 plugins/custom-user-tags/src/index.ts create mode 100644 plugins/custom-user-tags/src/lib/resolveTag.ts create mode 100644 plugins/custom-user-tags/src/lib/tags.ts create mode 100644 plugins/custom-user-tags/src/patches/chat.ts create mode 100644 plugins/custom-user-tags/src/patches/details.tsx create mode 100644 plugins/custom-user-tags/src/patches/name.tsx create mode 100644 plugins/custom-user-tags/src/patches/tag.tsx create mode 100644 plugins/custom-user-tags/src/ui/Settings.tsx create mode 100644 plugins/custom-user-tags/src/ui/TagEditorAlert.tsx diff --git a/README.md b/README.md index 6d7664b..c06d34e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ To install: open Discord with Revenge installed, go to **Settings → Plugins**, | Plugin | Status | Description | Install link | | --- | --- | --- | --- | | Staff Tags | Rebuilt | OWNER/ADMIN/STAFF/MOD-style tags next to members, computed from real server permissions. Per-tag text, colors, gradients, and visibility. | `https://rp.jarviscli.dev/staff-tags/install/` | +| Custom User Tags | New | Create your own tags (text + color) and assign them to specific users, independent of server permissions. Long-press a name to manage one, or add by user ID in settings. | `https://rp.jarviscli.dev/custom-user-tags/install/` | | RoleColorEverywhere | Revived | A member's top role color shown in mentions, the typing indicator, voice channel names, member list role headers, and optionally message text. | `https://rp.jarviscli.dev/role-color-everywhere/install/` | | PronounDB | Revived | Shows a user's pronouns in their profile, if they've set them at pronoundb.org (nothing to configure in the plugin itself). | `https://rp.jarviscli.dev/pronoun-db/install/` | | FakeProfileThemesAndEffects | Revived | Profile theming and profile effects without Nitro. Fork of [shipwr3ckd's FPTE](https://github.com/shipwr3ckd/FPTE) - fixed a crash on every profile-editor open, confirmed via on-device diagnostics. | `https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/` | diff --git a/plugins/custom-user-tags/manifest.json b/plugins/custom-user-tags/manifest.json new file mode 100644 index 0000000..35a7505 --- /dev/null +++ b/plugins/custom-user-tags/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Custom User Tags", + "description": "Create your own tags (text + color) and assign them to specific users - long-press a name in the member list or profile popout to manage one, or add one by user ID in settings.", + "authors": [ + { "name": "everestmcarthur" } + ], + "main": "src/index.ts" +} diff --git a/plugins/custom-user-tags/src/index.ts b/plugins/custom-user-tags/src/index.ts new file mode 100644 index 0000000..12bd60e --- /dev/null +++ b/plugins/custom-user-tags/src/index.ts @@ -0,0 +1,25 @@ +import { logger } from "@vendetta"; +import { storage } from "@vendetta/plugin"; +import { applyPatches } from "@shared/lib/patcher"; +import patchChat from "./patches/chat"; +import patchName from "./patches/name"; +import patchDetails from "./patches/details"; +import patchTag from "./patches/tag"; +import Settings from "./ui/Settings"; + +let unpatchAll: () => void = () => {}; + +export default { + onLoad: () => { + storage.tags ??= {}; + + unpatchAll = applyPatches("Custom User Tags", logger, { + tag: patchTag, + chat: patchChat, + name: patchName, + details: patchDetails + }); + }, + onUnload: () => unpatchAll(), + settings: Settings +}; diff --git a/plugins/custom-user-tags/src/lib/resolveTag.ts b/plugins/custom-user-tags/src/lib/resolveTag.ts new file mode 100644 index 0000000..a2d6625 --- /dev/null +++ b/plugins/custom-user-tags/src/lib/resolveTag.ts @@ -0,0 +1,20 @@ +import { chroma } from "@vendetta/metro/common"; +import { rawColors } from "@vendetta/ui"; +import { isValidHex } from "@shared/lib/color"; +import { getUserTag } from "./tags"; + +export interface ResolvedTag { + text: string; + textColor: any; + backgroundColor: string; +} + +export default function resolveTag(userId: string | undefined): ResolvedTag | undefined { + const tag = getUserTag(userId); + if (!tag?.text || !isValidHex(tag.color)) return undefined; + + const backgroundColor = tag.color; + const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500; + + return { text: tag.text, textColor, backgroundColor }; +} diff --git a/plugins/custom-user-tags/src/lib/tags.ts b/plugins/custom-user-tags/src/lib/tags.ts new file mode 100644 index 0000000..57a1e92 --- /dev/null +++ b/plugins/custom-user-tags/src/lib/tags.ts @@ -0,0 +1,24 @@ +import { storage } from "@vendetta/plugin"; + +export interface UserTag { + text: string; + color: string; +} + +export function allTags(): Record { + storage.tags ??= {}; + return storage.tags; +} + +export function getUserTag(userId: string | undefined): UserTag | undefined { + if (!userId) return undefined; + return allTags()[userId]; +} + +export function setUserTag(userId: string, tag: UserTag): void { + allTags()[userId] = tag; +} + +export function removeUserTag(userId: string): void { + delete allTags()[userId]; +} diff --git a/plugins/custom-user-tags/src/patches/chat.ts b/plugins/custom-user-tags/src/patches/chat.ts new file mode 100644 index 0000000..2ed25cf --- /dev/null +++ b/plugins/custom-user-tags/src/patches/chat.ts @@ -0,0 +1,29 @@ +import { findByName } from "@vendetta/metro"; +import { ReactNative, chroma } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; +import resolveTag from "../lib/resolveTag"; + +const getTagProperties = findByName("getTagProperties", false); + +// Chat message tags are rendered from plain data (not a patchable element tree), so this can only +// ever produce a solid-color tag here - same platform limitation Staff Tags documents for the same +// reason. +export default () => { + if (!getTagProperties) return () => {}; + + return after("default", getTagProperties, ([{ message }], ret) => { + if (ret?.tagType) return; + + const tag = resolveTag(message?.author?.id); + if (!tag) return; + + return { + ...ret, + tagText: tag.text, + tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined, + tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()), + tagVerified: false, + tagType: undefined + }; + }); +}; diff --git a/plugins/custom-user-tags/src/patches/details.tsx b/plugins/custom-user-tags/src/patches/details.tsx new file mode 100644 index 0000000..b71100b --- /dev/null +++ b/plugins/custom-user-tags/src/patches/details.tsx @@ -0,0 +1,77 @@ +import { findByProps, findByTypeNameAll } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { findInReactTree } from "@vendetta/utils"; +import resolveTag from "../lib/resolveTag"; +import openTagEditor from "../ui/TagEditorAlert"; + +const TagModule = findByProps("getBotLabel"); + +// Covers member list rows and the profile popout, same target Staff Tags already confirmed works +// (findByTypeNameAll("UserRow")) - and the long-press hook for assigning a tag, since this repo has +// no confirmed way to hook Discord's own user context menu. +const rowPatch = ([{ user }]: any[], res: any) => { + try { + if (user?.id && res?.props) { + const existingLongPress = res.props.onLongPress; + res.props.onLongPress = (...args: any[]) => { + existingLongPress?.(...args); + openTagEditor(user.id, user.globalName || user.username || user.id); + }; + } + + const label = res?.props?.label; + const nameContainer = findInReactTree( + label, + (c) => + Array.isArray(c?.props?.children) && + c.props.children.some( + (ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string" + ) + ); + if (!nameContainer) return; + + const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types); + if (existingTag && existingTag.props?.type !== 0) return; + + const tag = resolveTag(user?.id); + if (!tag) return; + + if (existingTag) { + Object.assign(existingTag.props, { + type: 0, + text: tag.text, + textColor: tag.textColor, + backgroundColor: tag.backgroundColor, + verified: false + }); + return; + } + + if (!Array.isArray(nameContainer.props.children)) { + nameContainer.props.children = [nameContainer.props.children]; + } + + if (TagModule) { + nameContainer.props.children.push( + + ); + } + } catch { + // Never let one bad lookup take down the whole member list. + } +}; + +export default () => { + const patches: (() => void)[] = []; + const rows = findByTypeNameAll("UserRow") ?? []; + + rows.forEach((UserRow: any) => patches.push(after("type", UserRow, rowPatch))); + + return () => patches.forEach((unpatch) => unpatch()); +}; diff --git a/plugins/custom-user-tags/src/patches/name.tsx b/plugins/custom-user-tags/src/patches/name.tsx new file mode 100644 index 0000000..149eef6 --- /dev/null +++ b/plugins/custom-user-tags/src/patches/name.tsx @@ -0,0 +1,53 @@ +import { findByProps } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { findInReactTree } from "@vendetta/utils"; +import resolveTag from "../lib/resolveTag"; + +const TagModule = findByProps("getBotLabel"); + +// findByName("DisplayName", false) doesn't match - confirmed live via Key Inspector's Eval console +// (a full module-key scan, while fixing Staff Tags' identical lookup) that the export survives as a +// plain named property, not the component's own runtime .name/.displayName, which doesn't survive +// production minification. findByProps looks at property keys instead. +const DisplayNameModule = findByProps("DisplayName") as any; + +export default () => { + if (!DisplayNameModule?.DisplayName) return () => {}; + + return after("DisplayName", DisplayNameModule, ([{ user }]: any[], ret: any) => { + try { + const tagComponent = findInReactTree(ret, (c) => c?.type?.Types); + if (tagComponent && tagComponent.props?.type !== 0) return; + + const tag = resolveTag(user?.id); + if (!tag) return; + + if (tagComponent) { + tagComponent.props = { + type: 0, + text: tag.text, + textColor: tag.textColor, + backgroundColor: tag.backgroundColor, + verified: false + }; + return; + } + + const row = findInReactTree(ret, (c) => c?.props?.style?.flexDirection === "row"); + if (!Array.isArray(row?.props?.children) || !TagModule) return; + + row.props.children.push( + + ); + } catch { + // Never let one bad lookup take down every name row on screen. + } + }); +}; diff --git a/plugins/custom-user-tags/src/patches/tag.tsx b/plugins/custom-user-tags/src/patches/tag.tsx new file mode 100644 index 0000000..5e4f0a8 --- /dev/null +++ b/plugins/custom-user-tags/src/patches/tag.tsx @@ -0,0 +1,23 @@ +import { findByProps } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { findInReactTree } from "@vendetta/utils"; + +const Tag = findByProps("getBotLabel"); + +/** + * Discord's own Tag component ignores custom text/color props unless we push them into the + * already-rendered tree ourselves - this is what actually makes our overrides visible. Identical to + * Staff Tags' own copy of this patch - fully generic, doesn't care which plugin computed the tag. + */ +export default () => { + if (!Tag) return () => {}; + + return after("default", Tag, ([{ text, textColor, backgroundColor }], ret) => { + const label = findInReactTree(ret, (c) => typeof c?.props?.children === "string"); + if (!label) return; + + if (text) label.props.children = text; + if (textColor) label.props.style?.push?.({ color: textColor }); + if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor }); + }); +}; diff --git a/plugins/custom-user-tags/src/ui/Settings.tsx b/plugins/custom-user-tags/src/ui/Settings.tsx new file mode 100644 index 0000000..5ea176b --- /dev/null +++ b/plugins/custom-user-tags/src/ui/Settings.tsx @@ -0,0 +1,78 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { useProxy } from "@vendetta/storage"; +import { storage } from "@vendetta/plugin"; +import { Forms } from "@vendetta/ui/components"; +import { showToast } from "@vendetta/ui/toasts"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import ListSection from "@shared/ui/ListSection"; +import ColorInput from "@shared/ui/ColorInput"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import NoteBox from "@shared/ui/NoteBox"; +import { allTags, setUserTag, removeUserTag } from "../lib/tags"; + +const { View } = ReactNative; +const { FormSection, FormInput } = Forms; + +function AddTagForm() { + const [userId, setUserId] = React.useState(""); + const [text, setText] = React.useState(""); + const [color, setColor] = React.useState("#5865F2"); + const canSave = !!userId.trim() && !!text.trim(); + + return ( + + + + + { + setUserTag(userId.trim(), { text: text.trim(), color }); + showToast(`Tagged user ${userId.trim()}`, undefined); + setUserId(""); + setText(""); + }} + /> + + ); +} + +export default function Settings() { + useProxy(storage); + const tags = allTags(); + useProxy(tags); + const userIds = Object.keys(tags); + + return ( + + + Long-press a name in the member list or profile popout to tag someone directly, or add + one here by user ID - turn on Developer Mode (Discord Settings → Advanced) to get a + "Copy ID" option when you long-press a user elsewhere. + + + + + ({ + key: id, + label: tags[id].text, + subLabel: `${id} • Tap to remove`, + onPress: () => { + removeUserTag(id); + showToast("Removed tag", undefined); + } + }))} + /> + + ); +} diff --git a/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx b/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx new file mode 100644 index 0000000..10bba84 --- /dev/null +++ b/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx @@ -0,0 +1,79 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { findByProps } from "@vendetta/metro"; +import { Forms } from "@vendetta/ui/components"; +import { showToast } from "@vendetta/ui/toasts"; +import ColorInput from "@shared/ui/ColorInput"; +import { getUserTag, setUserTag, removeUserTag } from "../lib/tags"; + +const { View } = ReactNative; +const { FormInput } = Forms; + +const ALERT_KEY = "custom-user-tags-editor"; + +// Looked up lazily (not at module scope) since this repo hasn't needed Discord's native alert system +// before - safer to resolve it right when actually opening the editor than to risk an early miss at +// plugin-load time getting permanently cached. +function alertParts() { + const alerts = findByProps("openAlert", "dismissAlert") as any; + const modal = findByProps("AlertModal", "AlertActions") as any; + return { + openAlert: alerts?.openAlert, + dismissAlert: alerts?.dismissAlert, + AlertModal: modal?.AlertModal, + AlertActions: modal?.AlertActions, + AlertActionButton: modal?.AlertActionButton + }; +} + +function TagEditor({ userId, username }: { userId: string; username: string }) { + const existing = getUserTag(userId); + const [text, setText] = React.useState(existing?.text ?? ""); + const [color, setColor] = React.useState(existing?.color ?? "#5865F2"); + const { dismissAlert, AlertModal, AlertActions, AlertActionButton } = alertParts(); + + if (!AlertModal || !AlertActions || !AlertActionButton) return null; + + const save = () => { + if (!text.trim()) { + showToast("Enter some tag text first", undefined); + return; + } + setUserTag(userId, { text: text.trim(), color }); + showToast(`Tagged ${username}`, undefined); + dismissAlert?.(ALERT_KEY); + }; + + const remove = () => { + removeUserTag(userId); + showToast(`Removed ${username}'s tag`, undefined); + dismissAlert?.(ALERT_KEY); + }; + + return ( + + + + + } + actions={ + + + {existing && } + + + } + /> + ); +} + +export default function openTagEditor(userId: string, username: string): void { + const { openAlert } = alertParts(); + if (!openAlert) { + showToast("Couldn't open the tag editor - alert system not found", undefined); + return; + } + openAlert(ALERT_KEY, ); +} diff --git a/site/meta.json b/site/meta.json index 4f4e4a4..c6e5c81 100644 --- a/site/meta.json +++ b/site/meta.json @@ -1,4 +1,21 @@ { + "custom-user-tags": { + "category": "Moderation & Appearance", + "status": "new", + "accent": "#F0637E", + "tagline": "Create your own tags and assign them to specific users - not tied to server permissions.", + "note": "Didn't exist in the Revenge/Vendetta plugin ecosystem before. Modeled directly on Staff Tags' proven 4-surface architecture (the chat message tag, the name row in chat/member list/profile, and the native Tag component itself), keyed by user ID instead of permission tier - the same code, resolving a completely different kind of tag.", + "howItWorks": "Long-press a name in the member list or a profile popout to open a small editor: type whatever text you want and pick a color, and that becomes their tag - showing next to their name in chat, the member list, and the profile popout, exactly like a Staff Tags tag would. There's no confirmed way in this repo yet to hook Discord's own native long-press menu for a user, so this attaches its own long-press handler directly to the row instead (chaining whatever Discord's own row already does, never replacing it). Tags can also be added from the plugin's own settings screen by pasting a user ID directly, for tagging someone who isn't visible on screen right now.", + "features": [ + "Long-press a member list row or profile popout to tag someone", + "Custom tag text and a full color picker, not limited to preset tiers", + "Also shows in chat messages next to the author's name", + "Add/remove tags by user ID from the settings screen", + "Per-user, not per-permission - works for anyone regardless of their server roles" + ], + "commands": [], + "limitations": "Chat message tags are built from plain data passed to Discord's native row renderer, not a patchable React element, so they always render as a solid color there - same platform limitation Staff Tags documents. Long-press to open the editor is a plugin-attached handler, not a native Discord menu item, since no confirmed way to add one exists yet." + }, "staff-tags": { "category": "Moderation & Appearance", "status": "rebuilt",