Add PronounDB, RoleColorEverywhere revivals + new Message Snippets and Reminders plugins

This commit is contained in:
everestmcarthur 2026-08-01 10:11:49 -04:00
parent 17395fbbf5
commit 570e972a1d
23 changed files with 919 additions and 0 deletions

View file

@ -0,0 +1,8 @@
{
"name": "Message Snippets",
"description": "Save reusable text and send it with /snippet <name>. Manage snippets from the plugin settings, or with /snippet-save, /snippet-delete, /snippet-list.",
"authors": [
{ "name": "everestmcarthur" }
],
"main": "src/index.ts"
}

View file

@ -0,0 +1,67 @@
import { registerCommand } from "@vendetta/commands";
import { showToast } from "@vendetta/ui/toasts";
import { getSnippets, saveSnippet, deleteSnippet } from "./lib/snippets";
// Discord slash-command option type for a plain string argument.
const STRING = 3;
export default function loadCommands(): (() => void)[] {
const unregisterSend = registerCommand({
name: "snippet",
description: "Send a saved text snippet",
options: [{ name: "name", description: "Snippet name", type: STRING, required: true }],
execute: (args) => {
const name = args.find((a) => a.name === "name")?.value;
const text = name ? getSnippets()[name] : undefined;
if (!text) {
showToast(`No snippet named "${name}"`, undefined);
return;
}
return { content: text };
}
});
const unregisterSave = registerCommand({
name: "snippet-save",
description: "Save a text snippet",
options: [
{ name: "name", description: "Snippet name", type: STRING, required: true },
{ name: "text", description: "Snippet content", type: STRING, required: true }
],
execute: (args) => {
const name = args.find((a) => a.name === "name")?.value;
const text = args.find((a) => a.name === "text")?.value;
if (!name || !text) return;
saveSnippet(name, text);
showToast(`Saved snippet "${name}"`, undefined);
}
});
const unregisterDelete = registerCommand({
name: "snippet-delete",
description: "Delete a saved text snippet",
options: [{ name: "name", description: "Snippet name", type: STRING, required: true }],
execute: (args) => {
const name = args.find((a) => a.name === "name")?.value;
if (!name) return;
deleteSnippet(name);
showToast(`Deleted snippet "${name}"`, undefined);
}
});
const unregisterList = registerCommand({
name: "snippet-list",
description: "List your saved snippet names",
options: [],
execute: () => {
const names = Object.keys(getSnippets());
showToast(names.length ? names.join(", ") : "No snippets saved yet", undefined);
}
});
return [unregisterSend, unregisterSave, unregisterDelete, unregisterList];
}

View file

@ -0,0 +1,12 @@
import loadCommands from "./commands";
import Settings from "./ui/Settings";
let unregisterFns: (() => void)[] = [];
export default {
onLoad: () => {
unregisterFns = loadCommands();
},
onUnload: () => unregisterFns.forEach((fn) => fn()),
settings: Settings
};

View file

@ -0,0 +1,14 @@
import { storage } from "@vendetta/plugin";
export function getSnippets(): Record<string, string> {
storage.snippets ??= {};
return storage.snippets;
}
export function saveSnippet(name: string, text: string) {
getSnippets()[name.trim()] = text;
}
export function deleteSnippet(name: string) {
delete getSnippets()[name];
}

View file

@ -0,0 +1,69 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin";
import { Forms } from "@vendetta/ui/components";
import { getSnippets, saveSnippet, deleteSnippet } from "../lib/snippets";
const { ScrollView, View, TouchableOpacity } = ReactNative;
const { FormSection, FormRow, FormInput, FormText } = Forms;
function NewSnippetForm() {
const [name, setName] = React.useState("");
const [text, setText] = React.useState("");
const canSave = !!name.trim() && !!text.trim();
return (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<FormInput title="Name" placeholder="e.g. rules" value={name} onChange={setName} />
<FormInput title="Text" placeholder="What /snippet should send" value={text} onChange={setText} multiline />
<TouchableOpacity
disabled={!canSave}
onPress={() => {
saveSnippet(name.trim(), text);
setName("");
setText("");
}}
style={{
marginTop: 8,
backgroundColor: "#5865F2",
borderRadius: 8,
padding: 10,
alignItems: "center",
opacity: canSave ? 1 : 0.5
}}
>
<FormText style={{ color: "white" }}>Save snippet</FormText>
</TouchableOpacity>
</View>
);
}
export default function Settings() {
useProxy(storage);
const snippets = getSnippets();
useProxy(snippets);
const names = Object.keys(snippets);
return (
<ScrollView style={{ flex: 1 }}>
<FormSection title="Add a snippet">
<NewSnippetForm />
</FormSection>
<FormSection title="Saved snippets">
{names.length === 0 && (
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}>
None yet. Add one above, then send it anywhere with /snippet name.
</FormText>
)}
{names.map((name) => (
<FormRow
key={name}
label={name}
subLabel={`${snippets[name].slice(0, 60)}${snippets[name].length > 60 ? "…" : ""} • Tap to delete`}
onPress={() => deleteSnippet(name)}
/>
))}
</FormSection>
</ScrollView>
);
}

View file

@ -0,0 +1,11 @@
{
"name": "PronounDB",
"description": "Shows a user's pronouns (from pronoundb.org) in their profile.",
"authors": [
{ "name": "everestmcarthur" }
],
"main": "src/index.tsx",
"vendetta": {
"icon": "ic_group"
}
}

View file

@ -0,0 +1,44 @@
import { logger } from "@vendetta";
import { find } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import PronounSection from "./ui/PronounSection";
const UserProfile = find((m: any) => m?.type?.name === "UserProfile");
function patchProfile(): () => void {
if (!UserProfile) return () => {};
return after("type", UserProfile, (_: any, res: any) => {
try {
const bioSection = findInReactTree(res, (r) =>
Array.isArray(r?.props?.children) &&
r.props.children.some((c: any) => c?.type?.name === "UserProfileBio")
);
const children = bioSection?.props?.children;
if (!Array.isArray(children)) return;
const withProfile = children.find((c: any) => typeof c?.props?.displayProfile?.userId === "string");
const userId = withProfile?.props?.displayProfile?.userId;
if (!userId) return;
children.unshift(<PronounSection userId={userId} />);
} catch {
// One broken profile card render shouldn't spam errors on every profile open.
}
});
}
let unpatch: () => void = () => {};
export default {
onLoad: () => {
try {
unpatch = patchProfile();
} catch (e) {
logger.error(`[PronounDB] Failed to apply the profile patch: ${e}`);
}
},
onUnload: () => unpatch()
};

View file

@ -0,0 +1,74 @@
import { safeFetch } from "@vendetta/utils";
// PronounDB's v1 API (used by the original version of this plugin) has since been shut down (HTTP 410) -
// this uses their current v2 API instead.
const PRONOUN_LABELS: Record<string, string> = {
he: "he/him",
she: "she/her",
they: "they/them",
it: "it/its",
any: "any",
other: "other",
ask: "ask",
avoid: "avoid, use name"
};
function formatCodes(codes: string[] | undefined): string | undefined {
if (!codes?.length) return undefined;
return codes.map(c => PRONOUN_LABELS[c] ?? c).join(" / ");
}
const cache = new Map<string, string | null>();
const listeners = new Map<string, Set<(value: string | undefined) => void>>();
let queue = new Set<string>();
let flushTimer: any;
function notify(id: string, value: string | undefined) {
listeners.get(id)?.forEach(cb => cb(value));
listeners.delete(id);
}
async function flush() {
const ids = [...queue];
queue = new Set();
if (!ids.length) return;
try {
const res = await safeFetch(`https://pronoundb.org/api/v2/lookup?platform=discord&ids=${ids.join(",")}`, {
headers: { Accept: "application/json" }
});
const data = await res.json();
for (const id of ids) {
const display = formatCodes(data?.[id]?.sets?.en);
cache.set(id, display ?? null);
notify(id, display);
}
} catch {
for (const id of ids) {
cache.set(id, null);
notify(id, undefined);
}
}
}
export function getCachedPronouns(userId: string): string | undefined {
return cache.get(userId) ?? undefined;
}
/** Subscribes to a user's pronouns, batching lookups across everything rendering at once. Returns an unsubscribe fn. */
export function fetchPronouns(userId: string, onResult: (value: string | undefined) => void): () => void {
if (cache.has(userId)) {
onResult(cache.get(userId) ?? undefined);
return () => {};
}
if (!listeners.has(userId)) listeners.set(userId, new Set());
listeners.get(userId)!.add(onResult);
queue.add(userId);
clearTimeout(flushTimer);
flushTimer = setTimeout(flush, 150);
return () => listeners.get(userId)?.delete(onResult);
}

View file

@ -0,0 +1,20 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { findByName } from "@vendetta/metro";
import { fetchPronouns, getCachedPronouns } from "../lib/pronounStore";
const { Text } = ReactNative;
const UserProfileSection = findByName("UserProfileSection", false);
export default function PronounSection({ userId }: { userId: string }) {
const [pronouns, setPronouns] = React.useState(() => getCachedPronouns(userId));
React.useEffect(() => fetchPronouns(userId, setPronouns), [userId]);
if (!pronouns || !UserProfileSection) return null;
return (
<UserProfileSection title="Pronouns">
<Text style={{ fontSize: 16 }}>{pronouns}</Text>
</UserProfileSection>
);
}

View file

@ -0,0 +1,8 @@
{
"name": "Reminders",
"description": "Set quick reminders with /remind <time> <text>, e.g. /remind 20m Walk the dog. Fires while Discord is running in the foreground or background - it can't wake the app up if it's been fully closed.",
"authors": [
{ "name": "everestmcarthur" }
],
"main": "src/index.ts"
}

View file

@ -0,0 +1,32 @@
import { registerCommand } from "@vendetta/commands";
import { showToast } from "@vendetta/ui/toasts";
import { parseDuration } from "./lib/duration";
import { addReminder } from "./lib/reminders";
const STRING = 3;
export default function loadCommands(): (() => void)[] {
const unregisterRemind = registerCommand({
name: "remind",
description: "Set a reminder, e.g. /remind 20m Walk the dog",
options: [
{ name: "in", description: "e.g. 10m, 2h, 1d", type: STRING, required: true },
{ name: "text", description: "What to remind you about", type: STRING, required: true }
],
execute: (args) => {
const inValue = args.find((a) => a.name === "in")?.value;
const text = args.find((a) => a.name === "text")?.value;
const delay = inValue ? parseDuration(inValue) : undefined;
if (!delay || !text) {
showToast(`Couldn't parse "${inValue}" - try something like 10m, 2h, or 1d`, undefined);
return;
}
addReminder(text, delay);
showToast(`Reminder set for ${inValue} from now`, undefined);
}
});
return [unregisterRemind];
}

View file

@ -0,0 +1,17 @@
import loadCommands from "./commands";
import { startScheduler, stopScheduler } from "./lib/reminders";
import Settings from "./ui/Settings";
let unregisterFns: (() => void)[] = [];
export default {
onLoad: () => {
unregisterFns = loadCommands();
startScheduler();
},
onUnload: () => {
unregisterFns.forEach((fn) => fn());
stopScheduler();
},
settings: Settings
};

View file

@ -0,0 +1,19 @@
const MULTIPLIERS: Record<string, number> = {
s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000,
m: 60000, min: 60000, mins: 60000, minute: 60000, minutes: 60000,
h: 3600000, hr: 3600000, hrs: 3600000, hour: 3600000, hours: 3600000,
d: 86400000, day: 86400000, days: 86400000,
w: 604800000, week: 604800000, weeks: 604800000
};
/** Parses inputs like "10m", "2 h", "1day" into a millisecond delay. Returns undefined if unparseable. */
export function parseDuration(input: string): number | undefined {
const match = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/);
if (!match) return undefined;
const amount = Number(match[1]);
const multiplier = MULTIPLIERS[match[2]];
if (!multiplier || !Number.isFinite(amount) || amount <= 0) return undefined;
return Math.round(amount * multiplier);
}

View file

@ -0,0 +1,64 @@
import { storage } from "@vendetta/plugin";
import { showToast } from "@vendetta/ui/toasts";
import { showConfirmationAlert } from "@vendetta/ui/alerts";
export interface Reminder {
id: string;
dueAt: number;
text: string;
}
export function getReminders(): Reminder[] {
storage.reminders ??= [];
return storage.reminders;
}
export function addReminder(text: string, delayMs: number) {
getReminders().push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
dueAt: Date.now() + delayMs,
text
});
}
export function removeReminder(id: string) {
const list = getReminders();
const idx = list.findIndex((r) => r.id === id);
if (idx !== -1) list.splice(idx, 1);
}
function fire(reminder: Reminder) {
try {
showConfirmationAlert({
title: "⏰ Reminder",
content: reminder.text,
confirmText: "Got it"
});
} catch {
showToast(`⏰ ${reminder.text}`, undefined);
}
}
let interval: any;
// Pure-JS timers only run while the app process is alive - this can't wake Discord up from fully closed,
// only fire while it's open (foreground or backgrounded-but-running).
export function startScheduler() {
stopScheduler();
interval = setInterval(() => {
const now = Date.now();
const due = getReminders().filter((r) => r.dueAt <= now);
if (!due.length) return;
due.forEach((r) => {
fire(r);
removeReminder(r.id);
});
}, 15000);
}
export function stopScheduler() {
if (interval) clearInterval(interval);
interval = undefined;
}

View file

@ -0,0 +1,54 @@
import { ReactNative } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin";
import { Forms } from "@vendetta/ui/components";
import { getReminders, removeReminder } from "../lib/reminders";
const { ScrollView, View } = ReactNative;
const { FormSection, FormRow, FormText } = Forms;
function formatRemaining(dueAt: number): string {
const ms = dueAt - Date.now();
if (ms <= 0) return "due now";
const mins = Math.round(ms / 60000);
if (mins < 60) return `in ${mins}m`;
const hours = Math.round(mins / 60);
if (hours < 24) return `in ${hours}h`;
return `in ${Math.round(hours / 24)}d`;
}
export default function Settings() {
useProxy(storage);
const reminders = getReminders();
useProxy(reminders);
return (
<ScrollView style={{ flex: 1 }}>
<FormSection title="Pending reminders">
{reminders.length === 0 && (
<FormText style={{ marginHorizontal: 16, marginVertical: 8 }}>
None yet. Set one from any chat with /remind, e.g. "/remind 20m Walk the dog".
</FormText>
)}
{reminders.map((r) => (
<FormRow
key={r.id}
label={r.text}
subLabel={`${formatRemaining(r.dueAt)} • Tap to cancel`}
onPress={() => removeReminder(r.id)}
/>
))}
</FormSection>
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<FormText>
Reminders fire while Discord is open (foreground or background) - they can't wake the app
up from fully closed, since that needs a native OS-level notification this plugin doesn't
have access to.
</FormText>
</View>
</ScrollView>
);
}

View file

@ -0,0 +1,11 @@
{
"name": "RoleColorEverywhere",
"description": "Displays a member's top role color in more places: mentions, the typing indicator, voice channel participant names, member list role headers, and optionally message text.",
"authors": [
{ "name": "everestmcarthur" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_theme_24px"
}
}

View file

@ -0,0 +1,37 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import patchRows from "./patches/rows";
import patchMemberList from "./patches/memberList";
import patchTypingWrapper from "./patches/typingWrapper";
import patchVoiceUserConnected from "./patches/voiceUserConnected";
import Settings from "./ui/Settings";
let patches: (() => void)[] = [];
function safePatch(name: string, apply: () => () => void): () => void {
try {
return apply();
} catch (e) {
logger.error(`[RoleColorEverywhere] Failed to apply the "${name}" patch, that surface will be skipped: ${e}`);
return () => {};
}
}
export default {
onLoad: () => {
storage.noMention ??= false;
storage.noRole ??= false;
storage.noVoice ??= false;
storage.hideTyping ??= false;
storage.chatInterpolation ??= 0;
patches = [
safePatch("mentions & chat text", patchRows),
safePatch("member list role headers", patchMemberList),
safePatch("typing indicator", patchTypingWrapper),
safePatch("voice channel names", patchVoiceUserConnected)
];
},
onUnload: () => patches.forEach((unpatch) => unpatch()),
settings: Settings
};

View file

@ -0,0 +1,28 @@
import { find, findByStoreName } from "@vendetta/metro";
import { semanticColors } from "@vendetta/ui";
export const ThemeStore = findByStoreName("ThemeStore");
export const resolveSemanticColor =
find((m: any) => m?.default?.internal?.resolveSemanticColor)?.default?.internal?.resolveSemanticColor ??
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
(() => undefined);
export function interpolateColor(color1: string, color2: string, percentage: number): string {
const hexToRgb = (hex: string) => (hex.match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16));
const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join("");
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage));
return rgbToHex(interpolated);
}
export function defaultTextColor(): string | undefined {
try {
return resolveSemanticColor(ThemeStore?.theme, semanticColors.TEXT_NORMAL);
} catch {
return undefined;
}
}

View file

@ -0,0 +1,46 @@
import { findByStoreName } from "@vendetta/metro";
import { ReactNative } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import { storage } from "@vendetta/plugin";
const { View } = ReactNative;
const GuildStore = findByStoreName("GuildStore");
// Colors the role section headers in the member list. Patches the generic View render, since these headers
// aren't exposed as a named component - `roleId` on the found node is what narrows it down to the right one.
export default function patchMemberList(): () => void {
if (!View) return () => {};
return after("render", View, (_: any, res: any) => {
try {
if (storage.noRole) return;
const roleHeader = findInReactTree(res, (r) => r?.props?.roleId !== undefined);
if (!roleHeader) return;
if (Number.isNaN(Number(roleHeader.props.roleId))) return;
if (roleHeader.props.excludedApplications) return;
if ("displayRoleIcon" in roleHeader.props) return;
if ("searchable" in roleHeader.props) return;
const outer = { type: { ...roleHeader.type } };
after("type", outer.type, (_: any, inner: any) => {
const labelWrapper = inner?.props?.children?.[1];
if (!labelWrapper?.type) return;
const label = { type: { ...labelWrapper.type } };
after("render", label.type, (_: any, labelRes: any) => {
const role = GuildStore?.getGuild(roleHeader.props.guildId)?.roles?.[roleHeader.props.roleId];
if (!role?.colorString || !labelRes?.props?.style?.push) return;
labelRes.props.style.push({ color: role.colorString });
});
labelWrapper.type = label.type;
});
roleHeader.type = outer.type;
} catch {
// Skip this render pass - a header staying uncolored beats a crashed member list.
}
});
}

View file

@ -0,0 +1,128 @@
import { findByName, findByStoreName } from "@vendetta/metro";
import { ReactNative } from "@vendetta/metro/common";
import { after, before } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { defaultTextColor, interpolateColor } from "../lib/color";
const GuildMemberStore = findByStoreName("GuildMemberStore");
const ChannelStore = findByStoreName("ChannelStore");
const RowManager = findByName("RowManager", false);
function patchComponents(component: any, funcs: any[], args: any[], tree?: any[]): any {
if (!component) return component;
tree = tree ? [...tree, component] : [component];
if (Array.isArray(component.content)) {
component.content.forEach((sub: any, i: number) => (component.content[i] = patchComponents(sub, funcs, args, tree)));
} else if (component.items) {
component.items.forEach((sub: any, i: number) => (component.items[i] = patchComponents(sub, funcs, args, tree)));
} else if (Array.isArray(component)) {
component.forEach((sub: any, i: number) => (component[i] = patchComponents(sub, funcs, args, tree)));
}
if (component.type) {
for (const func of funcs) {
component = func(component, args, tree) || component;
}
}
return component;
}
function handleRow(row: any) {
const { message } = row;
if (!message) return;
if ((!storage.chatInterpolation || storage.chatInterpolation <= 0) && storage.noMention) return;
const channel = ChannelStore?.getChannel(message.channelId);
if (!channel?.guild_id) return;
const mentionPatch = (component: any) => {
if (component.type !== "mention" || !component.userId) return;
const member = GuildMemberStore?.getMember(channel.guild_id, component.userId);
const hexc = member?.colorString;
if (!hexc) return;
return {
...component,
roleColor: parseInt(hexc.slice(1), 16),
color: parseInt(hexc.slice(1), 16),
colorString: hexc
};
};
const colorPatch = (component: any, [authorId]: any[], tree?: any[]) => {
const types = tree?.map((c) => c.type);
if (types?.some((t) => ["mention", "channelMention", "roleMention", "commandMention", "link"].includes(t))) return;
if (component.type !== "text") return;
const authorMember = GuildMemberStore?.getMember(message.guildId, authorId);
if (!authorMember?.colorString) return;
return {
content: [component],
target: "usernameOnClick",
context: {
username: 1,
usernameOnClick: {
action: "0",
userId: "0",
linkColor: ReactNative.processColor(
interpolateColor(defaultTextColor() ?? "#ffffff", authorMember.colorString, storage.chatInterpolation / 100)
),
messageChannelId: "0"
},
medium: true
},
type: "link"
};
};
const patches: any[] = [];
if (storage.chatInterpolation > 0) patches.push(colorPatch);
if (!storage.noMention) {
patches.push(mentionPatch);
message.shouldShowRoleOnName = true;
if (message.referencedMessage?.message) message.referencedMessage.message.shouldShowRoleOnName = true;
}
if (!patches.length) return;
if (message.content) patchComponents({ content: message.content }, patches, [message.authorId]);
if (message.embeds) {
message.embeds.forEach((embed: any) => patchComponents({ content: embed.description }, patches, [message.authorId]));
}
if (message.referencedMessage?.message?.content) {
patchComponents({ content: message.referencedMessage.message.content }, patches, [message.referencedMessage.message.authorId]);
}
}
// Discord's Android client renders the message list natively - message data is passed across the JS/native
// bridge as JSON, so this is patched at that boundary rather than in a React tree, with a fallback to the
// older `RowManager.generate` path for builds where the native bridge doesn't exist.
export default function patchRows(): () => void {
const { NativeModules } = ReactNative;
const DCDChatManager = NativeModules?.DCDChatManager;
if (DCDChatManager?.updateRows) {
return before("updateRows", DCDChatManager, (args: any[]) => {
try {
const rows = JSON.parse(args[1]);
for (const row of rows) handleRow(row);
args[1] = JSON.stringify(rows);
} catch {
// Leave args untouched - better to show uncolored rows than break message loading.
}
});
}
if (RowManager?.prototype?.generate) {
return after("generate", RowManager.prototype, (_: any, row: any) => {
try { handleRow(row); } catch { /* skip this row */ }
});
}
return () => {};
}

View file

@ -0,0 +1,60 @@
import { findByProps, findByStoreName } from "@vendetta/metro";
import { React, constants } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
import { General } from "@vendetta/ui/components";
import { storage } from "@vendetta/plugin";
import { defaultTextColor } from "../lib/color";
const { Text } = General;
const UserStore = findByStoreName("UserStore");
const RelationshipStore = findByStoreName("RelationshipStore");
const GuildMemberStore = findByStoreName("GuildMemberStore");
const TypingWrapper = findByProps("TYPING_WRAPPER_HEIGHT");
export default function patchTypingWrapper(): () => void {
if (!TypingWrapper?.default) return () => {};
return after("default", TypingWrapper, ([{ channel }]: any[], res: any) => {
try {
if (!res || storage.hideTyping) return;
const Typing = res.props?.children;
if (!Typing) return;
const unpatchTyping = after("type", Typing, (_: any, typingRes: any) => {
try {
React.useEffect(() => () => unpatchTyping(), []);
const typingThing = typingRes?.props?.children?.[0]?.props?.children?.[1]?.props;
if (!typingThing?.children || typingThing.children === "Several people are typing...") return;
const users = (TypingWrapper.useTypingUserIds?.(channel.id) ?? []).map((userId: string) => {
const member = GuildMemberStore?.getMember(channel.guild_id, userId);
const user = UserStore?.getUser(userId);
const name = member?.nick || RelationshipStore?.getNickname?.(userId) || user?.globalName || user?.username || "Someone";
const color = member?.colorString || defaultTextColor();
return { name, color };
});
if (!users.length) return;
const userText = (u: { name: string; color: any }) =>
React.createElement(Text, { style: { color: u.color, fontFamily: constants.Fonts.DISPLAY_SEMIBOLD } }, u.name);
typingThing.children =
users.length === 1
? [userText(users[0]), " is typing..."]
: [
...users.slice(0, -1).flatMap((u: any, i: number) => [userText(u), i < users.length - 2 ? ", " : " and "]),
userText(users[users.length - 1]),
" are typing..."
];
} catch {
// Leave the default "X is typing..." text alone.
}
});
} catch {
// Skip typing indicator coloring entirely for this render.
}
});
}

View file

@ -0,0 +1,32 @@
import { findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { defaultTextColor } from "../lib/color";
export default function patchVoiceUserConnected(): () => void {
const VoiceUserConnected = findByName("VoiceUserConnected", false);
if (!VoiceUserConnected) return () => {};
return after("default", VoiceUserConnected, ([args]: any[], res: any) => {
try {
if (storage.noVoice || !res?.type) return;
const usesRender = !!res.type.prototype?.render;
const hook = usesRender ? "render" : "type";
const target = usesRender ? res.type.prototype : res.type;
const unpatch = after(hook, target, (_: any, innerRes: any) => {
try {
unpatch();
const textProps = innerRes?.props?.children?.[1]?.props;
if (!textProps?.style) return;
textProps.style.color = args?.member?.colorString || defaultTextColor();
} catch {
// Leave the default voice row color.
}
});
} catch {
// Skip this voice row.
}
});
}

View file

@ -0,0 +1,64 @@
import { ReactNative } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { Forms } from "@vendetta/ui/components";
const { ScrollView, View } = ReactNative;
const { FormSection, FormSwitchRow, FormInput, FormText } = Forms;
export default function Settings() {
useProxy(storage);
return (
<ScrollView style={{ flex: 1 }}>
<FormSection title="Where to show the top role color">
<FormSwitchRow
label="Typing indicator"
subLabel='Color names in the "X is typing..." bar'
value={!storage.hideTyping}
onValueChange={(v: boolean) => { storage.hideTyping = !v; }}
/>
<FormSwitchRow
label="Mentions"
subLabel="Color @mentions in chat messages"
value={!storage.noMention}
onValueChange={(v: boolean) => { storage.noMention = !v; }}
/>
<FormSwitchRow
label="Member list role headers"
subLabel="Color the role section headers in the member list"
value={!storage.noRole}
onValueChange={(v: boolean) => { storage.noRole = !v; }}
/>
<FormSwitchRow
label="Voice channel participant names"
value={!storage.noVoice}
onValueChange={(v: boolean) => { storage.noVoice = !v; }}
/>
<FormSwitchRow
label="Message text"
subLabel="Tints message text toward the author's role color - disables text selection on tinted messages"
value={storage.chatInterpolation > 0}
onValueChange={(v: boolean) => { storage.chatInterpolation = v ? 60 : 0; }}
/>
{storage.chatInterpolation > 0 && (
<FormInput
title="Message text strength (0-100)"
value={String(storage.chatInterpolation)}
keyboardType="numeric"
onChange={(v: string) => {
storage.chatInterpolation = Math.max(0, Math.min(100, Number(v) || 0));
}}
/>
)}
</FormSection>
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<FormText>
Some of these surfaces (especially the member list headers) rely on component internals
Discord doesn't officially expose, so a future Discord update may silently turn one of
these off again - the rest will keep working if that happens.
</FormText>
</View>
</ScrollView>
);
}