From 570e972a1d076625190aef8abc2277d8ef02740f Mon Sep 17 00:00:00 2001 From: everestmcarthur <280397636+everestmcarthur@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:11:49 -0400 Subject: [PATCH] Add PronounDB, RoleColorEverywhere revivals + new Message Snippets and Reminders plugins --- plugins/message-snippets/manifest.json | 8 ++ plugins/message-snippets/src/commands.ts | 67 +++++++++ plugins/message-snippets/src/index.ts | 12 ++ plugins/message-snippets/src/lib/snippets.ts | 14 ++ plugins/message-snippets/src/ui/Settings.tsx | 69 ++++++++++ plugins/pronoun-db/manifest.json | 11 ++ plugins/pronoun-db/src/index.tsx | 44 ++++++ plugins/pronoun-db/src/lib/pronounStore.ts | 74 ++++++++++ plugins/pronoun-db/src/ui/PronounSection.tsx | 20 +++ plugins/reminders/manifest.json | 8 ++ plugins/reminders/src/commands.ts | 32 +++++ plugins/reminders/src/index.ts | 17 +++ plugins/reminders/src/lib/duration.ts | 19 +++ plugins/reminders/src/lib/reminders.ts | 64 +++++++++ plugins/reminders/src/ui/Settings.tsx | 54 ++++++++ plugins/role-color-everywhere/manifest.json | 11 ++ plugins/role-color-everywhere/src/index.ts | 37 +++++ .../role-color-everywhere/src/lib/color.ts | 28 ++++ .../src/patches/memberList.ts | 46 +++++++ .../role-color-everywhere/src/patches/rows.ts | 128 ++++++++++++++++++ .../src/patches/typingWrapper.ts | 60 ++++++++ .../src/patches/voiceUserConnected.ts | 32 +++++ .../role-color-everywhere/src/ui/Settings.tsx | 64 +++++++++ 23 files changed, 919 insertions(+) create mode 100644 plugins/message-snippets/manifest.json create mode 100644 plugins/message-snippets/src/commands.ts create mode 100644 plugins/message-snippets/src/index.ts create mode 100644 plugins/message-snippets/src/lib/snippets.ts create mode 100644 plugins/message-snippets/src/ui/Settings.tsx create mode 100644 plugins/pronoun-db/manifest.json create mode 100644 plugins/pronoun-db/src/index.tsx create mode 100644 plugins/pronoun-db/src/lib/pronounStore.ts create mode 100644 plugins/pronoun-db/src/ui/PronounSection.tsx create mode 100644 plugins/reminders/manifest.json create mode 100644 plugins/reminders/src/commands.ts create mode 100644 plugins/reminders/src/index.ts create mode 100644 plugins/reminders/src/lib/duration.ts create mode 100644 plugins/reminders/src/lib/reminders.ts create mode 100644 plugins/reminders/src/ui/Settings.tsx create mode 100644 plugins/role-color-everywhere/manifest.json create mode 100644 plugins/role-color-everywhere/src/index.ts create mode 100644 plugins/role-color-everywhere/src/lib/color.ts create mode 100644 plugins/role-color-everywhere/src/patches/memberList.ts create mode 100644 plugins/role-color-everywhere/src/patches/rows.ts create mode 100644 plugins/role-color-everywhere/src/patches/typingWrapper.ts create mode 100644 plugins/role-color-everywhere/src/patches/voiceUserConnected.ts create mode 100644 plugins/role-color-everywhere/src/ui/Settings.tsx diff --git a/plugins/message-snippets/manifest.json b/plugins/message-snippets/manifest.json new file mode 100644 index 0000000..5772451 --- /dev/null +++ b/plugins/message-snippets/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Message Snippets", + "description": "Save reusable text and send it with /snippet . Manage snippets from the plugin settings, or with /snippet-save, /snippet-delete, /snippet-list.", + "authors": [ + { "name": "everestmcarthur" } + ], + "main": "src/index.ts" +} diff --git a/plugins/message-snippets/src/commands.ts b/plugins/message-snippets/src/commands.ts new file mode 100644 index 0000000..7856dcf --- /dev/null +++ b/plugins/message-snippets/src/commands.ts @@ -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]; +} diff --git a/plugins/message-snippets/src/index.ts b/plugins/message-snippets/src/index.ts new file mode 100644 index 0000000..ea9caa6 --- /dev/null +++ b/plugins/message-snippets/src/index.ts @@ -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 +}; diff --git a/plugins/message-snippets/src/lib/snippets.ts b/plugins/message-snippets/src/lib/snippets.ts new file mode 100644 index 0000000..550cdd2 --- /dev/null +++ b/plugins/message-snippets/src/lib/snippets.ts @@ -0,0 +1,14 @@ +import { storage } from "@vendetta/plugin"; + +export function getSnippets(): Record { + storage.snippets ??= {}; + return storage.snippets; +} + +export function saveSnippet(name: string, text: string) { + getSnippets()[name.trim()] = text; +} + +export function deleteSnippet(name: string) { + delete getSnippets()[name]; +} diff --git a/plugins/message-snippets/src/ui/Settings.tsx b/plugins/message-snippets/src/ui/Settings.tsx new file mode 100644 index 0000000..7e71162 --- /dev/null +++ b/plugins/message-snippets/src/ui/Settings.tsx @@ -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 ( + + + + { + saveSnippet(name.trim(), text); + setName(""); + setText(""); + }} + style={{ + marginTop: 8, + backgroundColor: "#5865F2", + borderRadius: 8, + padding: 10, + alignItems: "center", + opacity: canSave ? 1 : 0.5 + }} + > + Save snippet + + + ); +} + +export default function Settings() { + useProxy(storage); + const snippets = getSnippets(); + useProxy(snippets); + const names = Object.keys(snippets); + + return ( + + + + + + {names.length === 0 && ( + + None yet. Add one above, then send it anywhere with /snippet name. + + )} + {names.map((name) => ( + 60 ? "…" : ""} • Tap to delete`} + onPress={() => deleteSnippet(name)} + /> + ))} + + + ); +} diff --git a/plugins/pronoun-db/manifest.json b/plugins/pronoun-db/manifest.json new file mode 100644 index 0000000..b3efadc --- /dev/null +++ b/plugins/pronoun-db/manifest.json @@ -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" + } +} diff --git a/plugins/pronoun-db/src/index.tsx b/plugins/pronoun-db/src/index.tsx new file mode 100644 index 0000000..2acd7ac --- /dev/null +++ b/plugins/pronoun-db/src/index.tsx @@ -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(); + } 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() +}; diff --git a/plugins/pronoun-db/src/lib/pronounStore.ts b/plugins/pronoun-db/src/lib/pronounStore.ts new file mode 100644 index 0000000..d551dce --- /dev/null +++ b/plugins/pronoun-db/src/lib/pronounStore.ts @@ -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 = { + 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(); +const listeners = new Map void>>(); +let queue = new Set(); +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); +} diff --git a/plugins/pronoun-db/src/ui/PronounSection.tsx b/plugins/pronoun-db/src/ui/PronounSection.tsx new file mode 100644 index 0000000..5f1cfdd --- /dev/null +++ b/plugins/pronoun-db/src/ui/PronounSection.tsx @@ -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 ( + + {pronouns} + + ); +} diff --git a/plugins/reminders/manifest.json b/plugins/reminders/manifest.json new file mode 100644 index 0000000..5766abc --- /dev/null +++ b/plugins/reminders/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Reminders", + "description": "Set quick reminders with /remind