Add Staff Tags plugin: rebuilt for current Revenge/Discord, with per-tag colors, gradients, and visibility

This commit is contained in:
everestmcarthur 2026-08-01 09:39:03 -04:00
commit 17395fbbf5
17 changed files with 5185 additions and 0 deletions

39
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: Build and deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Prepare environment
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install deps
run: npm install
- name: Build plugin(s)
run: node ./build.mjs
- name: Copy additional files
run: |
cp README.md dist/README.md
printf -- "---\npermalink: /404.html\n---\n" > dist/404.md
printf -- "> **Note:** You accessed a link that returned a 404, probably by clicking one of the plugin links. You're supposed to copy the link address and add it into Revenge.\n\n" >> dist/404.md
cat README.md >> dist/404.md
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
enable_jekyll: true

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# revenge-plugins
A small collection of plugins for [Revenge](https://github.com/revenge-mod/revenge-bundle), the Discord Android client mod.
## Installing a plugin
1. Open Discord with Revenge installed, go to **Settings → Plugins**.
2. Tap the `+` button and paste the plugin's install link (see below).
3. Enable it.
| Plugin | Description | Install link |
| --- | --- | --- |
| Staff Tags | Adds OWNER/ADMIN/STAFF/MOD-style tags next to members, based on their actual server permissions. Fully customizable: per-tag text, colors, gradients, and visibility. | `https://everestmcarthur.github.io/revenge-plugins/staff-tags/` |
## About Staff Tags
This is a rebuilt version of the old `staff-tags` plugin, which stopped working after Discord renamed some of the
internal components it depended on (`DisplayName`, `HeaderName`, `getTagProperties`) - when any one of those lookups
came back empty, the entire plugin used to fail to load, taking every tag down with it, everywhere.
This version isolates each patch (chat tags, member list, profile, channel header) so that if Discord changes one of
them again in the future, only that specific surface stops showing tags instead of the whole plugin breaking. It also
adds:
- Per-tag show/hide toggles
- Custom tag text
- Custom solid colors
- Gradient tags (member list & profile only - chat message tags are rendered from data rather than a
patchable element, so they always use a solid color)
- "Use top role color" as a fallback when no custom color is set
Tags are computed from the built-in `computePermissions` API against real permission bits (Administrator, Manage
Server, Manage Messages, Kick/Ban, etc.), the same as the original plugin, not from role names.
## Building locally
```sh
npm install
npm run build
```
Output is written to `dist/<plugin>/`.
## Contributing
Issues and PRs are welcome, especially if a Discord update breaks a lookup a plugin relies on - `findByName` /
`findByProps` targets can and do change without notice.

93
build.mjs Normal file
View file

@ -0,0 +1,93 @@
import { readFile, writeFile, readdir } from "fs/promises";
import { extname } from "path";
import { createHash } from "crypto";
import { rollup } from "rollup";
import url from "@rollup/plugin-url";
import esbuild from "rollup-plugin-esbuild";
import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import swc from "@swc/core";
const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"];
/** @type import("rollup").InputPluginOption */
const plugins = [
nodeResolve(),
commonjs(),
url({
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],
limit: 0,
}),
{
name: "swc",
async transform(code, id) {
const ext = extname(id);
if (!extensions.includes(ext)) return null;
const ts = ext.includes("ts");
const tsx = ts ? ext.endsWith("x") : undefined;
const jsx = !ts ? ext.endsWith("x") : undefined;
const result = await swc.transform(code, {
filename: id,
jsc: {
externalHelpers: true,
parser: {
syntax: ts ? "typescript" : "ecmascript",
tsx,
jsx,
},
},
env: {
targets: "defaults",
include: [
"transform-classes",
"transform-arrow-functions",
],
},
});
return result.code;
},
},
esbuild({ minify: true }),
];
for (const plug of await readdir("./plugins")) {
const manifest = JSON.parse(await readFile(`./plugins/${plug}/manifest.json`));
const outPath = `./dist/${plug}/index.js`;
try {
const bundle = await rollup({
input: `./plugins/${plug}/${manifest.main}`,
onwarn: () => {},
plugins,
});
await bundle.write({
file: outPath,
globals(id) {
if (id.startsWith("@vendetta")) return id.substring(1).replace(/\//g, ".");
const map = {
react: "window.React",
};
return map[id] || null;
},
format: "iife",
compact: true,
exports: "named",
});
await bundle.close();
const toHash = await readFile(outPath);
manifest.hash = createHash("sha256").update(toHash).digest("hex");
manifest.main = "index.js";
await writeFile(`./dist/${plug}/manifest.json`, JSON.stringify(manifest));
console.log(`Successfully built ${manifest.name}!`);
} catch (e) {
console.error(`Failed to build ${plug}...`, e);
process.exit(1);
}
}

4432
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "revenge-plugins",
"private": true,
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@swc/helpers": "^0.5.17"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-url": "latest",
"@swc/core": "^1.3.35",
"esbuild": "^0.16.14",
"rollup": "^3.9.1",
"rollup-plugin-esbuild": "^5.0.0",
"vendetta-types": "latest"
}
}

View file

@ -0,0 +1,11 @@
{
"name": "Staff Tags",
"description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.",
"authors": [
{ "name": "everestmcarthur" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_badge_staff"
}
}

View file

@ -0,0 +1,37 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import patchChat from "./patches/chat";
import patchDetails from "./patches/details";
import patchName from "./patches/name";
import patchTag from "./patches/tag";
import Settings from "./ui/pages/Settings";
let patches: (() => void)[] = [];
// Discord occasionally renames/removes the internals a single surface (chat, member list, etc.)
// depends on. Isolating each patch means one broken lookup only disables that surface instead of
// taking the whole plugin down, which is what happened to the old build of this plugin.
function safePatch(name: string, apply: () => () => void): () => void {
try {
return apply();
} catch (e) {
logger.error(`[Staff Tags] Failed to apply the "${name}" patch, that surface will be skipped: ${e}`);
return () => {};
}
}
export default {
onLoad: () => {
storage.useRoleColor ??= false;
storage.tags ??= {};
patches = [
safePatch("tag", patchTag),
safePatch("chat", patchChat),
safePatch("name", patchName),
safePatch("details", patchDetails)
];
},
onUnload: () => patches.forEach((unpatch) => unpatch()),
settings: Settings
};

View file

@ -0,0 +1,119 @@
import { findByProps, findByStoreName } from "@vendetta/metro";
import { chroma, constants } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { rawColors } from "@vendetta/ui";
const { Permissions } = constants;
const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
const GuildMemberStore = findByStoreName("GuildMemberStore");
export interface TagDefinition {
id: string;
defaultText: string;
defaultColor: string;
condition?: (guild: any, channel: any, user: any) => boolean;
permissions?: string[];
}
export const TAG_DEFINITIONS: TagDefinition[] = [
{ id: "webhook", defaultText: "WEBHOOK", defaultColor: "#99AAB5", condition: (_g, _c, user) => !!user?.isNonUserBot?.() },
{ id: "owner", defaultText: "OWNER", defaultColor: "#F0B232", condition: (guild, _c, user) => !!guild && guild.ownerId === user?.id },
{ id: "admin", defaultText: "ADMIN", defaultColor: "#F23F42", permissions: ["ADMINISTRATOR"] },
{ id: "staff", defaultText: "STAFF", defaultColor: "#23A55A", permissions: ["MANAGE_GUILD", "MANAGE_CHANNELS", "MANAGE_ROLES", "MANAGE_WEBHOOKS"] },
{ id: "mod", defaultText: "MOD", defaultColor: "#5865F2", permissions: ["MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS"] },
{ id: "vc_mod", defaultText: "VC Mod", defaultColor: "#1ABC9C", permissions: ["MOVE_MEMBERS", "MUTE_MEMBERS", "DEAFEN_MEMBERS"] },
{ id: "chat_mod", defaultText: "Chat Mod", defaultColor: "#9B59B6", permissions: ["MODERATE_MEMBERS"] }
];
export interface TagOverride {
enabled?: boolean;
text?: string;
useCustomColor?: boolean;
color?: string;
useGradient?: boolean;
gradientColor?: string;
}
export interface ResolvedTag {
id: string;
text: string;
textColor: any;
backgroundColor: string;
gradientColor?: string;
verified: boolean;
}
const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
/** Lazily initializes and returns the per-tag settings object, backed directly by plugin storage. */
export function tagSettings(id: string): TagOverride {
storage.tags ??= {};
storage.tags[id] ??= {};
return storage.tags[id];
}
function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string {
if (settings.useCustomColor && settings.color && HEX_REGEX.test(settings.color)) {
return settings.color;
}
if (storage.useRoleColor) {
try {
const roleColor = GuildMemberStore?.getMember?.(guild?.id, user?.id)?.colorString;
if (roleColor) return roleColor;
} catch { /* fall through to default */ }
}
return def.defaultColor;
}
export default function getTag(guild: any, channel: any, user: any): ResolvedTag | undefined {
if (!user) return undefined;
let permissions: string[] = [];
if (guild) {
try {
const permissionsInt = permissionsModule?.computePermissions?.({
user,
context: guild,
overwrites: channel?.permissionOverwrites
});
if (permissionsInt != null) {
permissions = Object.entries(Permissions)
.filter(([, bit]) => permissionsInt & (bit as bigint))
.map(([name]) => name);
}
} catch { /* no guild permission context available, treat as none */ }
}
for (const def of TAG_DEFINITIONS) {
const settings = tagSettings(def.id);
if (settings.enabled === false) continue;
const matchesCondition = !!def.condition?.(guild, channel, user);
const matchesPermission = !user.bot && !!def.permissions?.some(p => permissions.includes(p));
if (!matchesCondition && !matchesPermission) continue;
const backgroundColor = resolveBackgroundColor(def, settings, guild, user);
const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500;
let gradientColor: string | undefined;
if (settings.useGradient) {
gradientColor = settings.gradientColor && HEX_REGEX.test(settings.gradientColor)
? settings.gradientColor
: chroma(backgroundColor).brighten(1.4).hex();
}
return {
id: def.id,
text: settings.text?.trim() || def.defaultText,
textColor,
backgroundColor,
gradientColor,
verified: false
};
}
return undefined;
}

View file

@ -0,0 +1,25 @@
import { findByProps } from "@vendetta/metro";
let cached: any;
let attempted = false;
/**
* Discord ships a native gradient view (used for boost/nitro badges) but it isn't part of any
* stable public API, so this is a best-effort lookup that quietly returns null if it can't be found -
* gradient tags then fall back to a solid color instead of crashing anything.
*/
export function getGradientComponent(): any {
if (attempted) return cached;
attempted = true;
try {
cached =
findByProps("colors", "start", "end")?.default ??
findByProps("colors", "locations")?.default ??
null;
} catch {
cached = null;
}
return cached;
}

View file

@ -0,0 +1,32 @@
import { findByName, findByStoreName } from "@vendetta/metro";
import { ReactNative, chroma } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
import getTag from "../lib/getTag";
const getTagProperties = findByName("getTagProperties", false);
const GuildStore = findByStoreName("GuildStore");
const ChannelStore = findByStoreName("ChannelStore");
// Chat message tags are rendered from plain data (not a patchable element tree), so gradients
// aren't possible here - only in the member list and profile, where we control the JSX directly.
export default () => {
if (!getTagProperties) return () => {};
return after("default", getTagProperties, ([{ message }], ret) => {
if (ret?.tagType) return;
const channel = ChannelStore?.getChannel(message?.channel_id);
const guild = GuildStore?.getGuild(channel?.guild_id);
const tag = getTag(guild, channel, message?.author);
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: tag.verified,
tagType: undefined
};
});
};

View file

@ -0,0 +1,78 @@
import { findByProps, findByStoreName, findByTypeNameAll } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import getTag from "../lib/getTag";
import GradientTag from "../ui/GradientTag";
// Covers member list rows and the profile popout, where the name and tag sit on the same line.
const TagModule = findByProps("getBotLabel");
const GuildStore = findByStoreName("GuildStore");
const rowPatch = ([{ guildId, user }]: any[], res: any) => {
try {
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 guild = GuildStore?.getGuild(guildId);
const tag = getTag(guild, undefined, user);
if (!tag) return;
if (existingTag) {
Object.assign(existingTag.props, {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
verified: tag.verified
});
return;
}
if (!Array.isArray(nameContainer.props.children)) {
nameContainer.props.children = [nameContainer.props.children];
}
if (tag.gradientColor) {
nameContainer.props.children.push(
<GradientTag
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
/>
);
} else if (TagModule) {
nameContainer.props.children.push(
<TagModule.default
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
verified={tag.verified}
/>
);
}
} 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,80 @@
import { findByName, findByProps, findByStoreName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import getTag from "../lib/getTag";
import GradientTag from "../ui/GradientTag";
// Covers the name row shown in chat messages (next to the display name + timestamp) and channel headers.
const DisplayName = findByName("DisplayName", false);
const HeaderName = findByName("HeaderName", false);
const TagModule = findByProps("getBotLabel");
const GuildStore = findByStoreName("GuildStore");
const ChannelStore = findByStoreName("ChannelStore");
export default () => {
const patches: (() => void)[] = [];
if (HeaderName) {
patches.push(after("default", HeaderName, ([{ channelId }], ret) => {
if (ret?.props) ret.props.channelId = channelId;
}));
}
if (DisplayName) {
patches.push(after("default", DisplayName, ([{ guildId, channelId, user }], ret) => {
try {
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
// A real built-in tag (bot/system/etc.) is already present - don't touch it.
if (tagComponent && tagComponent.props?.type !== 0) return;
const guild = GuildStore?.getGuild(guildId);
const channel = ChannelStore?.getChannel(channelId);
const tag = getTag(guild, channel, user);
if (!tag) return;
if (tagComponent) {
tagComponent.props = {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
verified: tag.verified
};
return;
}
const row = findInReactTree(ret, (c) => c?.props?.style?.flexDirection === "row");
if (!Array.isArray(row?.props?.children)) return;
if (tag.gradientColor) {
row.props.children.push(
<GradientTag
style={{ marginLeft: 0 }}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
/>
);
} else if (TagModule) {
row.props.children.push(
<TagModule.default
style={{ marginLeft: 0 }}
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
verified={tag.verified}
/>
);
}
} catch {
// Never let one bad lookup take down every name row on screen.
}
}));
}
return () => patches.forEach((unpatch) => unpatch());
};

View file

@ -0,0 +1,22 @@
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.
*/
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,45 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { getGradientComponent } from "../lib/gradient";
const { View, Text } = ReactNative;
const baseStyle = {
flexDirection: "row",
alignItems: "center",
borderRadius: 4,
paddingHorizontal: 4,
marginLeft: 4,
overflow: "hidden"
};
interface GradientTagProps {
text: string;
textColor: any;
backgroundColor: string;
gradientColor?: string;
style?: any;
}
export default function GradientTag({ text, textColor, backgroundColor, gradientColor, style }: GradientTagProps) {
const textStyle = { color: textColor, fontSize: 11, fontWeight: "700" };
const Gradient = gradientColor ? getGradientComponent() : null;
if (Gradient) {
return (
<Gradient
colors={[backgroundColor, gradientColor]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[baseStyle, style]}
>
<Text style={textStyle}>{text}</Text>
</Gradient>
);
}
return (
<View style={[baseStyle, { backgroundColor }, style]}>
<Text style={textStyle}>{text}</Text>
</View>
);
}

View file

@ -0,0 +1,91 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { Forms } from "@vendetta/ui/components";
import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag";
const { ScrollView, View } = ReactNative;
const { FormSection, FormSwitchRow, FormInput } = Forms;
const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; defaultText: string; defaultColor: string }) {
const settings = tagSettings(id);
useProxy(settings);
const enabled = settings.enabled !== false;
const invalidColor = !!settings.color && !HEX_REGEX.test(settings.color);
const invalidGradient = !!settings.gradientColor && !HEX_REGEX.test(settings.gradientColor);
return (
<FormSection title={defaultText}>
<FormSwitchRow
label="Show this tag"
value={enabled}
onValueChange={(v: boolean) => { settings.enabled = v; }}
/>
<FormInput
title="Tag text"
placeholder={defaultText}
value={settings.text ?? ""}
editable={enabled}
onChange={(v: string) => { settings.text = v; }}
/>
<FormSwitchRow
label="Custom color"
subLabel={`Default: ${defaultColor}`}
value={!!settings.useCustomColor}
disabled={!enabled}
onValueChange={(v: boolean) => { settings.useCustomColor = v; }}
/>
{settings.useCustomColor && (
<FormInput
title="Color (hex)"
placeholder={defaultColor}
value={settings.color ?? ""}
editable={enabled}
onChange={(v: string) => { settings.color = v; }}
helpText={invalidColor ? "Invalid hex color, e.g. #5865F2 - falling back to default for now" : undefined}
/>
)}
<FormSwitchRow
label="Gradient"
subLabel="Member list & profile only, chat tags stay solid"
value={!!settings.useGradient}
disabled={!enabled}
onValueChange={(v: boolean) => { settings.useGradient = v; }}
/>
{settings.useGradient && (
<FormInput
title="Gradient color (hex)"
placeholder="Leave blank to auto-generate"
value={settings.gradientColor ?? ""}
editable={enabled}
onChange={(v: string) => { settings.gradientColor = v; }}
helpText={invalidGradient ? "Invalid hex color, leave blank to auto-generate" : undefined}
/>
)}
</FormSection>
);
}
export default function Settings() {
useProxy(storage);
return (
<ScrollView style={{ flex: 1 }}>
<FormSection title="General">
<FormSwitchRow
label="Use top role color"
subLabel="Used when a tag has no custom color set"
value={!!storage.useRoleColor}
onValueChange={(v: boolean) => { storage.useRoleColor = v; }}
/>
</FormSection>
{TAG_DEFINITIONS.map((def) => (
<TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
))}
<View style={{ height: 24 }} />
</ScrollView>
);
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"jsx": "react-native",
"allowJs": true
},
"include": ["plugins", "node_modules/vendetta-types"]
}