Tags: dedupe, SVG icons, tap-to-edit settings

This commit is contained in:
Ally Founder 2026-08-16 22:32:15 -04:00
parent 7462034a4f
commit 56b745d834
21 changed files with 361 additions and 60 deletions

View file

@ -0,0 +1,121 @@
# Custom SVG tags + icon-only mode (staff-tags, custom-user-tags)
Date: 2026-08-16
Repos/plugins: `staff-tags`, `custom-user-tags` (classic, `/root/revenge-plugins`)
## Problem
Both plugins currently support only a fixed set of ~50 preset monochrome icons (`lib/icons.ts`,
rendered as a single SVG `path` via `ui/Icon.tsx`). Users want to supply their own SVG artwork for
a tag's icon, and want an easy way to show only the icon with no text label at all — today a tag
always falls back to `defaultText`/its stored text and never renders truly empty.
## Feasibility (confirmed live against a connected 340.13 client)
`findByProps("Svg")` — the module both plugins already use for `Svg`/`Path` — also exports
`SvgXml`/`SvgFromXml`/`SvgUri`/`SvgCss` and the full `react-native-svg` primitive set (`Circle`,
`Rect`, `G`, gradients, etc.). Discord bundles the complete library, not just the two primitives
currently used. Rendering arbitrary user-supplied SVG markup via `SvgXml` requires no new
dependency.
## Decisions (from user)
- **Input method:** paste raw SVG markup into a text field. No native file/document picker — avoids
depending on an RN module that isn't confirmed available in Revenge's plugin sandbox.
- **Coloring:** a custom SVG keeps its own authored colors. The tag's color/gradient settings only
affect the text label and background chip, not the SVG's internal fills. (Preset icons keep their
existing behavior: force-tinted to the tag's icon color.)
- **Icon-only toggle:** one general "Icon only (hide text)" switch that works for both preset icons
and custom SVGs, not a custom-SVG-specific feature.
## Data model changes
`staff-tags/src/lib/getTag.ts` — `TagOverride` interface gains:
```ts
customSvg?: string; // raw SVG markup, mutually exclusive with `icon` (preset id)
customSvgFallback?: string; // short text shown in chat, where icons can't render at all
iconOnly?: boolean;
```
`custom-user-tags/src/lib/tags.ts` — `UserTag` interface gains the same three fields.
Setting `customSvg` in the editor UI clears `icon` (and vice versa) — a tag has at most one icon
source at a time. Nothing in storage besides these two fields changes.
## Icon representation and rendering
Extend `IconDef` (`lib/icons.ts`, both plugins) to represent either a preset or a custom SVG:
```ts
export interface IconDef {
id: string;
name: string;
fallback: string;
path?: string; // preset: path data
viewBox?: string; // preset: defaults to "0 0 24 24"
svg?: string; // custom: full raw markup
}
```
`getIcon(id)` is unchanged (still resolves preset ids from `ICONS`). A new path in
`getTag.ts`/`resolveTag.ts`: if `settings.customSvg`/`tag.customSvg` is set, build the resolved
tag's `icon` as `{ id: "custom", name: "Custom", fallback: settings.customSvgFallback ?? "",
svg: settings.customSvg }` instead of calling `getIcon(settings.icon)`.
`ui/Icon.tsx` branches on which field is present:
- `icon.svg` set → `<SvgXml xml={icon.svg} width={size} height={size} style={style} />`, no `fill`
prop (preserves the SVG's own colors). Wrapped in try/catch — a malformed paste renders nothing
instead of throwing, consistent with how the rest of these patches degrade on bad input.
- `icon.path` set → existing `<Svg><Path d={icon.path} /></Svg>` behavior, unchanged, still tinted
via the `color` prop.
Every existing call site (`patches/tag.tsx`'s chat-tag styling, `ui/GradientTag.tsx`) already just
passes `icon` and `color` through to `Icon` generically — no changes needed there.
## Icon-only text suppression
`getTag.ts` currently resolves text as `settings.text?.trim() || def.defaultText` — never actually
empty. When `settings.iconOnly`/`tag.iconOnly` is true, resolve `text: ""` instead, skipping the
`defaultText` fallback. This requires an icon (preset or custom) to be set — the editor UI disables
the toggle when no icon is selected, since an empty-text/no-icon tag would render nothing.
This already flows correctly with no further changes:
- Name row / member list / profile (JSX surfaces): render the icon with an empty label.
- Chat (`patches/chat.ts`, flat-data surface, can never render a real icon — pre-existing platform
limit both plugins already document): existing ternary
`tag.icon ? (tag.text ? icon+text : icon.fallback) : tag.text` already falls through to
`icon.fallback` when text is empty. Preset icons already have a `fallback` glyph. Custom SVGs
don't, so the editor gets an optional short "fallback text for chat" input next to the paste box
(stored as `customSvgFallback?: string`) — if left blank, chat shows an empty-but-colored chip.
Acceptable degraded case; not worth blocking on.
## Input validation
On save, before writing `customSvg` to storage: trim whitespace, reject (inline error, don't save)
if empty or doesn't start with `<svg` (case-insensitive), cap at 20,000 characters. No deeper XML
parsing/validation at save time — the render-time try/catch around `SvgXml` is the actual safety
net against malformed-but-passes-the-basic-check markup.
## UI changes
`staff-tags/src/ui/pages/Settings.tsx` and `custom-user-tags/src/ui/TagEditorAlert.tsx` (each
plugin's real per-tag editor — confirmed these are where `IconPicker`/`ColorInput` are currently
wired in, not any shared file):
- A multiline text input for pasting SVG markup, next to the existing `IconPicker`. Selecting a
preset icon clears `customSvg`; entering SVG markup clears `icon`.
- A short "fallback text for chat" input, shown only when `customSvg` is set.
- An "Icon only (hide text)" switch, disabled unless an icon (preset or custom) is currently set.
## Files touched (9 files: 4 in staff-tags, 5 in custom-user-tags — custom-user-tags splits
resolution across two files where staff-tags has one)
- `lib/icons.ts` — `IconDef` shape
- `lib/getTag.ts` (staff-tags) / `lib/resolveTag.ts` + `lib/tags.ts` (custom-user-tags) — storage
fields, resolution logic
- `ui/Icon.tsx` — `SvgXml` render branch
- `ui/pages/Settings.tsx` (staff-tags) / `ui/TagEditorAlert.tsx` (custom-user-tags) — paste field,
fallback-text field, icon-only switch
## Explicitly out of scope
- File/document picker upload.
- Recoloring or otherwise transforming a custom SVG's internal fills.
- Any change to `patches/name.tsx`, `patches/details.tsx`, `patches/profile.tsx`, or
`patches/tag.tsx` — they already consume `icon`/`text` generically and need no changes.

View file

@ -1,9 +1,27 @@
import { findByProps } from "@vendetta/metro";
export interface IconDef {
id: string;
name: string;
path: string;
fallback: string;
path?: string;
viewBox?: string;
svg?: string;
}
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
export const MAX_CUSTOM_SVG_LENGTH = 20000;
export function isValidCustomSvg(markup: string | undefined): boolean {
const trimmed = markup?.trim();
if (!trimmed || trimmed.length > MAX_CUSTOM_SVG_LENGTH || !/^<svg/i.test(trimmed)) return false;
try {
svgModule?.parse?.(trimmed);
return true;
} catch {
return false;
}
}
export const ICONS: IconDef[] = [

View file

@ -1,8 +1,8 @@
import { chroma } from "@vendetta/metro/common";
import { rawColors } from "@vendetta/ui";
import { isValidHex } from "@shared/lib/color";
import { getUserTag } from "./tags";
import { getIcon, IconDef } from "./icons";
import { getUserTag, UserTag } from "./tags";
import { getIcon, isValidCustomSvg, IconDef } from "./icons";
export interface ResolvedTag {
text: string;
@ -12,16 +12,23 @@ export interface ResolvedTag {
iconColor: any;
}
function resolveIcon(tag: UserTag): IconDef | undefined {
if (tag.customSvg && isValidCustomSvg(tag.customSvg)) {
return { id: "custom", name: "Custom", fallback: tag.customSvgFallback?.trim() || "", svg: tag.customSvg.trim() };
}
return getIcon(tag.icon);
}
export default function resolveTag(userId: string | undefined): ResolvedTag | undefined {
const tag = getUserTag(userId);
if ((!tag?.text && !tag?.icon) || !isValidHex(tag.color)) return undefined;
if ((!tag?.text && !tag?.icon && !tag?.customSvg) || !isValidHex(tag.color)) return undefined;
const backgroundColor = tag.color;
const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500;
const icon = getIcon(tag.icon);
const icon = resolveIcon(tag);
return {
text: tag.text || "",
text: tag.iconOnly && icon ? "" : tag.text || "",
textColor,
backgroundColor,
icon,

View file

@ -4,6 +4,9 @@ export interface UserTag {
text: string;
color: string;
icon?: string;
customSvg?: string;
customSvgFallback?: string;
iconOnly?: boolean;
}
export function allTags(): Record<string, UserTag> {

View file

@ -12,7 +12,7 @@ export default () => {
if (!getTagProperties) return () => {};
return after("default", getTagProperties, ([{ message }], ret) => {
if (ret?.tagType) return;
if (ret?.tagType || ret?.__revengeCustomTag) return;
const tag = resolveTag(message?.author?.id);
if (!tag) return;
@ -29,7 +29,8 @@ export default () => {
tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined,
tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()),
tagVerified: false,
tagType: undefined
tagType: undefined,
__revengeCustomTag: true
};
});
};

View file

@ -29,6 +29,8 @@ const rowPatch = ([{ user }]: any[], res: any) => {
);
if (!nameContainer) return;
if (findInReactTree(nameContainer, (c) => c?.props?.__revengeCustomTag)) return;
const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
@ -43,7 +45,8 @@ const rowPatch = ([{ user }]: any[], res: any) => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: false
verified: false,
__revengeCustomTag: true
});
return;
}
@ -62,6 +65,7 @@ const rowPatch = ([{ user }]: any[], res: any) => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
__revengeCustomTag={true}
/>
);
}

View file

@ -14,6 +14,8 @@ export default () => {
return after("DisplayName", DisplayNameModule, ([{ user }]: any[], ret: any) => {
try {
if (findInReactTree(ret, (c) => c?.props?.__revengeCustomTag)) return;
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
if (tagComponent && tagComponent.props?.type !== 0) return;
@ -28,7 +30,8 @@ export default () => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: false
verified: false,
__revengeCustomTag: true
};
return;
}
@ -46,6 +49,7 @@ export default () => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
__revengeCustomTag={true}
/>
);
} catch {

View file

@ -20,7 +20,7 @@ export default () => {
if (textColor) label.props.style?.push?.({ color: textColor });
if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor });
if (icon?.path) {
if (icon?.path || icon?.svg) {
const style = label.props.style ?? {};
ret.props.children = (
<View style={{ flexDirection: "row", alignItems: "center" }}>

View file

@ -5,6 +5,7 @@ import { IconDef } from "../lib/icons";
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
const Svg = svgModule?.Svg ?? (() => null);
const Path = svgModule?.Path ?? (() => null);
const SvgXml = svgModule?.SvgXml ?? (() => null);
interface IconProps {
icon: IconDef;
@ -14,6 +15,9 @@ interface IconProps {
}
export default function Icon({ icon, size = 12, color = "#fff", style }: IconProps) {
// Custom SVGs keep their own authored colors - no fill override, unlike preset icons below.
if (icon.svg) return <SvgXml xml={icon.svg} width={size} height={size} style={style} />;
if (!icon.path) return null;
const viewBox = icon.viewBox ?? "0 0 24 24";
return (

View file

@ -2,24 +2,20 @@ import { React, ReactNative } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin";
import { TableRowGroup, TextInput } from "@shared/ui/table";
import { showToast } from "@vendetta/ui/toasts";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import ListSection from "@shared/ui/ListSection";
import PrimaryButton from "@shared/ui/PrimaryButton";
import NoteBox from "@shared/ui/NoteBox";
import { allTags, setUserTag, removeUserTag } from "../lib/tags";
import { allTags } from "../lib/tags";
import { getIcon } from "../lib/icons";
import ColorInput from "./ColorInput";
import IconPicker from "./IconPicker";
import openTagEditor from "./TagEditorAlert";
const { View } = ReactNative;
// Opens the same rich editor used for long-press tagging (full SVG/icon-only support) instead of
// keeping a second, simpler add-tag form here that would drift out of sync with it.
function AddTagForm() {
const [userId, setUserId] = React.useState("");
const [text, setText] = React.useState("");
const [color, setColor] = React.useState("#5865F2");
const [icon, setIcon] = React.useState("none");
const canSave = !!userId.trim() && (!!text.trim() || icon !== "none");
return (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
@ -29,20 +25,11 @@ function AddTagForm() {
value={userId}
onChange={setUserId}
/>
<TextInput label="Tag text (optional if icon is set)" placeholder="e.g. FRIEND" value={text} onChange={setText} />
<IconPicker title="Icon" value={icon} onChange={setIcon} color={color} />
<ColorInput title="Color" value={color} onChange={setColor} />
<PrimaryButton
label="Save tag"
disabled={!canSave}
label="Edit tag"
disabled={!userId.trim()}
style={{ marginTop: 8 }}
onPress={() => {
setUserTag(userId.trim(), { text: text.trim(), color, icon: icon === "none" ? undefined : icon });
showToast(`Tagged user ${userId.trim()}`, undefined);
setUserId("");
setText("");
setIcon("none");
}}
onPress={() => openTagEditor(userId.trim(), userId.trim())}
/>
</View>
);
@ -68,16 +55,13 @@ export default function Settings() {
title="Tagged users"
emptyText="No one's tagged yet."
items={userIds.map((id) => {
const icon = getIcon(tags[id].icon)?.fallback;
const label = icon ? `${icon} ${tags[id].text}`.trim() : tags[id].text;
const icon = getIcon(tags[id].icon)?.fallback || tags[id].customSvgFallback;
const label = (icon ? `${icon} ${tags[id].text}` : tags[id].text).trim() || id;
return {
key: id,
label,
subLabel: `${id} • Tap to remove`,
onPress: () => {
removeUserTag(id);
showToast("Removed tag", undefined);
}
subLabel: `${id} • Tap to edit`,
onPress: () => openTagEditor(id, tags[id].text || id)
};
})}
/>

View file

@ -22,7 +22,7 @@ interface TagProps {
export default function CustomTag({ tag, style }: TagProps) {
return (
<View style={[baseStyle, { backgroundColor: tag.backgroundColor }, style]}>
{tag.icon && tag.icon.path && <Icon icon={tag.icon} size={12} color={tag.iconColor} style={{ marginRight: tag.text ? 4 : 0 }} />}
{tag.icon && (tag.icon.path || tag.icon.svg) && <Icon icon={tag.icon} size={12} color={tag.iconColor} style={{ marginRight: tag.text ? 4 : 0 }} />}
{!!tag.text && <Text style={{ color: tag.textColor, fontSize: 11, fontWeight: "700" }}>{tag.text}</Text>}
</View>
);

View file

@ -1,10 +1,11 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { findByProps } from "@vendetta/metro";
import { TextInput } from "@shared/ui/table";
import { TableSwitchRow, TextInput } from "@shared/ui/table";
import { showToast } from "@vendetta/ui/toasts";
import ColorInput from "./ColorInput";
import IconPicker from "./IconPicker";
import { getUserTag, setUserTag, removeUserTag } from "../lib/tags";
import { isValidCustomSvg, MAX_CUSTOM_SVG_LENGTH } from "../lib/icons";
const { View } = ReactNative;
@ -30,16 +31,36 @@ function TagEditor({ userId, username }: { userId: string; username: string }) {
const [text, setText] = React.useState(existing?.text ?? "");
const [color, setColor] = React.useState(existing?.color ?? "#5865F2");
const [icon, setIcon] = React.useState(existing?.icon ?? "none");
const [svg, setSvg] = React.useState(existing?.customSvg ?? "");
const [svgFallback, setSvgFallback] = React.useState(existing?.customSvgFallback ?? "");
const [iconOnly, setIconOnly] = React.useState(!!existing?.iconOnly);
const { dismissAlert, AlertModal, AlertActions, AlertActionButton } = alertParts();
if (!AlertModal || !AlertActions || !AlertActionButton) return null;
const hasIcon = icon !== "none" || !!svg.trim();
const save = () => {
if (!text.trim() && icon === "none") {
if (svg.trim() && !isValidCustomSvg(svg)) {
showToast("Invalid SVG markup", undefined);
return;
}
if (!text.trim() && !hasIcon) {
showToast("Enter some tag text or choose an icon first", undefined);
return;
}
setUserTag(userId, { text: text.trim(), color, icon: icon === "none" ? undefined : icon });
if (iconOnly && !hasIcon) {
showToast("Select an icon before enabling icon-only", undefined);
return;
}
setUserTag(userId, {
text: text.trim(),
color,
icon: svg.trim() ? undefined : icon === "none" ? undefined : icon,
customSvg: svg.trim() || undefined,
customSvgFallback: svgFallback.trim() || undefined,
iconOnly
});
showToast(`Tagged ${username}`, undefined);
dismissAlert?.(ALERT_KEY);
};
@ -56,7 +77,41 @@ function TagEditor({ userId, username }: { userId: string; username: string }) {
content={
<View>
<TextInput label="Tag text" placeholder="e.g. FRIEND" value={text} onChange={setText} />
<IconPicker title="Icon" value={icon} onChange={setIcon} color={color} />
<IconPicker
title="Icon"
value={icon}
onChange={(v: string) => {
setIcon(v);
if (v !== "none") setSvg("");
}}
color={color}
/>
<TextInput
label="Custom SVG"
placeholder="<svg>...</svg>"
value={svg}
multiline
maxLength={MAX_CUSTOM_SVG_LENGTH}
onChange={(v: string) => {
setSvg(v);
if (v) setIcon("none");
}}
/>
{!!svg && (
<TextInput
label="Fallback text for chat"
placeholder="Icons can't render in chat, only member list & profile"
value={svgFallback}
onChange={setSvgFallback}
/>
)}
<TableSwitchRow
label="Icon only"
subLabel="Hide the text label, show just the icon"
value={iconOnly}
disabled={!hasIcon}
onValueChange={setIconOnly}
/>
<ColorInput title="Color" value={color} onChange={setColor} />
</View>
}

View file

@ -3,7 +3,7 @@ import { chroma, constants } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { rawColors } from "@vendetta/ui";
import { isValidHex } from "@shared/lib/color";
import { getIcon, IconDef } from "./icons";
import { getIcon, isValidCustomSvg, IconDef } from "./icons";
const { Permissions } = constants;
const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
@ -31,6 +31,9 @@ export interface TagOverride {
enabled?: boolean;
text?: string;
icon?: string;
customSvg?: string;
customSvgFallback?: string;
iconOnly?: boolean;
useCustomColor?: boolean;
color?: string;
useGradient?: boolean;
@ -55,6 +58,13 @@ export function tagSettings(id: string): TagOverride {
return storage.tags[id];
}
function resolveIcon(settings: TagOverride): IconDef | undefined {
if (settings.customSvg && isValidCustomSvg(settings.customSvg)) {
return { id: "custom", name: "Custom", fallback: settings.customSvgFallback?.trim() || "", svg: settings.customSvg.trim() };
}
return getIcon(settings.icon);
}
function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string {
if (settings.useCustomColor && isValidHex(settings.color)) {
return settings.color;
@ -100,7 +110,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
const backgroundColor = resolveBackgroundColor(def, settings, guild, user);
const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500;
const icon = getIcon(settings.icon);
const icon = resolveIcon(settings);
let gradientColor: string | undefined;
if (settings.useGradient) {
@ -111,7 +121,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
return {
id: def.id,
text: settings.text?.trim() || def.defaultText,
text: settings.iconOnly && icon ? "" : settings.text?.trim() || def.defaultText,
textColor,
backgroundColor,
gradientColor,

View file

@ -1,9 +1,27 @@
import { findByProps } from "@vendetta/metro";
export interface IconDef {
id: string;
name: string;
path: string;
fallback: string;
path?: string;
viewBox?: string;
svg?: string;
}
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
export const MAX_CUSTOM_SVG_LENGTH = 20000;
export function isValidCustomSvg(markup: string | undefined): boolean {
const trimmed = markup?.trim();
if (!trimmed || trimmed.length > MAX_CUSTOM_SVG_LENGTH || !/^<svg/i.test(trimmed)) return false;
try {
svgModule?.parse?.(trimmed);
return true;
} catch {
return false;
}
}
export const ICONS: IconDef[] = [

View file

@ -13,7 +13,7 @@ export default () => {
if (!getTagProperties) return () => {};
return after("default", getTagProperties, ([{ message }], ret) => {
if (ret?.tagType) return;
if (ret?.tagType || ret?.__revengeCustomTag) return;
const channel = ChannelStore?.getChannel(message?.channel_id);
const guild = GuildStore?.getGuild(channel?.guild_id);
@ -32,7 +32,8 @@ export default () => {
tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined,
tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()),
tagVerified: tag.verified,
tagType: undefined
tagType: undefined,
__revengeCustomTag: true
};
});
};

View file

@ -21,6 +21,8 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
);
if (!nameContainer) return;
if (findInReactTree(nameContainer, (c) => c?.props?.__revengeCustomTag)) return;
const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
@ -36,7 +38,8 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: tag.verified
verified: tag.verified,
__revengeCustomTag: true
});
return;
}
@ -54,6 +57,7 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
gradientColor={tag.gradientColor}
icon={tag.icon}
iconColor={tag.iconColor}
__revengeCustomTag={true}
/>
);
} else if (TagModule) {
@ -66,6 +70,7 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={tag.verified}
__revengeCustomTag={true}
/>
);
}

View file

@ -26,6 +26,8 @@ export default () => {
if (DisplayNameModule?.DisplayName) {
patches.push(after("DisplayName", DisplayNameModule, ([{ guildId, channelId, user }], ret) => {
try {
if (findInReactTree(ret, (c) => c?.props?.__revengeCustomTag)) return;
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
// A real built-in tag (bot/system/etc.) is already present - don't touch it.
@ -44,7 +46,8 @@ export default () => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: tag.verified
verified: tag.verified,
__revengeCustomTag: true
};
return;
}
@ -62,6 +65,7 @@ export default () => {
gradientColor={tag.gradientColor}
icon={tag.icon}
iconColor={tag.iconColor}
__revengeCustomTag={true}
/>
);
} else if (TagModule) {
@ -75,6 +79,7 @@ export default () => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={tag.verified}
__revengeCustomTag={true}
/>
);
}

View file

@ -20,7 +20,7 @@ export default () => {
if (textColor) label.props.style?.push?.({ color: textColor });
if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor });
if (icon?.path) {
if (icon?.path || icon?.svg) {
const style = label.props.style ?? {};
ret.props.children = (
<View style={{ flexDirection: "row", alignItems: "center" }}>

View file

@ -30,7 +30,7 @@ export default function GradientTag({ text, textColor, backgroundColor, gradient
const Gradient = gradientColor ? getGradientComponent() : null;
const tagContent = (
<>
{icon?.path && <Icon icon={icon} size={12} color={iconColor ?? textColor} style={{ marginRight: text ? 4 : 0 }} />}
{(icon?.path || icon?.svg) && <Icon icon={icon} size={12} color={iconColor ?? textColor} style={{ marginRight: text ? 4 : 0 }} />}
<Text style={textStyle}>{text}</Text>
</>
);

View file

@ -5,6 +5,7 @@ import { IconDef } from "../lib/icons";
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
const Svg = svgModule?.Svg ?? (() => null);
const Path = svgModule?.Path ?? (() => null);
const SvgXml = svgModule?.SvgXml ?? (() => null);
interface IconProps {
icon: IconDef;
@ -14,6 +15,9 @@ interface IconProps {
}
export default function Icon({ icon, size = 12, color = "#fff", style }: IconProps) {
// Custom SVGs keep their own authored colors - no fill override, unlike preset icons below.
if (icon.svg) return <SvgXml xml={icon.svg} width={size} height={size} style={style} />;
if (!icon.path) return null;
const viewBox = icon.viewBox ?? "0 0 24 24";
return (

View file

@ -1,10 +1,11 @@
import { React } from "@vendetta/metro/common";
import { NavigationNative, React } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
import { TableRow, TableRowArrow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag";
import { getIcon } from "../../lib/icons";
import { getIcon, isValidCustomSvg, MAX_CUSTOM_SVG_LENGTH } from "../../lib/icons";
import ColorInput from "../ColorInput";
import IconPicker from "../IconPicker";
@ -35,9 +36,43 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
<IconPicker
title="Icon"
value={settings.icon ?? "none"}
onChange={(v: string) => { settings.icon = v === "none" ? undefined : v; }}
onChange={(v: string) => {
settings.icon = v === "none" ? undefined : v;
if (v !== "none") settings.customSvg = undefined;
}}
color={activeColor}
/>
<TextInput
label="Custom SVG"
placeholder="<svg>...</svg>"
value={settings.customSvg ?? ""}
multiline
maxLength={MAX_CUSTOM_SVG_LENGTH}
editable={enabled}
onChange={(v: string) => {
settings.customSvg = v || undefined;
if (v) settings.icon = undefined;
}}
/>
{!!settings.customSvg && !isValidCustomSvg(settings.customSvg) && (
<NoteBox>Invalid SVG markup - this tag will show without an icon until it's fixed.</NoteBox>
)}
{!!settings.customSvg && (
<TextInput
label="Fallback text for chat"
placeholder="Icons can't render in chat, only member list & profile"
value={settings.customSvgFallback ?? ""}
editable={enabled}
onChange={(v: string) => { settings.customSvgFallback = v; }}
/>
)}
<TableSwitchRow
label="Icon only"
subLabel="Hide the text label, show just the icon"
value={!!settings.iconOnly}
disabled={!enabled || !(settings.icon || settings.customSvg)}
onValueChange={(v: boolean) => { settings.iconOnly = v; }}
/>
<TableSwitchRow
label="Custom color"
subLabel={`Default: ${defaultColor}`}
@ -73,6 +108,7 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
export default function Settings() {
useProxy(storage);
const navigation = NavigationNative.useNavigation();
return (
<SettingsScaffold>
@ -84,9 +120,30 @@ export default function Settings() {
onValueChange={(v: boolean) => { storage.useRoleColor = v; }}
/>
</TableRowGroup>
{TAG_DEFINITIONS.map((def) => (
<TagSettingsSection key={def.id} id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
))}
<TableRowGroup title="Tags">
{TAG_DEFINITIONS.map((def) => {
const settings = tagSettings(def.id);
const iconFallback = getIcon(settings.icon)?.fallback;
const label = iconFallback ? `${iconFallback} ${def.defaultText}` : def.defaultText;
return (
<TableRow
key={def.id}
label={label}
subLabel={settings.enabled === false ? "Disabled" : "Enabled"}
trailing={<TableRowArrow />}
onPress={() => navigation.navigate("VendettaCustomPage", {
title: def.defaultText,
render: () => (
<SettingsScaffold>
<TagSettingsSection id={def.id} defaultText={def.defaultText} defaultColor={def.defaultColor} />
</SettingsScaffold>
)
})}
/>
);
})}
</TableRowGroup>
</SettingsScaffold>
);
}