diff --git a/plugins/rose-utils/src/lib/messageActionSheet.ts b/plugins/rose-utils/src/lib/messageActionSheet.ts new file mode 100644 index 0000000..383c5e1 --- /dev/null +++ b/plugins/rose-utils/src/lib/messageActionSheet.ts @@ -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(); +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; + } + }; +} diff --git a/plugins/rose-utils/src/lib/snowflake.ts b/plugins/rose-utils/src/lib/snowflake.ts new file mode 100644 index 0000000..cc38127 --- /dev/null +++ b/plugins/rose-utils/src/lib/snowflake.ts @@ -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; + } +} diff --git a/plugins/rose-utils/src/modules/DeveloperMode/index.ts b/plugins/rose-utils/src/modules/DeveloperMode/index.ts new file mode 100644 index 0000000..5d66da3 --- /dev/null +++ b/plugins/rose-utils/src/modules/DeveloperMode/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts b/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts deleted file mode 100644 index 911d230..0000000 --- a/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts +++ /dev/null @@ -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() {}, - }, -}); diff --git a/plugins/rose-utils/src/modules/MessageDevTools/index.ts b/plugins/rose-utils/src/modules/MessageDevTools/index.ts new file mode 100644 index 0000000..5d11994 --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageDevTools/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/ServerInfoTools/index.ts b/plugins/rose-utils/src/modules/ServerInfoTools/index.ts new file mode 100644 index 0000000..3ab3fd3 --- /dev/null +++ b/plugins/rose-utils/src/modules/ServerInfoTools/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/SnowflakeTools/index.ts b/plugins/rose-utils/src/modules/SnowflakeTools/index.ts new file mode 100644 index 0000000..5303560 --- /dev/null +++ b/plugins/rose-utils/src/modules/SnowflakeTools/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/TenorGifFix/dark.png b/plugins/rose-utils/src/modules/TenorGifFix/dark.png deleted file mode 100644 index 5fba546..0000000 Binary files a/plugins/rose-utils/src/modules/TenorGifFix/dark.png and /dev/null differ diff --git a/plugins/rose-utils/src/modules/TenorGifFix/index.ts b/plugins/rose-utils/src/modules/TenorGifFix/index.ts deleted file mode 100644 index 65e4416..0000000 --- a/plugins/rose-utils/src/modules/TenorGifFix/index.ts +++ /dev/null @@ -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() {}, - }, -}); diff --git a/plugins/rose-utils/src/modules/TenorGifFix/light.png b/plugins/rose-utils/src/modules/TenorGifFix/light.png deleted file mode 100644 index 421f14d..0000000 Binary files a/plugins/rose-utils/src/modules/TenorGifFix/light.png and /dev/null differ diff --git a/plugins/rose-utils/src/modules/index.ts b/plugins/rose-utils/src/modules/index.ts index 4448ae9..e922a5c 100644 --- a/plugins/rose-utils/src/modules/index.ts +++ b/plugins/rose-utils/src/modules/index.ts @@ -1,11 +1,23 @@ import SpotifyListenAlong from "./SpotifyListenAlong"; -import TenorGifFix from "./TenorGifFix"; import SendSpotifyInvite from "./SendSpotifyInvite"; import Minimod from "./Minimod"; 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 // 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"). -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, +];