diff --git a/docs/superpowers/specs/2026-08-16-custom-svg-tags-design.md b/docs/superpowers/specs/2026-08-16-custom-svg-tags-design.md new file mode 100644 index 0000000..31f294d --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-custom-svg-tags-design.md @@ -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 → ``, 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 `` 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 ` | 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 || !/^ { diff --git a/plugins/custom-user-tags/src/patches/chat.ts b/plugins/custom-user-tags/src/patches/chat.ts index eb5ba8a..c271434 100644 --- a/plugins/custom-user-tags/src/patches/chat.ts +++ b/plugins/custom-user-tags/src/patches/chat.ts @@ -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 }; }); }; diff --git a/plugins/custom-user-tags/src/patches/details.tsx b/plugins/custom-user-tags/src/patches/details.tsx index f5a3b59..99463e1 100644 --- a/plugins/custom-user-tags/src/patches/details.tsx +++ b/plugins/custom-user-tags/src/patches/details.tsx @@ -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} /> ); } diff --git a/plugins/custom-user-tags/src/patches/name.tsx b/plugins/custom-user-tags/src/patches/name.tsx index 6a1079b..d9e5bb4 100644 --- a/plugins/custom-user-tags/src/patches/name.tsx +++ b/plugins/custom-user-tags/src/patches/name.tsx @@ -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 { diff --git a/plugins/custom-user-tags/src/patches/tag.tsx b/plugins/custom-user-tags/src/patches/tag.tsx index 3dc1b84..1e36b9e 100644 --- a/plugins/custom-user-tags/src/patches/tag.tsx +++ b/plugins/custom-user-tags/src/patches/tag.tsx @@ -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 = ( diff --git a/plugins/custom-user-tags/src/ui/Icon.tsx b/plugins/custom-user-tags/src/ui/Icon.tsx index 4a616da..07b2c96 100644 --- a/plugins/custom-user-tags/src/ui/Icon.tsx +++ b/plugins/custom-user-tags/src/ui/Icon.tsx @@ -5,6 +5,7 @@ import { IconDef } from "../lib/icons"; const svgModule = findByProps("Svg") as Record | 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 ; + if (!icon.path) return null; const viewBox = icon.viewBox ?? "0 0 24 24"; return ( diff --git a/plugins/custom-user-tags/src/ui/Settings.tsx b/plugins/custom-user-tags/src/ui/Settings.tsx index c3d9cf6..6668f0e 100644 --- a/plugins/custom-user-tags/src/ui/Settings.tsx +++ b/plugins/custom-user-tags/src/ui/Settings.tsx @@ -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 ( @@ -29,20 +25,11 @@ function AddTagForm() { value={userId} onChange={setUserId} /> - - - { - 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())} /> ); @@ -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) }; })} /> diff --git a/plugins/custom-user-tags/src/ui/Tag.tsx b/plugins/custom-user-tags/src/ui/Tag.tsx index b686e94..0f6d0d8 100644 --- a/plugins/custom-user-tags/src/ui/Tag.tsx +++ b/plugins/custom-user-tags/src/ui/Tag.tsx @@ -22,7 +22,7 @@ interface TagProps { export default function CustomTag({ tag, style }: TagProps) { return ( - {tag.icon && tag.icon.path && } + {tag.icon && (tag.icon.path || tag.icon.svg) && } {!!tag.text && {tag.text}} ); diff --git a/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx b/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx index 299b87a..e0128d3 100644 --- a/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx +++ b/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx @@ -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={ - + { + setIcon(v); + if (v !== "none") setSvg(""); + }} + color={color} + /> + { + setSvg(v); + if (v) setIcon("none"); + }} + /> + {!!svg && ( + + )} + } diff --git a/plugins/staff-tags/src/lib/getTag.ts b/plugins/staff-tags/src/lib/getTag.ts index fc2eaa3..34cb2ae 100644 --- a/plugins/staff-tags/src/lib/getTag.ts +++ b/plugins/staff-tags/src/lib/getTag.ts @@ -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, diff --git a/plugins/staff-tags/src/lib/icons.ts b/plugins/staff-tags/src/lib/icons.ts index a248aab..5adf92a 100644 --- a/plugins/staff-tags/src/lib/icons.ts +++ b/plugins/staff-tags/src/lib/icons.ts @@ -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 | 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 || !/^ { 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 }; }); }; diff --git a/plugins/staff-tags/src/patches/details.tsx b/plugins/staff-tags/src/patches/details.tsx index a270b71..26f7815 100644 --- a/plugins/staff-tags/src/patches/details.tsx +++ b/plugins/staff-tags/src/patches/details.tsx @@ -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} /> ); } diff --git a/plugins/staff-tags/src/patches/name.tsx b/plugins/staff-tags/src/patches/name.tsx index 1d46494..ff4c28e 100644 --- a/plugins/staff-tags/src/patches/name.tsx +++ b/plugins/staff-tags/src/patches/name.tsx @@ -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} /> ); } diff --git a/plugins/staff-tags/src/patches/tag.tsx b/plugins/staff-tags/src/patches/tag.tsx index 3dc1b84..1e36b9e 100644 --- a/plugins/staff-tags/src/patches/tag.tsx +++ b/plugins/staff-tags/src/patches/tag.tsx @@ -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 = ( diff --git a/plugins/staff-tags/src/ui/GradientTag.tsx b/plugins/staff-tags/src/ui/GradientTag.tsx index e7db5fc..bad4535 100644 --- a/plugins/staff-tags/src/ui/GradientTag.tsx +++ b/plugins/staff-tags/src/ui/GradientTag.tsx @@ -30,7 +30,7 @@ export default function GradientTag({ text, textColor, backgroundColor, gradient const Gradient = gradientColor ? getGradientComponent() : null; const tagContent = ( <> - {icon?.path && } + {(icon?.path || icon?.svg) && } {text} ); diff --git a/plugins/staff-tags/src/ui/Icon.tsx b/plugins/staff-tags/src/ui/Icon.tsx index 4a616da..07b2c96 100644 --- a/plugins/staff-tags/src/ui/Icon.tsx +++ b/plugins/staff-tags/src/ui/Icon.tsx @@ -5,6 +5,7 @@ import { IconDef } from "../lib/icons"; const svgModule = findByProps("Svg") as Record | 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 ; + if (!icon.path) return null; const viewBox = icon.viewBox ?? "0 0 24 24"; return ( diff --git a/plugins/staff-tags/src/ui/pages/Settings.tsx b/plugins/staff-tags/src/ui/pages/Settings.tsx index 70fd718..de4f230 100644 --- a/plugins/staff-tags/src/ui/pages/Settings.tsx +++ b/plugins/staff-tags/src/ui/pages/Settings.tsx @@ -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 { settings.icon = v === "none" ? undefined : v; }} + onChange={(v: string) => { + settings.icon = v === "none" ? undefined : v; + if (v !== "none") settings.customSvg = undefined; + }} color={activeColor} /> + { + settings.customSvg = v || undefined; + if (v) settings.icon = undefined; + }} + /> + {!!settings.customSvg && !isValidCustomSvg(settings.customSvg) && ( + Invalid SVG markup - this tag will show without an icon until it's fixed. + )} + {!!settings.customSvg && ( + { settings.customSvgFallback = v; }} + /> + )} + { settings.iconOnly = v; }} + /> @@ -84,9 +120,30 @@ export default function Settings() { onValueChange={(v: boolean) => { storage.useRoleColor = v; }} /> - {TAG_DEFINITIONS.map((def) => ( - - ))} + + {TAG_DEFINITIONS.map((def) => { + const settings = tagSettings(def.id); + const iconFallback = getIcon(settings.icon)?.fallback; + const label = iconFallback ? `${iconFallback} ${def.defaultText}` : def.defaultText; + + return ( + } + onPress={() => navigation.navigate("VendettaCustomPage", { + title: def.defaultText, + render: () => ( + + + + ) + })} + /> + ); + })} + ); }