RoseUtils: expand Developer Mode, add message/server dev-tools, drop Tenor fix
This commit is contained in:
parent
97642009b3
commit
48662f828c
11 changed files with 660 additions and 113 deletions
144
plugins/rose-utils/src/lib/messageActionSheet.ts
Normal file
144
plugins/rose-utils/src/lib/messageActionSheet.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { before, after } from "@vendetta/patcher";
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { findInReactTree } from "@vendetta/utils";
|
||||||
|
|
||||||
|
// Injection technique ported from this repo's ViewRaw plugin (view-raw/src/index.tsx) - confirmed
|
||||||
|
// working there for adding a row to the message long-press action sheet. Generalized here so more
|
||||||
|
// than one RoseUtils module can each contribute their own rows through a single shared patch,
|
||||||
|
// instead of every module that wants this hook patching openLazy separately (which would stack
|
||||||
|
// duplicate patches, and duplicate "instance.__patched" bookkeeping, the moment more than one such
|
||||||
|
// module is enabled at once).
|
||||||
|
export interface MessageActionRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
icon?: string;
|
||||||
|
onPress: (message: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageActionBuilder = (message: any) => MessageActionRow[];
|
||||||
|
|
||||||
|
const LazyActionSheet = findByProps("openLazy", "hideActionSheet");
|
||||||
|
const { ActionSheetRow } = findByProps("ActionSheetRow") ?? {};
|
||||||
|
|
||||||
|
const builders = new Set<MessageActionBuilder>();
|
||||||
|
let realUnpatch: (() => void) | null = null;
|
||||||
|
|
||||||
|
function buildRowElement(row: MessageActionRow, message: any) {
|
||||||
|
if (!ActionSheetRow) return null;
|
||||||
|
|
||||||
|
return React.createElement(ActionSheetRow, {
|
||||||
|
label: row.label,
|
||||||
|
subLabel: row.sublabel,
|
||||||
|
icon: row.icon ? React.createElement(ActionSheetRow.Icon, { source: getAssetIDByName(row.icon) }) : undefined,
|
||||||
|
onPress: () => {
|
||||||
|
try {
|
||||||
|
row.onPress(message);
|
||||||
|
} finally {
|
||||||
|
LazyActionSheet?.hideActionSheet?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
key: row.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRowElements(message: any) {
|
||||||
|
const elements: any[] = [];
|
||||||
|
for (const build of builders) {
|
||||||
|
try {
|
||||||
|
for (const row of build(message)) {
|
||||||
|
const el = buildRowElement(row, message);
|
||||||
|
if (el) elements.push(el);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A broken builder shouldn't block anyone else's rows from showing up.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOpenLazy(): () => void {
|
||||||
|
return before("openLazy", LazyActionSheet, ([component, key, msg]: any[]) => {
|
||||||
|
if (key !== "MessageLongPressActionSheet" || !msg?.message) return;
|
||||||
|
|
||||||
|
component.then((instance: any) => {
|
||||||
|
// Discord appears to reuse the same lazy-loaded instance across every action sheet
|
||||||
|
// open - only patch it once, and track the active message on the instance itself so
|
||||||
|
// re-opening on a different message doesn't need a fresh patch.
|
||||||
|
instance.__roseUtilsActiveMessage = msg.message;
|
||||||
|
if (instance.__roseUtilsPatched) return;
|
||||||
|
instance.__roseUtilsPatched = true;
|
||||||
|
|
||||||
|
after("default", instance, (_: any, result: any) => {
|
||||||
|
try {
|
||||||
|
const rows = collectRowElements(instance.__roseUtilsActiveMessage);
|
||||||
|
if (!rows.length) return;
|
||||||
|
|
||||||
|
// Real ActionSheetRow groups - search every group for one that already has
|
||||||
|
// ActionSheetRow-shaped children and insert there, falling back to a brand new
|
||||||
|
// group at the top if none match.
|
||||||
|
const groups = findInReactTree(
|
||||||
|
result,
|
||||||
|
(x: any) => Array.isArray(x) && x[0]?.type?.name === "ActionSheetRowGroup",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(groups) && groups.length && ActionSheetRow) {
|
||||||
|
let inserted = false;
|
||||||
|
for (const group of groups) {
|
||||||
|
const children = findInReactTree(
|
||||||
|
group,
|
||||||
|
(c: any) => Array.isArray(c) && c.some((child: any) => child?.type?.name === "ActionSheetRow"),
|
||||||
|
);
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
children.push(...rows);
|
||||||
|
inserted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inserted && typeof groups.unshift === "function" && ActionSheetRow.Group) {
|
||||||
|
groups.unshift(React.createElement(ActionSheetRow.Group, {}, rows));
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
if (inserted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name-independent fallback: any array where every element already looks like
|
||||||
|
// an action sheet row (has both a label and an onPress).
|
||||||
|
const genericRowGroup = findInReactTree(
|
||||||
|
result,
|
||||||
|
(x: any) =>
|
||||||
|
Array.isArray(x) &&
|
||||||
|
x.length > 0 &&
|
||||||
|
x.every((el: any) => typeof el?.props?.label === "string" && typeof el?.props?.onPress === "function"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(genericRowGroup)) genericRowGroup.push(...rows);
|
||||||
|
} catch {
|
||||||
|
// Best-effort - if the action sheet shape doesn't match any known strategy,
|
||||||
|
// the extra rows just don't appear rather than crashing the sheet.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a row builder for the message long-press action sheet. Returns an unregister
|
||||||
|
* function. The underlying openLazy patch is reference-counted across every caller - it's only
|
||||||
|
* applied on the first registration and only removed once the last one unregisters, so modules
|
||||||
|
* can start/stop independently of each other's registration order.
|
||||||
|
*/
|
||||||
|
export function registerMessageAction(builder: MessageActionBuilder): () => void {
|
||||||
|
if (!realUnpatch && LazyActionSheet) realUnpatch = patchOpenLazy();
|
||||||
|
builders.add(builder);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
builders.delete(builder);
|
||||||
|
if (builders.size === 0 && realUnpatch) {
|
||||||
|
realUnpatch();
|
||||||
|
realUnpatch = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
15
plugins/rose-utils/src/lib/snowflake.ts
Normal file
15
plugins/rose-utils/src/lib/snowflake.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Pure math, no Discord internals involved - Discord IDs are Twitter-style snowflakes: the top 42
|
||||||
|
// bits are milliseconds since the Discord epoch (2015-01-01T00:00:00.000Z).
|
||||||
|
const DISCORD_EPOCH = 1420070400000n;
|
||||||
|
|
||||||
|
export function decodeSnowflakeTimestamp(id: string): Date | null {
|
||||||
|
const trimmed = id.trim();
|
||||||
|
if (!/^\d{15,20}$/.test(trimmed)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ms = (BigInt(trimmed) >> 22n) + DISCORD_EPOCH;
|
||||||
|
return new Date(Number(ms));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
plugins/rose-utils/src/modules/DeveloperMode/index.ts
Normal file
152
plugins/rose-utils/src/modules/DeveloperMode/index.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||||
|
import { clipboard } from "@vendetta/metro/common";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { fluxSubscribe } from "@shared/lib/flux";
|
||||||
|
import { Module, ModuleCategory } from "../../lib/Module";
|
||||||
|
import { registerMessageAction, type MessageActionRow } from "../../lib/messageActionSheet";
|
||||||
|
|
||||||
|
// Confirmed against decompiled current-build Discord source: Developer Mode is a proto-backed
|
||||||
|
// setting exposed as DeveloperMode.getSetting()/updateSetting(bool), part of the same
|
||||||
|
// modules/user_settings/UserSettings.tsx module that exports every other appearance/advanced
|
||||||
|
// setting (DarkSidebar, MessageDisplayCompact, etc - all built the same way via defineProtoSetting).
|
||||||
|
// It resets itself more than it should on Revenge - rather than guess at why, this just re-asserts
|
||||||
|
// it every time the user's settings proto changes, which is the same event Discord's own settings
|
||||||
|
// UI reacts to.
|
||||||
|
const UserSettingsModule = findByProps("DeveloperMode", "DarkSidebar");
|
||||||
|
|
||||||
|
// Confirmed live via Key Inspector Eval (/root/eval-for-revenge/ru/activity-payload-capture.txt):
|
||||||
|
// caught a real LOCAL_ACTIVITY_UPDATE payload while Watch Together was running - the activity
|
||||||
|
// object is exactly {type, name, application_id, platform, party, secrets, flags, metadata}, snake
|
||||||
|
// case, matching the primary branch checked below. That capture was for the local user specifically
|
||||||
|
// (own activities dispatch through a local-first path, which is why PresenceStore.getActivities on
|
||||||
|
// your own ID came back empty even mid-session) - but it's the same public Gateway Activity object
|
||||||
|
// schema Discord broadcasts for everyone, which is what PresenceStore caches for *other* users -
|
||||||
|
// the actual case this code needs, since it looks up the message author, not the local user. Not
|
||||||
|
// yet caught a live PresenceStore.getActivities(otherUserId) call directly, but the shape match
|
||||||
|
// leaves nothing left to guess at.
|
||||||
|
const PresenceStore = findByStoreName("PresenceStore");
|
||||||
|
|
||||||
|
// Confirmed live via Key Inspector Eval (/root/eval-for-revenge/ru/all-checks.txt) against real
|
||||||
|
// slash-command messages: message.interaction is populated with {id, name, type, user,
|
||||||
|
// displayName} exactly as read below (message.type 20 = CHAT_INPUT_COMMAND on these).
|
||||||
|
// interactionMetadata/interaction_metadata are checked as a fallback for whichever build doesn't
|
||||||
|
// populate the legacy .interaction field.
|
||||||
|
function getCommandInteraction(message: any): { id: string; name?: string } | null {
|
||||||
|
const interaction = message?.interaction ?? message?.interactionMetadata ?? message?.interaction_metadata;
|
||||||
|
if (!interaction?.id) return null;
|
||||||
|
return { id: String(interaction.id), name: interaction.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandIdRows(message: any): MessageActionRow[] {
|
||||||
|
const interaction = getCommandInteraction(message);
|
||||||
|
if (!interaction) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-command-id",
|
||||||
|
label: "Copy Command ID",
|
||||||
|
sublabel: interaction.name ? `/${interaction.name}` : "Slash command interaction",
|
||||||
|
icon: "ic_copy_24px",
|
||||||
|
onPress: () => {
|
||||||
|
clipboard.setString(interaction.id);
|
||||||
|
showToast("Copied command ID to clipboard", getAssetIDByName("ic_copy_24px"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityIdRows(message: any): MessageActionRow[] {
|
||||||
|
if (!PresenceStore?.getActivities) return [];
|
||||||
|
|
||||||
|
const authorId = message?.author?.id;
|
||||||
|
if (!authorId) return [];
|
||||||
|
|
||||||
|
let activities: any[];
|
||||||
|
try {
|
||||||
|
activities = PresenceStore.getActivities(authorId);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(activities)) return [];
|
||||||
|
|
||||||
|
const withAppId = activities.filter((a) => a?.application_id ?? a?.applicationId);
|
||||||
|
if (!withAppId.length) return [];
|
||||||
|
|
||||||
|
return withAppId.map((activity, i) => {
|
||||||
|
const appId = String(activity.application_id ?? activity.applicationId);
|
||||||
|
return {
|
||||||
|
key: `rose-utils-copy-activity-id-${i}`,
|
||||||
|
label: withAppId.length > 1 ? `Copy Activity ID (${activity.name ?? i + 1})` : "Copy Activity/Game ID",
|
||||||
|
sublabel: activity.name,
|
||||||
|
icon: "ic_copy_24px",
|
||||||
|
onPress: () => {
|
||||||
|
clipboard.setString(appId);
|
||||||
|
showToast(`Copied ${activity.name ?? "activity"} ID to clipboard`, getAssetIDByName("ic_copy_24px"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Module({
|
||||||
|
id: "enforce-developer-mode",
|
||||||
|
label: "Developer Mode",
|
||||||
|
meta: {
|
||||||
|
sublabel: "Turns on Discord's real Developer Mode (all its native Copy ID options) and adds extra copy-ID tools slash commands and activities don't get natively",
|
||||||
|
category: ModuleCategory.Useful,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
keepNativeOn: {
|
||||||
|
label: "Keep native Developer Mode on",
|
||||||
|
subLabel: "Re-asserts Discord's own Developer Mode setting - it tends to reset itself on Revenge-based clients",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyCommandId: {
|
||||||
|
label: "Copy Command ID",
|
||||||
|
subLabel: "Adds a row to the message long-press menu for messages sent via a slash command",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyActivityId: {
|
||||||
|
label: "Copy Activity/Game ID",
|
||||||
|
subLabel: "Adds a row to the message long-press menu to copy the author's current game/activity application ID (not every activity type has one, e.g. a plain custom status)",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
onStart() {
|
||||||
|
if (this.storage.options.keepNativeOn) {
|
||||||
|
const DeveloperMode = UserSettingsModule?.DeveloperMode;
|
||||||
|
if (DeveloperMode?.getSetting && DeveloperMode?.updateSetting) {
|
||||||
|
const enforce = () => {
|
||||||
|
try {
|
||||||
|
if (!DeveloperMode.getSetting()) DeveloperMode.updateSetting(true);
|
||||||
|
} catch {
|
||||||
|
// Best-effort - if this particular check throws, the next settings change retries it.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
enforce();
|
||||||
|
this.patches.add(fluxSubscribe("USER_SETTINGS_PROTO_UPDATE", enforce));
|
||||||
|
|
||||||
|
// Belt and suspenders: if whatever resets this doesn't go through
|
||||||
|
// USER_SETTINGS_PROTO_UPDATE for some reason, this still catches it within 5s
|
||||||
|
// instead of staying off for the rest of the session.
|
||||||
|
const interval = setInterval(enforce, 5000);
|
||||||
|
this.patches.add(() => clearInterval(interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.storage.options.copyCommandId) {
|
||||||
|
this.patches.add(registerMessageAction(commandIdRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.storage.options.copyActivityId) {
|
||||||
|
this.patches.add(registerMessageAction(activityIdRows));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStop() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { findByProps } from "@vendetta/metro";
|
|
||||||
import { fluxSubscribe } from "@shared/lib/flux";
|
|
||||||
import { Module, ModuleCategory } from "../../lib/Module";
|
|
||||||
|
|
||||||
// Confirmed against decompiled current-build Discord source: Developer Mode is a proto-backed
|
|
||||||
// setting exposed as DeveloperMode.getSetting()/updateSetting(bool), part of the same
|
|
||||||
// modules/user_settings/UserSettings.tsx module that exports every other appearance/advanced
|
|
||||||
// setting (DarkSidebar, MessageDisplayCompact, etc - all built the same way via defineProtoSetting).
|
|
||||||
// It resets itself more than it should on Revenge - rather than guess at why, this just re-asserts
|
|
||||||
// it every time the user's settings proto changes, which is the same event Discord's own settings
|
|
||||||
// UI reacts to.
|
|
||||||
const UserSettingsModule = findByProps("DeveloperMode", "DarkSidebar");
|
|
||||||
|
|
||||||
export default new Module({
|
|
||||||
id: "enforce-developer-mode",
|
|
||||||
label: "Enforce Developer Mode",
|
|
||||||
meta: {
|
|
||||||
sublabel: "Keeps Developer Mode switched on - it tends to reset itself on Revenge-based clients",
|
|
||||||
category: ModuleCategory.Fixes,
|
|
||||||
},
|
|
||||||
handlers: {
|
|
||||||
onStart() {
|
|
||||||
const DeveloperMode = UserSettingsModule?.DeveloperMode;
|
|
||||||
if (!DeveloperMode?.getSetting || !DeveloperMode?.updateSetting) return;
|
|
||||||
|
|
||||||
const enforce = () => {
|
|
||||||
try {
|
|
||||||
if (!DeveloperMode.getSetting()) DeveloperMode.updateSetting(true);
|
|
||||||
} catch {
|
|
||||||
// Best-effort - if this particular check throws, the next settings change retries it.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
enforce();
|
|
||||||
this.patches.add(fluxSubscribe("USER_SETTINGS_PROTO_UPDATE", enforce));
|
|
||||||
|
|
||||||
// Belt and suspenders: if whatever resets this doesn't go through
|
|
||||||
// USER_SETTINGS_PROTO_UPDATE for some reason, this still catches it within 5s instead
|
|
||||||
// of staying off for the rest of the session.
|
|
||||||
const interval = setInterval(enforce, 5000);
|
|
||||||
this.patches.add(() => clearInterval(interval));
|
|
||||||
},
|
|
||||||
onStop() {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
153
plugins/rose-utils/src/modules/MessageDevTools/index.ts
Normal file
153
plugins/rose-utils/src/modules/MessageDevTools/index.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { clipboard } from "@vendetta/metro/common";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { Module, ModuleCategory } from "../../lib/Module";
|
||||||
|
import { registerMessageAction, type MessageActionRow } from "../../lib/messageActionSheet";
|
||||||
|
|
||||||
|
const COPY_ICON = "ic_copy_24px";
|
||||||
|
|
||||||
|
function copy(value: string, toastLabel: string) {
|
||||||
|
clipboard.setString(value);
|
||||||
|
showToast(toastLabel, getAssetIDByName(COPY_ICON));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every field read below (webhookId, nonce, attachments, messageReference, type/flags/timestamps)
|
||||||
|
// is part of Discord's own public message object schema - the same shape the client already
|
||||||
|
// deserializes the gateway/REST payload into before handing it to the action sheet - not
|
||||||
|
// internal-UI guesswork, unlike the activity-ID lookup in the Developer Mode module. webhookId,
|
||||||
|
// nonce, and messageReference are confirmed live via Key Inspector Eval
|
||||||
|
// (/root/eval-for-revenge/ru/all-checks.txt) against real interaction-response and reply messages.
|
||||||
|
function webhookIdRow(message: any): MessageActionRow[] {
|
||||||
|
const webhookId = message?.webhookId ?? message?.webhook_id;
|
||||||
|
if (!webhookId) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-webhook-id",
|
||||||
|
label: "Copy Webhook ID",
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => copy(String(webhookId), "Copied webhook ID to clipboard"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonceRow(message: any): MessageActionRow[] {
|
||||||
|
const nonce = message?.nonce;
|
||||||
|
if (!nonce) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-nonce",
|
||||||
|
label: "Copy Nonce",
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => copy(String(nonce), "Copied nonce to clipboard"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentIdsRow(message: any): MessageActionRow[] {
|
||||||
|
const attachments = message?.attachments;
|
||||||
|
if (!Array.isArray(attachments) || !attachments.length) return [];
|
||||||
|
|
||||||
|
const ids = attachments.map((a: any) => a?.id).filter(Boolean);
|
||||||
|
if (!ids.length) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-attachment-ids",
|
||||||
|
label: ids.length > 1 ? `Copy Attachment IDs (${ids.length})` : "Copy Attachment ID",
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => copy(ids.join(", "), `Copied ${ids.length} attachment ID${ids.length === 1 ? "" : "s"} to clipboard`),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedMessageIdRow(message: any): MessageActionRow[] {
|
||||||
|
const ref = message?.messageReference ?? message?.message_reference;
|
||||||
|
const refId = ref?.messageId ?? ref?.message_id;
|
||||||
|
if (!refId) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-referenced-id",
|
||||||
|
label: "Copy Replied-to Message ID",
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => copy(String(refId), "Copied replied-to message ID to clipboard"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugInfoRow(message: any): MessageActionRow[] {
|
||||||
|
if (!message?.id) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-debug-info",
|
||||||
|
label: "Copy Message Debug Info",
|
||||||
|
sublabel: "Type, flags, and timestamps",
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => {
|
||||||
|
const info = {
|
||||||
|
id: message.id,
|
||||||
|
channelId: message.channelId ?? message.channel_id,
|
||||||
|
type: message.type,
|
||||||
|
flags: message.flags,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
editedTimestamp: message.editedTimestamp ?? message.edited_timestamp,
|
||||||
|
};
|
||||||
|
copy(JSON.stringify(info, null, 2), "Copied message debug info to clipboard");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Module({
|
||||||
|
id: "message-dev-tools",
|
||||||
|
label: "Message Dev Tools",
|
||||||
|
meta: {
|
||||||
|
sublabel: "Extra copy-ID rows in the message long-press menu, beyond what Discord's Developer Mode gives you natively",
|
||||||
|
category: ModuleCategory.Useful,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
copyWebhookId: {
|
||||||
|
label: "Copy Webhook ID",
|
||||||
|
subLabel: "For messages sent by a webhook",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyNonce: {
|
||||||
|
label: "Copy Nonce",
|
||||||
|
subLabel: "The client-generated ID used to match an optimistic send to its real message",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyAttachmentIds: {
|
||||||
|
label: "Copy Attachment ID(s)",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyReferencedId: {
|
||||||
|
label: "Copy Replied-to Message ID",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyDebugInfo: {
|
||||||
|
label: "Copy Message Debug Info",
|
||||||
|
subLabel: "Type, flags, and creation/edit timestamps as JSON",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
onStart() {
|
||||||
|
const options = this.storage.options;
|
||||||
|
|
||||||
|
if (options.copyWebhookId) this.patches.add(registerMessageAction(webhookIdRow));
|
||||||
|
if (options.copyNonce) this.patches.add(registerMessageAction(nonceRow));
|
||||||
|
if (options.copyAttachmentIds) this.patches.add(registerMessageAction(attachmentIdsRow));
|
||||||
|
if (options.copyReferencedId) this.patches.add(registerMessageAction(referencedMessageIdRow));
|
||||||
|
if (options.copyDebugInfo) this.patches.add(registerMessageAction(debugInfoRow));
|
||||||
|
},
|
||||||
|
onStop() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
131
plugins/rose-utils/src/modules/ServerInfoTools/index.ts
Normal file
131
plugins/rose-utils/src/modules/ServerInfoTools/index.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { findByStoreName } from "@vendetta/metro";
|
||||||
|
import { clipboard } from "@vendetta/metro/common";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { Module, ModuleCategory } from "../../lib/Module";
|
||||||
|
import { registerMessageAction, type MessageActionRow } from "../../lib/messageActionSheet";
|
||||||
|
import { decodeSnowflakeTimestamp } from "../../lib/snowflake";
|
||||||
|
|
||||||
|
// Confirmed live via Key Inspector Eval (/root/eval-for-revenge/ru/all-checks.txt) against a real
|
||||||
|
// server: GuildStore.getGuild() returns name/ownerId/features/premiumTier/premiumSubscriberCount/
|
||||||
|
// verificationLevel/vanityURLCode exactly as read below. RelationshipStore.getFriendIDs() and
|
||||||
|
// GuildMemberStore.getMember(guildId, userId) are confirmed working too - a real friend showed up
|
||||||
|
// as a cached member of the test server, cross-checked against UserStore.getUser().username.
|
||||||
|
const GuildStore = findByStoreName("GuildStore");
|
||||||
|
const RelationshipStore = findByStoreName("RelationshipStore");
|
||||||
|
const GuildMemberStore = findByStoreName("GuildMemberStore");
|
||||||
|
const UserStore = findByStoreName("UserStore");
|
||||||
|
|
||||||
|
const COPY_ICON = "ic_copy_24px";
|
||||||
|
|
||||||
|
function copy(value: string, toastLabel: string) {
|
||||||
|
clipboard.setString(value);
|
||||||
|
showToast(toastLabel, getAssetIDByName(COPY_ICON));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGuildId(message: any): string | null {
|
||||||
|
return message?.guildId ?? message?.guild_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverInfoRow(message: any): MessageActionRow[] {
|
||||||
|
const guildId = getGuildId(message);
|
||||||
|
if (!guildId || !GuildStore?.getGuild) return [];
|
||||||
|
|
||||||
|
const guild = GuildStore.getGuild(guildId);
|
||||||
|
if (!guild) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-server-info",
|
||||||
|
label: "Copy Server Info",
|
||||||
|
sublabel: guild.name,
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => {
|
||||||
|
const info = {
|
||||||
|
id: guildId,
|
||||||
|
name: guild.name,
|
||||||
|
ownerId: guild.ownerId,
|
||||||
|
createdAt: decodeSnowflakeTimestamp(guildId)?.toISOString(),
|
||||||
|
boostTier: guild.premiumTier,
|
||||||
|
boostCount: guild.premiumSubscriberCount,
|
||||||
|
verificationLevel: guild.verificationLevel,
|
||||||
|
vanityURLCode: guild.vanityURLCode,
|
||||||
|
description: guild.description,
|
||||||
|
features: guild.features,
|
||||||
|
};
|
||||||
|
copy(JSON.stringify(info, null, 2), "Copied server info to clipboard");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendsInServerRow(message: any): MessageActionRow[] {
|
||||||
|
const guildId = getGuildId(message);
|
||||||
|
if (!guildId || !RelationshipStore?.getFriendIDs || !GuildMemberStore?.getMember) return [];
|
||||||
|
|
||||||
|
let friendIds: string[];
|
||||||
|
try {
|
||||||
|
friendIds = RelationshipStore.getFriendIDs();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(friendIds) || !friendIds.length) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "rose-utils-copy-friends-in-server",
|
||||||
|
label: "Copy Friends In This Server",
|
||||||
|
sublabel: "Only counts friends already loaded into this server's member list this session",
|
||||||
|
icon: COPY_ICON,
|
||||||
|
onPress: () => {
|
||||||
|
const inServer = friendIds
|
||||||
|
.filter((id) => {
|
||||||
|
try {
|
||||||
|
return !!GuildMemberStore.getMember(guildId, id);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((id) => UserStore?.getUser?.(id)?.username ?? id);
|
||||||
|
|
||||||
|
copy(
|
||||||
|
inServer.length
|
||||||
|
? inServer.join(", ")
|
||||||
|
: "(none found - they may just not be loaded into the member list yet)",
|
||||||
|
`Copied ${inServer.length} friend${inServer.length === 1 ? "" : "s"} in this server to clipboard`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Module({
|
||||||
|
id: "server-info-tools",
|
||||||
|
label: "Server Info Tools",
|
||||||
|
meta: {
|
||||||
|
sublabel: "Adds Copy Server Info and Copy Friends In This Server rows to the message long-press menu",
|
||||||
|
category: ModuleCategory.Useful,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
copyServerInfo: {
|
||||||
|
label: "Copy Server Info",
|
||||||
|
subLabel: "Boost tier, verification level, features, vanity URL, creation date, and more",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
copyFriendsInServer: {
|
||||||
|
label: "Copy Friends In This Server",
|
||||||
|
subLabel: "Cross-references your friends list against this server's cached member list",
|
||||||
|
type: "toggle",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
onStart() {
|
||||||
|
const options = this.storage.options;
|
||||||
|
if (options.copyServerInfo) this.patches.add(registerMessageAction(serverInfoRow));
|
||||||
|
if (options.copyFriendsInServer) this.patches.add(registerMessageAction(friendsInServerRow));
|
||||||
|
},
|
||||||
|
onStop() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
50
plugins/rose-utils/src/modules/SnowflakeTools/index.ts
Normal file
50
plugins/rose-utils/src/modules/SnowflakeTools/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { clipboard } from "@vendetta/metro/common";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { Module, ModuleCategory } from "../../lib/Module";
|
||||||
|
import { decodeSnowflakeTimestamp } from "../../lib/snowflake";
|
||||||
|
|
||||||
|
function decodeSnowflake(id: string): string | null {
|
||||||
|
return decodeSnowflakeTimestamp(id)?.toISOString() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Module({
|
||||||
|
id: "snowflake-tools",
|
||||||
|
label: "Snowflake Tools",
|
||||||
|
meta: {
|
||||||
|
sublabel: "Decode any Discord ID's creation timestamp - useful after copying an ID with Developer Mode",
|
||||||
|
category: ModuleCategory.Useful,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
decodeClipboard: {
|
||||||
|
label: "Decode ID(s) in clipboard",
|
||||||
|
subLabel: "Copy one or more Discord IDs first (one per line, or comma-separated), then tap this",
|
||||||
|
type: "button",
|
||||||
|
action() {
|
||||||
|
clipboard.getString().then((text: string) => {
|
||||||
|
const ids = text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (!ids.length) {
|
||||||
|
showToast("Clipboard is empty", undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ids.map((id) => {
|
||||||
|
const decoded = decodeSnowflake(id);
|
||||||
|
return decoded ? `${id} -> ${decoded}` : `${id} -> not a valid snowflake`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = lines.join("\n");
|
||||||
|
clipboard.setString(report);
|
||||||
|
showToast(
|
||||||
|
`Decoded ${lines.length} ID${lines.length === 1 ? "" : "s"} - result copied`,
|
||||||
|
getAssetIDByName("ic_copy_24px"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
onStart() {},
|
||||||
|
onStop() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -1,65 +0,0 @@
|
||||||
import { findByProps } from "@vendetta/metro";
|
|
||||||
import { before } from "@vendetta/patcher";
|
|
||||||
import dark from "./dark.png";
|
|
||||||
import light from "./light.png";
|
|
||||||
|
|
||||||
import { Module, ModuleCategory } from "../../lib/Module";
|
|
||||||
|
|
||||||
const MediaManager = findByProps("downloadMediaAsset");
|
|
||||||
const mediaShareActions = findByProps("useMediaShareActions");
|
|
||||||
|
|
||||||
function parseURL(url: string) {
|
|
||||||
const path = url.split("/");
|
|
||||||
const tenorIndex = path.findIndex((x) => x.endsWith(".tenor.com"));
|
|
||||||
if (tenorIndex === -1) return;
|
|
||||||
|
|
||||||
const [host, id, file] = path.slice(tenorIndex, tenorIndex + 3);
|
|
||||||
if (!host || !id || !file) return;
|
|
||||||
|
|
||||||
return `https://${host}/${id.slice(0, -2)}AC/${file.split(".")[0]}.gif`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new Module({
|
|
||||||
id: "tenor-gif-fix",
|
|
||||||
label: "Tenor GIF Fix",
|
|
||||||
meta: {
|
|
||||||
sublabel: "Fixes a several-year old bug which downloads gifs from Tenor as videos instead of gif files",
|
|
||||||
category: ModuleCategory.Fixes,
|
|
||||||
},
|
|
||||||
handlers: {
|
|
||||||
onStart() {
|
|
||||||
// Older Discord versions.
|
|
||||||
if (MediaManager?.downloadMediaAsset) {
|
|
||||||
this.patches.add(
|
|
||||||
before("downloadMediaAsset", MediaManager, (args: any[]) => {
|
|
||||||
const url = args[0];
|
|
||||||
if (!url || typeof url !== "string") return;
|
|
||||||
|
|
||||||
const parsed = parseURL(url);
|
|
||||||
if (parsed) {
|
|
||||||
args[0] = parsed;
|
|
||||||
args[1] = 1;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Newer Discord versions.
|
|
||||||
if (mediaShareActions?.useMediaShareActions) {
|
|
||||||
this.patches.add(
|
|
||||||
before("useMediaShareActions", mediaShareActions, (args: any[]) => {
|
|
||||||
const source = args[0]?.source;
|
|
||||||
if (!source?.uri) return;
|
|
||||||
|
|
||||||
const parsed = parseURL(source.uri);
|
|
||||||
if (parsed) {
|
|
||||||
source.sourceURI = parsed;
|
|
||||||
delete source.videoURI;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStop() {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -1,11 +1,23 @@
|
||||||
import SpotifyListenAlong from "./SpotifyListenAlong";
|
import SpotifyListenAlong from "./SpotifyListenAlong";
|
||||||
import TenorGifFix from "./TenorGifFix";
|
|
||||||
import SendSpotifyInvite from "./SendSpotifyInvite";
|
import SendSpotifyInvite from "./SendSpotifyInvite";
|
||||||
import Minimod from "./Minimod";
|
import Minimod from "./Minimod";
|
||||||
import ColorfulChannels from "./ColorfulChannels";
|
import ColorfulChannels from "./ColorfulChannels";
|
||||||
import EnforceDeveloperMode from "./EnforceDeveloperMode";
|
import DeveloperMode from "./DeveloperMode";
|
||||||
|
import MessageDevTools from "./MessageDevTools";
|
||||||
|
import SnowflakeTools from "./SnowflakeTools";
|
||||||
|
import ServerInfoTools from "./ServerInfoTools";
|
||||||
|
|
||||||
// BetterComponents (Material-style Switch/Toast/Alert redesign) isn't ported - it needs
|
// BetterComponents (Material-style Switch/Toast/Alert redesign) isn't ported - it needs
|
||||||
// react-native-reanimated, which isn't part of this build, and it was already disabled by
|
// react-native-reanimated, which isn't part of this build, and it was already disabled by
|
||||||
// default upstream due to known issues ("the great component functionification of 2025").
|
// default upstream due to known issues ("the great component functionification of 2025").
|
||||||
export default [SpotifyListenAlong, TenorGifFix, SendSpotifyInvite, Minimod, ColorfulChannels, EnforceDeveloperMode];
|
// TenorGifFix was dropped from this module list at the user's request.
|
||||||
|
export default [
|
||||||
|
SpotifyListenAlong,
|
||||||
|
SendSpotifyInvite,
|
||||||
|
Minimod,
|
||||||
|
ColorfulChannels,
|
||||||
|
DeveloperMode,
|
||||||
|
MessageDevTools,
|
||||||
|
SnowflakeTools,
|
||||||
|
ServerInfoTools,
|
||||||
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue