Add Custom User Tags: create your own tags and assign them to specific users
This commit is contained in:
parent
51e34bbdb5
commit
36d7d2a621
12 changed files with 434 additions and 0 deletions
|
|
@ -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/` |
|
||||
|
|
|
|||
8
plugins/custom-user-tags/manifest.json
Normal file
8
plugins/custom-user-tags/manifest.json
Normal 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"
|
||||
}
|
||||
25
plugins/custom-user-tags/src/index.ts
Normal file
25
plugins/custom-user-tags/src/index.ts
Normal 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
|
||||
};
|
||||
20
plugins/custom-user-tags/src/lib/resolveTag.ts
Normal file
20
plugins/custom-user-tags/src/lib/resolveTag.ts
Normal 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 };
|
||||
}
|
||||
24
plugins/custom-user-tags/src/lib/tags.ts
Normal file
24
plugins/custom-user-tags/src/lib/tags.ts
Normal 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];
|
||||
}
|
||||
29
plugins/custom-user-tags/src/patches/chat.ts
Normal file
29
plugins/custom-user-tags/src/patches/chat.ts
Normal 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
|
||||
};
|
||||
});
|
||||
};
|
||||
77
plugins/custom-user-tags/src/patches/details.tsx
Normal file
77
plugins/custom-user-tags/src/patches/details.tsx
Normal 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());
|
||||
};
|
||||
53
plugins/custom-user-tags/src/patches/name.tsx
Normal file
53
plugins/custom-user-tags/src/patches/name.tsx
Normal 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.
|
||||
}
|
||||
});
|
||||
};
|
||||
23
plugins/custom-user-tags/src/patches/tag.tsx
Normal file
23
plugins/custom-user-tags/src/patches/tag.tsx
Normal 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 });
|
||||
});
|
||||
};
|
||||
78
plugins/custom-user-tags/src/ui/Settings.tsx
Normal file
78
plugins/custom-user-tags/src/ui/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
Normal file
79
plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
Normal 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} />);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue