Add Custom User Tags: create your own tags and assign them to specific users

This commit is contained in:
Ally Founder 2026-08-02 19:20:42 -04:00
parent 51e34bbdb5
commit 36d7d2a621
12 changed files with 434 additions and 0 deletions

View file

@ -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/` |

View file

@ -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"
}

View file

@ -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
};

View file

@ -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 };
}

View file

@ -0,0 +1,24 @@
import { storage } from "@vendetta/plugin";
export interface UserTag {
text: string;
color: string;
}
export function allTags(): Record<string, UserTag> {
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];
}

View file

@ -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
};
});
};

View file

@ -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(
<TagModule.default
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
verified={false}
/>
);
}
} 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());
};

View file

@ -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(
<TagModule.default
style={{ marginLeft: 0 }}
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
verified={false}
/>
);
} catch {
// Never let one bad lookup take down every name row on screen.
}
});
};

View file

@ -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 });
});
};

View file

@ -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 (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<FormInput
title="User ID"
placeholder="long-press a user in Discord's own menu and Copy ID"
value={userId}
onChange={setUserId}
/>
<FormInput title="Tag text" placeholder="e.g. FRIEND" value={text} onChange={setText} />
<ColorInput title="Color" value={color} onChange={setColor} />
<PrimaryButton
label="Save tag"
disabled={!canSave}
style={{ marginTop: 8 }}
onPress={() => {
setUserTag(userId.trim(), { text: text.trim(), color });
showToast(`Tagged user ${userId.trim()}`, undefined);
setUserId("");
setText("");
}}
/>
</View>
);
}
export default function Settings() {
useProxy(storage);
const tags = allTags();
useProxy(tags);
const userIds = Object.keys(tags);
return (
<SettingsScaffold>
<NoteBox>
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.
</NoteBox>
<FormSection title="Add a tag">
<AddTagForm />
</FormSection>
<ListSection
title="Tagged users"
emptyText="No one's tagged yet."
items={userIds.map((id) => ({
key: id,
label: tags[id].text,
subLabel: `${id} • Tap to remove`,
onPress: () => {
removeUserTag(id);
showToast("Removed tag", undefined);
}
}))}
/>
</SettingsScaffold>
);
}

View file

@ -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 (
<AlertModal
title={`Tag ${username}`}
content={
<View>
<FormInput title="Tag text" placeholder="e.g. FRIEND" value={text} onChange={setText} />
<ColorInput title="Color" value={color} onChange={setColor} />
</View>
}
actions={
<AlertActions>
<AlertActionButton text="Save" variant="primary" onPress={save} />
{existing && <AlertActionButton text="Remove tag" variant="secondary" onPress={remove} />}
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActions>
}
/>
);
}
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, <TagEditor userId={userId} username={username} />);
}

View file

@ -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",