Add PronounDB, RoleColorEverywhere revivals + new Message Snippets and Reminders plugins
This commit is contained in:
parent
17395fbbf5
commit
570e972a1d
23 changed files with 919 additions and 0 deletions
8
plugins/message-snippets/manifest.json
Normal file
8
plugins/message-snippets/manifest.json
Normal 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"
|
||||
}
|
||||
67
plugins/message-snippets/src/commands.ts
Normal file
67
plugins/message-snippets/src/commands.ts
Normal 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];
|
||||
}
|
||||
12
plugins/message-snippets/src/index.ts
Normal file
12
plugins/message-snippets/src/index.ts
Normal 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
|
||||
};
|
||||
14
plugins/message-snippets/src/lib/snippets.ts
Normal file
14
plugins/message-snippets/src/lib/snippets.ts
Normal 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];
|
||||
}
|
||||
69
plugins/message-snippets/src/ui/Settings.tsx
Normal file
69
plugins/message-snippets/src/ui/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
plugins/pronoun-db/manifest.json
Normal file
11
plugins/pronoun-db/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
plugins/pronoun-db/src/index.tsx
Normal file
44
plugins/pronoun-db/src/index.tsx
Normal 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()
|
||||
};
|
||||
74
plugins/pronoun-db/src/lib/pronounStore.ts
Normal file
74
plugins/pronoun-db/src/lib/pronounStore.ts
Normal 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);
|
||||
}
|
||||
20
plugins/pronoun-db/src/ui/PronounSection.tsx
Normal file
20
plugins/pronoun-db/src/ui/PronounSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
plugins/reminders/manifest.json
Normal file
8
plugins/reminders/manifest.json
Normal 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"
|
||||
}
|
||||
32
plugins/reminders/src/commands.ts
Normal file
32
plugins/reminders/src/commands.ts
Normal 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];
|
||||
}
|
||||
17
plugins/reminders/src/index.ts
Normal file
17
plugins/reminders/src/index.ts
Normal 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
|
||||
};
|
||||
19
plugins/reminders/src/lib/duration.ts
Normal file
19
plugins/reminders/src/lib/duration.ts
Normal 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);
|
||||
}
|
||||
64
plugins/reminders/src/lib/reminders.ts
Normal file
64
plugins/reminders/src/lib/reminders.ts
Normal 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;
|
||||
}
|
||||
54
plugins/reminders/src/ui/Settings.tsx
Normal file
54
plugins/reminders/src/ui/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
plugins/role-color-everywhere/manifest.json
Normal file
11
plugins/role-color-everywhere/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
plugins/role-color-everywhere/src/index.ts
Normal file
37
plugins/role-color-everywhere/src/index.ts
Normal 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
|
||||
};
|
||||
28
plugins/role-color-everywhere/src/lib/color.ts
Normal file
28
plugins/role-color-everywhere/src/lib/color.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
46
plugins/role-color-everywhere/src/patches/memberList.ts
Normal file
46
plugins/role-color-everywhere/src/patches/memberList.ts
Normal 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.
|
||||
}
|
||||
});
|
||||
}
|
||||
128
plugins/role-color-everywhere/src/patches/rows.ts
Normal file
128
plugins/role-color-everywhere/src/patches/rows.ts
Normal 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 () => {};
|
||||
}
|
||||
60
plugins/role-color-everywhere/src/patches/typingWrapper.ts
Normal file
60
plugins/role-color-everywhere/src/patches/typingWrapper.ts
Normal 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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
64
plugins/role-color-everywhere/src/ui/Settings.tsx
Normal file
64
plugins/role-color-everywhere/src/ui/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue