[ALL] Commit cleanup, comment cleanup
This commit is contained in:
parent
c1bc18a3df
commit
17ffd75a34
77 changed files with 293 additions and 831 deletions
|
|
@ -24,12 +24,8 @@ function parseTimestamp(value: any): any {
|
|||
return moment(value);
|
||||
}
|
||||
|
||||
// RowManager used to be looked up eagerly at module-import time with the cached findByName - a
|
||||
// plugin's top-level code runs as soon as Discord requires its bundle, which can be before Discord's
|
||||
// own code has required RowManager itself, and Revenge's findByName permanently caches a negative
|
||||
// result. Confirmed live via Key Inspector's Eval console: a raw, uncached scan found
|
||||
// RowManager.prototype.generate present and correct once the module had actually initialized - the
|
||||
// class itself was never the problem, just the timing of a one-shot lookup racing it.
|
||||
// findByName caches a negative result permanently if it runs before RowManager registers -
|
||||
// waitFor + a raw lookup retries until it's actually there.
|
||||
export default function patchRowManager(): () => void {
|
||||
const patches: (() => void)[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ function copyRoleColor(colorStrings: any, fallbackColor?: string | null) {
|
|||
}
|
||||
|
||||
function patchRolePill(): () => void {
|
||||
// Confirmed against decompiled current-build Discord source: the component is now just
|
||||
// "RolePill" (at app/components_native/common/RolePill.tsx) - "ThemedRolePill" doesn't exist
|
||||
// anywhere in it. Checking both names covers older builds that might still use the old one.
|
||||
// "RolePill" on current builds, "ThemedRolePill" on older ones.
|
||||
const RolePillComponent = findByName("RolePill", false) ?? findByName("ThemedRolePill", false);
|
||||
if (!RolePillComponent) return () => {};
|
||||
|
||||
|
|
@ -81,11 +79,8 @@ function patchRolePill(): () => void {
|
|||
});
|
||||
}
|
||||
|
||||
// Covers the role pills in a full profile's Roles section - a different render path from
|
||||
// patchRolePill above (that one's for role mentions in message text). This section renders
|
||||
// UserProfileRolesCard -> RolesList -> RoleItem, none of which are top-level exports, so we need
|
||||
// createElementIntercept to catch RoleItem's reference. It hands us the role object directly, with
|
||||
// both colorStrings and colorString on it already, so no need to guess at it from rendered styles.
|
||||
// Covers role pills in a full profile's Roles section (UserProfileRolesCard -> RolesList ->
|
||||
// RoleItem, none of which are top-level exports, hence createElementIntercept).
|
||||
function patchProfileRoleItem(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import openTagEditor from "../ui/TagEditorAlert";
|
|||
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
|
||||
// Covers member list rows and the profile popout, same target Staff Tags already confirmed works
|
||||
// (findByTypeNameAll("UserRow")) - and the long-press hook for assigning a tag, since this repo has
|
||||
// no confirmed way to hook Discord's own user context menu.
|
||||
// Covers member list rows and the profile popout, plus the long-press hook for assigning a tag
|
||||
// (no confirmed way to hook Discord's own user context menu).
|
||||
const rowPatch = ([{ user }]: any[], res: any) => {
|
||||
try {
|
||||
if (user?.id && res?.props) {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import resolveTag from "../lib/resolveTag";
|
|||
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
|
||||
// findByName("DisplayName", false) doesn't match - confirmed live via Key Inspector's Eval console
|
||||
// (a full module-key scan, while fixing Staff Tags' identical lookup) that the export survives as a
|
||||
// plain named property, not the component's own runtime .name/.displayName, which doesn't survive
|
||||
// production minification. findByProps looks at property keys instead.
|
||||
// findByName("DisplayName") doesn't survive minification - findByProps looks at the property key
|
||||
// instead.
|
||||
const DisplayNameModule = findByProps("DisplayName") as any;
|
||||
|
||||
export default () => {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,8 @@ import Icon from "../ui/Icon";
|
|||
const Tag = findByProps("getBotLabel");
|
||||
const { View, Text } = ReactNative;
|
||||
|
||||
/**
|
||||
* Discord's own Tag component ignores custom text/color props unless we push them into the
|
||||
* already-rendered tree ourselves - this is what actually makes our overrides visible. Now also
|
||||
* supports an `icon` prop (an IconDef from our icon library) and `iconColor` so tags can render
|
||||
* with or without text.
|
||||
*/
|
||||
// Discord's Tag component ignores custom text/color props unless pushed into the already-rendered
|
||||
// tree directly.
|
||||
export default () => {
|
||||
if (!Tag) return () => {};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,9 @@ let unpatchAll: () => void = () => {};
|
|||
|
||||
export default {
|
||||
onLoad() {
|
||||
// The "real" picker (EffectPickerActionSheet.tsx) works by manually calling Discord's own
|
||||
// EditProfileEffectActionSheet as a plain function from inside this plugin's own render,
|
||||
// outside React's normal reconciliation - Discord's component's hooks end up chained onto
|
||||
// this fiber's hook list. If that component's own hook count/order ever varies, that's the
|
||||
// "Rendered more/fewer hooks" crash class, and it's the one this plugin's own settings
|
||||
// screen already had a manual override toggle for. Defaulting to the fallback picker (a
|
||||
// self-contained implementation using only its own hooks) instead of opting into that risk
|
||||
// by default - it's a real crash a user hit live opening the Effects picker.
|
||||
// The "real" picker chains Discord's own hooks onto this fiber outside React's normal
|
||||
// reconciliation, risking a "Rendered more/fewer hooks" crash - default to the safer,
|
||||
// self-contained fallback picker instead.
|
||||
storage.forceFallbackEffectPicker ??= true;
|
||||
|
||||
unpatchAll = applyPatches("FakeProfileThemesAndEffects", logger, {
|
||||
|
|
|
|||
|
|
@ -1,44 +1,24 @@
|
|||
/* eslint-disable @stylistic/brace-style */
|
||||
|
||||
/** The FPTE delimiter codepoint (codepoint of zero-width space). */
|
||||
const DELIMITER_CODEPOINT = 0x200B;
|
||||
/** The FPTE delimiter (zero-width space). */
|
||||
const DELIMITER_CODEPOINT = 0x200B; // zero-width space
|
||||
const DELIMITER = String.fromCodePoint(DELIMITER_CODEPOINT);
|
||||
/** The FPTE radix (number of default-ignorable codepoints in the SSP plane). */
|
||||
const RADIX = 0x1000;
|
||||
/** The FPTE starting codepoint (first codepoint in the SSP plane). */
|
||||
const STARTING_CODEPOINT = 0xE0000;
|
||||
/** The FPTE ending codepoint (last default-ignorable codepoint in the SSP plane). */
|
||||
const RADIX = 0x1000; // count of default-ignorable codepoints in the SSP plane
|
||||
const STARTING_CODEPOINT = 0xE0000; // first codepoint in the SSP plane
|
||||
const ENDING_CODEPOINT = STARTING_CODEPOINT + RADIX - 1;
|
||||
|
||||
/**
|
||||
* Builds a theme color string in the legacy format: `[#primary,#accent]`, where primary and accent are
|
||||
* 24-bit colors as base-16 strings, with each codepoint of the string offset by +{@link STARTING_CODEPOINT}.
|
||||
* @param primary The 24-bit primary color.
|
||||
* @param accent The 24-bit accent color.
|
||||
* @returns The built legacy-format theme color string.
|
||||
*/
|
||||
/** Legacy format: `[#primary,#accent]`, each codepoint offset by +STARTING_CODEPOINT. */
|
||||
export function encodeColorsLegacy(primary: number, accent: number) {
|
||||
return String.fromCodePoint(...[...`[#${primary.toString(16)},#${accent.toString(16)}]`]
|
||||
.map(c => c.codePointAt(0)! + STARTING_CODEPOINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the theme colors from a legacy-format string.
|
||||
* @param str The legacy-format string to extract the theme colors from.
|
||||
* @returns The profile theme colors. Colors will be -1 if not found.
|
||||
* @see {@link encodeColorsLegacy}
|
||||
*/
|
||||
/** @returns [primary, accent] colors, or -1 for either if not found. */
|
||||
export function decodeColorsLegacy(str: string): [primaryColor: number, accentColor: number] {
|
||||
const [primary, accent] = str.matchAll(/(?<=#)[\dA-Fa-f]{1,6}/g);
|
||||
return [primary ? parseInt(primary[0], 16) : -1, accent ? parseInt(accent[0], 16) : -1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a 24-bit color to a base-{@link RADIX} string with each codepoint offset by +{@link STARTING_CODEPOINT}.
|
||||
* @param color The 24-bit color to be converted.
|
||||
* @returns The converted base-{@link RADIX} string with +{@link STARTING_CODEPOINT} offset.
|
||||
*/
|
||||
/** Base-RADIX encoding of a 24-bit color, each codepoint offset by +STARTING_CODEPOINT. */
|
||||
export function encodeColor(color: number) {
|
||||
if (color === 0) return String.fromCodePoint(STARTING_CODEPOINT);
|
||||
let str = "";
|
||||
|
|
@ -47,12 +27,7 @@ export function encodeColor(color: number) {
|
|||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a no-offset base-{@link RADIX} string to a 24-bit color.
|
||||
* @param str The no-offset base-{@link RADIX} string to be converted.
|
||||
* @returns The converted 24-bit color.
|
||||
* Will be -1 if `str` is empty and -2 if the color is greater than the maximum 24-bit color, 0xFFFFFF.
|
||||
*/
|
||||
/** @returns the decoded 24-bit color; -1 if str is empty, -2 if over 0xFFFFFF. */
|
||||
export function decodeColor(str: string) {
|
||||
if (str === "") return -1;
|
||||
let color = 0;
|
||||
|
|
@ -63,11 +38,7 @@ export function decodeColor(str: string) {
|
|||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an effect ID to a base-{@link RADIX} string with each code point offset by +{@link STARTING_CODEPOINT}.
|
||||
* @param id The effect ID to be converted.
|
||||
* @returns The converted base-{@link RADIX} string with +{@link STARTING_CODEPOINT} offset.
|
||||
*/
|
||||
/** Base-RADIX encoding of an effect ID, each codepoint offset by +STARTING_CODEPOINT. */
|
||||
export function encodeEffect(id: bigint) {
|
||||
if (id === 0n) return String.fromCodePoint(STARTING_CODEPOINT);
|
||||
let str = "";
|
||||
|
|
@ -76,12 +47,7 @@ export function encodeEffect(id: bigint) {
|
|||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a no-offset base-{@link RADIX} string to an effect ID.
|
||||
* @param str The no-offset base-{@link RADIX} string to be converted.
|
||||
* @returns The converted effect ID.
|
||||
* Will be -1n if `str` is empty and -2n if the color is greater than the maximum effect ID.
|
||||
*/
|
||||
/** @returns the decoded effect ID; -1n if str is empty, -2n if over the max effect ID. */
|
||||
export function decodeEffect(str: string) {
|
||||
if (str === "") return -1n;
|
||||
let id = 0n;
|
||||
|
|
@ -93,122 +59,68 @@ export function decodeEffect(str: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Builds a FPTE string containing the given primary/accent colors and effect ID. If the FPTE Builder is NOT set to backwards
|
||||
* compatibility mode, the primary and accent colors will be converted to base-{@link RADIX} before they are encoded.
|
||||
* @param primary The primary profile theme color. Must be negative if unset.
|
||||
* @param accent The accent profile theme color. Must be negative if unset.
|
||||
* @param effect The profile effect ID. Must be empty if unset.
|
||||
* @param legacy Whether the primary and accent colors should be legacy encoded.
|
||||
* @returns The built FPTE string. Will be empty if the given colors and effect are all unset.
|
||||
* Builds an FPTE string from primary/accent colors (negative = unset) and an effect ID (empty =
|
||||
* unset). In legacy mode, colors are legacy-encoded and both are always included (accent falls
|
||||
* back to primary, or vice versa); otherwise a shared/unset color is omitted to save space.
|
||||
*/
|
||||
export function buildFPTE(primary: number, accent: number, effect: string, legacy: boolean) {
|
||||
/** The FPTE string to be returned. */
|
||||
let fpte = "";
|
||||
|
||||
// If the FPTE Builder is set to backwards compatibility mode,
|
||||
// the primary and accent colors, if set, will be legacy encoded.
|
||||
if (legacy) {
|
||||
// Legacy FPTE strings must include both the primary and accent colors, even if they are the same.
|
||||
|
||||
if (primary >= 0) {
|
||||
// If both the primary and accent colors are set, they will be legacy encoded and added to the
|
||||
// string; otherwise, if the accent color is unset, the primary color will be used in its place.
|
||||
if (accent >= 0)
|
||||
fpte = encodeColorsLegacy(primary, accent);
|
||||
else
|
||||
fpte = encodeColorsLegacy(primary, primary);
|
||||
|
||||
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
|
||||
if (effect)
|
||||
fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
|
||||
fpte = accent >= 0 ? encodeColorsLegacy(primary, accent) : encodeColorsLegacy(primary, primary);
|
||||
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
return fpte;
|
||||
}
|
||||
|
||||
// Since the primary color is unset, the accent color, if set, will be used in its place.
|
||||
if (accent >= 0) {
|
||||
fpte = encodeColorsLegacy(accent, accent);
|
||||
|
||||
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
|
||||
if (effect)
|
||||
fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
|
||||
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
return fpte;
|
||||
}
|
||||
}
|
||||
// If the primary color is set, it will be encoded and added to the string.
|
||||
else if (primary >= 0) {
|
||||
fpte = encodeColor(primary);
|
||||
|
||||
// If the accent color is set and different from the primary color, it
|
||||
// will be encoded and added to the string prefixed by one delimiter.
|
||||
if (accent >= 0 && primary !== accent) {
|
||||
fpte += DELIMITER + encodeColor(accent);
|
||||
|
||||
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
|
||||
if (effect)
|
||||
fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
|
||||
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
return fpte;
|
||||
}
|
||||
}
|
||||
// If only the accent color is set, it will be encoded and added to the string.
|
||||
else if (accent >= 0)
|
||||
fpte = encodeColor(accent);
|
||||
else if (accent >= 0) fpte = encodeColor(accent);
|
||||
|
||||
// Since either the primary/accent colors are the same, both are unset, or just one is set, only one color will be added
|
||||
// to the string; therefore, the effect ID, if set, will be encoded and added to the string prefixed by two delimiters.
|
||||
// Only one (or no) color was written above, so the effect needs a double delimiter to mark
|
||||
// the skipped slot.
|
||||
if (effect)
|
||||
fpte += DELIMITER + DELIMITER + encodeEffect(BigInt(effect));
|
||||
|
||||
return fpte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the delimiter-separated values of the first FPTE substring in a string.
|
||||
* @param str The string to be searched for a FPTE substring.
|
||||
* @returns An array of the found FPTE substring's extracted values. Values will be empty if not found.
|
||||
*/
|
||||
/** Extracts the delimiter-separated values of the first FPTE substring in a string. */
|
||||
export function extractFPTE(str: string) {
|
||||
/** The array of extracted FPTE values to be returned. */
|
||||
const fpte: [maybePrimaryOrLegacy: string, maybeAccentOrEffect: string, maybeEffect: string] = ["", "", ""];
|
||||
/** The current index of {@link fpte} getting extracted. */
|
||||
let i = 0;
|
||||
|
||||
for (const char of str) {
|
||||
/** The current character's codepoint. */
|
||||
const cp = char.codePointAt(0)!;
|
||||
|
||||
// If the current character is a delimiter, then the current index of fpte has been completed.
|
||||
if (cp === DELIMITER_CODEPOINT) {
|
||||
// If the current index of fpte is the last, then the extraction is done.
|
||||
if (i >= 2) break;
|
||||
i++; // Start extracting the next index of fpte.
|
||||
i++;
|
||||
}
|
||||
// If the current character is not a delimiter but a valid FPTE
|
||||
// character, it will be added to the current index of fpte.
|
||||
else if (cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT)
|
||||
fpte[i]! += String.fromCodePoint(cp - STARTING_CODEPOINT);
|
||||
// If an FPTE string has been found and its end has been reached, then the extraction is done.
|
||||
else if (i > 0 || fpte[0]) break;
|
||||
}
|
||||
|
||||
return fpte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a string contains an FPTE substring based on the presence of
|
||||
* FPTE codepoints or the FPTE delimiter.
|
||||
* @param str The string to check.
|
||||
* @returns True if the string contains FPTE codepoints or delimiters, false otherwise.
|
||||
*/
|
||||
/** Detects an FPTE substring by the presence of FPTE codepoints or the delimiter. */
|
||||
export function hasFPTE(str: string): boolean {
|
||||
for (const char of str) {
|
||||
const cp = char.codePointAt(0)!;
|
||||
if (
|
||||
(cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT) || // FPTE codepoint range
|
||||
cp === DELIMITER_CODEPOINT // delimiter zero-width space
|
||||
) {
|
||||
if ((cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT) || cp === DELIMITER_CODEPOINT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -216,11 +128,11 @@ export function hasFPTE(str: string): boolean {
|
|||
}
|
||||
|
||||
export function stripFPTE(str: string) {
|
||||
return [...str]
|
||||
.filter(ch => {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
return (cp < STARTING_CODEPOINT || cp > ENDING_CODEPOINT) && cp !== DELIMITER_CODEPOINT;
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
return [...str]
|
||||
.filter(ch => {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
return (cp < STARTING_CODEPOINT || cp > ENDING_CODEPOINT) && cp !== DELIMITER_CODEPOINT;
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,10 @@ import { lazy } from "@shared/lib/lazy";
|
|||
|
||||
import type { CollectiblesItemType } from "@fpte/lib/records";
|
||||
|
||||
// There is no dedicated "ProfileEffectStore" anymore - confirmed live via devtools that it's
|
||||
// absent from Metro's module registry even after actually using Discord's real, native Nitro
|
||||
// effect picker on a Nitro account (so this isn't a lazy-loading timing issue, the store itself
|
||||
// doesn't exist in the current build). Profile effects are now served from the same shop/
|
||||
// collectibles catalog used for avatar decorations and nameplates: getProfileEffectsFromCategories
|
||||
// (confirmed live to return real data - 340 real effects) reads CollectiblesCategoryStore's
|
||||
// categories and returns ProfileEffectConfig objects directly, not wrapped in the old
|
||||
// {id, skuId, config} shape this file's ProfileEffect type expects - the adapter below rebuilds
|
||||
// that shape so every other file in this plugin didn't need to change.
|
||||
// There's no dedicated ProfileEffectStore anymore - effects are served from the same shop/
|
||||
// collectibles catalog used for avatar decorations and nameplates. getProfileEffectsFromCategories
|
||||
// returns ProfileEffectConfig objects directly, not the old {id, skuId, config} shape this file's
|
||||
// ProfileEffect type expects, so the adapter below rebuilds that shape.
|
||||
const getShopEffectsUtil = lazy(() => findByProps("getProfileEffectsFromCategories") as
|
||||
{ getProfileEffectsFromCategories(categories: unknown): ProfileEffectConfig[] } | undefined);
|
||||
const getCategoryStore = lazy(() => findByStoreName("CollectiblesCategoryStore") as
|
||||
|
|
|
|||
|
|
@ -24,14 +24,9 @@ let lastGoodTree: RN.Node;
|
|||
function PatchedEffectPickerActionSheet(props: EffectPickerActionSheetProps) {
|
||||
const { currentEffectId, effects, onSelect, user } = props;
|
||||
|
||||
// This has to be the very first thing checked, before EffectPicker is ever called below -
|
||||
// that call runs Discord's own EditProfileEffectActionSheet as a plain function from inside
|
||||
// this component's own render, which chains its internal hooks onto this fiber. Checking the
|
||||
// setting only *after* that call (as this used to) still paid that cost on every render
|
||||
// regardless of the setting - the crash it exists to avoid had already happened by the time
|
||||
// the fallback kicked in. forceFallbackEffectPicker doesn't change while this component is
|
||||
// mounted, so returning early here before any hooks run is safe across this component's own
|
||||
// re-renders.
|
||||
// Must be checked before EffectPicker is called below - that call chains Discord's own hooks
|
||||
// onto this fiber, so checking the setting after the call still pays the crash it's meant to
|
||||
// avoid. forceFallbackEffectPicker doesn't change while mounted, so this early return is safe.
|
||||
if (storage.forceFallbackEffectPicker)
|
||||
return <FallbackEffectPickerActionSheet {...props} />;
|
||||
|
||||
|
|
@ -39,10 +34,8 @@ function PatchedEffectPickerActionSheet(props: EffectPickerActionSheetProps) {
|
|||
|
||||
const themeContext = useThemeContext();
|
||||
|
||||
// ProfileEffectRecord's fields mirror the raw shop config (skuId, title, thumbnailPreviewSrc,
|
||||
// effects, ...) - confirmed live that constructing it from our {id, skuId, config} wrapper
|
||||
// instead of effect.config leaves everything but skuId/type undefined, which is why the picker
|
||||
// used to open with a fully empty effect list.
|
||||
// Must be built from effect.config, not our {id, skuId, config} wrapper - the wrapper leaves
|
||||
// everything but skuId/type undefined.
|
||||
const effectRecords = useMemo(() => effects.map(effect => ({ items: new ProfileEffectRecord(effect.config) })), [effects]);
|
||||
|
||||
let isLegacyEffectPicker = false;
|
||||
|
|
@ -105,8 +98,7 @@ function PatchedEffectPickerActionSheet(props: EffectPickerActionSheetProps) {
|
|||
setPreviewUserId(user.id);
|
||||
applyButton.props.onPress = () => {
|
||||
setPreviewUserId(undefined);
|
||||
// selectedProfileEffect is a ProfileEffectRecord - confirmed live it has no .id field, only
|
||||
// .skuId, so comparing against effect.id here always missed and onSelect got null every time.
|
||||
// selectedProfileEffect has no .id field, only .skuId.
|
||||
onSelect(effects.find(effect => effect.skuId === effectPickerInner.props.selectedProfileEffect?.skuId)?.config ?? null);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,8 @@ import { Radius, SafeAreaContext, Spacing, useWindowDimensions } from "@fpte/ui/
|
|||
|
||||
const ROW_SIZE = 3;
|
||||
|
||||
// lodash isn't a dependency of this repo (no node_modules/lodash, nothing in package.json) - the
|
||||
// build's Rollup config only marks react/react-native external, so an unresolved "lodash" import
|
||||
// got emitted as a bare, undefined global reference in the compiled bundle. That threw
|
||||
// "ReferenceError: lodash is not defined" the instant Revenge evaluated this plugin's bundle,
|
||||
// before onLoad ever ran - which is why enabling the plugin silently reverted with no visible
|
||||
// error anywhere. Only one function was ever used from it, so it's inlined here instead.
|
||||
// lodash isn't a dependency here - an unresolved import threw "lodash is not defined" before
|
||||
// onLoad ever ran. Only one function was used from it, so it's inlined here instead.
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@ export function showEffectPicker(
|
|||
onSelect: (effect: ProfileEffectConfig | null) => void,
|
||||
currentEffectId?: string | undefined
|
||||
) {
|
||||
// Called directly from a native onPress with nothing else between it and the touch dispatch -
|
||||
// an uncaught throw here is fatal to the whole app (confirmed live via adb logcat: a plain
|
||||
// TypeError here surfaced as AndroidRuntime FATAL EXCEPTION, not a recoverable React error
|
||||
// screen), so this needs its own try/catch rather than relying on React's own error handling.
|
||||
// Called directly from a native onPress - an uncaught throw here is fatal to the whole app
|
||||
// (surfaces as AndroidRuntime FATAL EXCEPTION, not a recoverable React error screen).
|
||||
try {
|
||||
const user = UserStore.getCurrentUser();
|
||||
const profileEffectStore = getProfileEffectStore();
|
||||
|
|
|
|||
|
|
@ -11,12 +11,8 @@ export const resolveSemanticColor: (theme: Theme, semanticColor: EmptyObject) =>
|
|||
?? find(m => m.meta?.resolveSemanticColor)?.meta.resolveSemanticColor
|
||||
?? (() => undefined);
|
||||
|
||||
/**
|
||||
* Discord renames semantic color tokens across versions - confirmed via live on-device diagnostics
|
||||
* that HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, and TEXT_NORMAL have all been
|
||||
* removed on at least one current build, which is exactly what crashed this plugin's profile editor
|
||||
* every time it rendered. This tries each candidate token name in order and never throws.
|
||||
*/
|
||||
// Discord renames semantic color tokens across versions - tries each candidate name in order and
|
||||
// never throws.
|
||||
export function resolveSemanticColorSafe(theme: Theme, tokenNames: string[], fallbackHex: string): string {
|
||||
for (const name of tokenNames) {
|
||||
const token = semanticColors?.[name];
|
||||
|
|
@ -41,10 +37,8 @@ export const useAvatarColors: (
|
|||
|
||||
export type Theme = "dark" | "light" | "midnight" | "darker";
|
||||
|
||||
// The unguarded version of this line (`findByProps("getProfileTheme").getProfileTheme`) is what was
|
||||
// crashing the whole plugin bundle at load time on at least one Discord build - accessing a property
|
||||
// on the result of a failed findByProps throws immediately, outside any of this plugin's own
|
||||
// try/catch, since it runs while the module is being evaluated, before onLoad is ever reached.
|
||||
// Unguarded, this crashed the whole plugin bundle at load time - a property access on a failed
|
||||
// findByProps throws while the module is being evaluated, before onLoad is ever reached.
|
||||
export const getProfileTheme: <T extends number | null | undefined>(primaryColor: T) => T extends number ? Theme : null
|
||||
= (findByProps("getProfileTheme") as Record<string, any> | undefined)?.getProfileTheme
|
||||
?? ((primaryColor: any) => (primaryColor == null ? null : "dark")) as any;
|
||||
|
|
|
|||
|
|
@ -10,11 +10,8 @@ export interface IconProps extends Pick<ImageProps, "accessible" | "accessibilit
|
|||
disableColor?: boolean | undefined;
|
||||
}
|
||||
|
||||
// Confirmed live: the eager version of this crashed with "Cannot read property 'LARGE' of
|
||||
// undefined" - findByProps("IconSizes") hadn't resolved yet at module-load time, so this fell
|
||||
// through to the bare `() => null` fallback, which has no .Sizes property at all for callers
|
||||
// like `Icon.Sizes.LARGE` to read. Falling back to a real, static SizeKey map instead of an
|
||||
// empty function means Icon always has a valid .Sizes even before the real module resolves.
|
||||
// A bare `() => null` fallback has no .Sizes for callers like Icon.Sizes.LARGE to read before
|
||||
// the real module resolves - this static map keeps Icon.Sizes always valid.
|
||||
const FALLBACK_SIZES: Record<SizeKey, string> = {
|
||||
EXTRA_SMALL_10: "extra_small_10", EXTRA_SMALL: "extra_small", SMALL: "small", SMALL_20: "small_20",
|
||||
MEDIUM: "medium", LARGE: "large", CUSTOM: "custom", REFRESH_SMALL_16: "refresh_small_16", SMALL_14: "small_14",
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ export * from "./Icon";
|
|||
export * from "./StaticEffect";
|
||||
export * from "./Text";
|
||||
|
||||
// findByName("FlashList") never matches - confirmed live (Key Inspector's Eval console, a full
|
||||
// module-key scan) that the exported property is still literally named FlashList, but findByName
|
||||
// matches by the component's own runtime .name/.displayName, which doesn't survive production
|
||||
// minification the way the export's property key does. findByProps looks at property keys instead.
|
||||
// findByName("FlashList") doesn't survive minification - findByProps looks at the property key.
|
||||
const flashListModule = findByProps("FlashList") as Record<string, any> | undefined;
|
||||
export const FlashList: typeof $FlashList = flashListModule?.FlashList ?? (() => null);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,8 @@ function kindOf(value: any): string {
|
|||
return typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks the stable `window.vendetta` API surface exposed to every plugin and lists
|
||||
* every key path found, bounded so it stays useful instead of dumping megabytes of Discord internals.
|
||||
* This is the actual compat API plugins are built against - not Discord's full module registry.
|
||||
*/
|
||||
// Walks the stable window.vendetta API surface and lists every key path found, bounded so it
|
||||
// stays useful instead of dumping Discord's full internals.
|
||||
export function dumpVendettaApiTree(maxDepth = 3): string {
|
||||
const seen = new WeakSet<object>();
|
||||
const lines: string[] = [];
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ interface Check {
|
|||
run: () => any;
|
||||
}
|
||||
|
||||
// Every internal lookup this repo's plugins (plus in-progress ports like FPTE) actually depend on.
|
||||
// Add to this list whenever a new plugin introduces a new lookup - that's what makes "run a full
|
||||
// scan" useful instead of checking one thing at a time.
|
||||
// Every internal lookup this repo's plugins depend on - add to this list whenever a new plugin
|
||||
// introduces one.
|
||||
const CHECKS: Check[] = [
|
||||
// Staff Tags
|
||||
{ label: 'findByProps("computePermissions", "canEveryoneRole")', run: () => findByProps("computePermissions", "canEveryoneRole") },
|
||||
|
|
|
|||
|
|
@ -40,10 +40,8 @@ function safeStringify(value: any): string {
|
|||
}
|
||||
}
|
||||
|
||||
// Android/iOS keyboards commonly have "smart punctuation" that silently swaps straight quotes for
|
||||
// curly ones as you type - that turns "foo" into “foo”, a different character to the JS parser.
|
||||
// Harmless to keep normalizing even though it turned out not to be the actual cause of the errors
|
||||
// seen while building this (see below) - cheap, and still a real footgun on its own.
|
||||
// Mobile keyboards commonly swap straight quotes for curly ones while typing, which is a
|
||||
// different character to the JS parser.
|
||||
function normalizeSmartPunctuation(code: string): string {
|
||||
return code
|
||||
.replace(/[‘’]/g, "'")
|
||||
|
|
@ -51,22 +49,10 @@ function normalizeSmartPunctuation(code: string): string {
|
|||
.replace(/[–—]/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs arbitrary code typed into the Eval box, with this repo's own lookup helpers already in
|
||||
* scope - built so an on-device assumption behind a fix (does this property exist, does this call
|
||||
* behave the way decompiled source suggested) can be checked in seconds instead of a full
|
||||
* build/push/update/test cycle.
|
||||
*
|
||||
* The dynamically-compiled function body is deliberately NOT `async`. Hermes (RN's JS engine)
|
||||
* precompiles the whole app bundle to bytecode ahead of time, and its runtime `eval`/`new Function`
|
||||
* path only supports a reduced subset of the language - confirmed on-device that it rejects `async`
|
||||
* functions outright ("async functions are unsupported"), even though normal precompiled code
|
||||
* (including this very function) uses async/await freely. So the user's code runs as a plain
|
||||
* synchronous function; if it needs to do something timed/async, it returns a Promise itself
|
||||
* (`return new Promise(resolve => setTimeout(() => resolve(x), 1000))` works fine - only the
|
||||
* `async`/`await` *keywords* are the problem, not Promises as a runtime object) and this function
|
||||
* awaits that result out here, in precompiled code where await is fully supported.
|
||||
*/
|
||||
// Runs arbitrary code typed into the Eval box, with this repo's own lookup helpers in scope. The
|
||||
// compiled function body is deliberately NOT async - Hermes's runtime eval/new Function path
|
||||
// rejects the async keyword outright, so the code runs as a plain sync function and returns a
|
||||
// Promise itself if it needs to do something timed, which this function awaits out here instead.
|
||||
export async function runEval(rawCode: string): Promise<string> {
|
||||
const code = normalizeSmartPunctuation(rawCode);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,9 @@ import { before } from "@vendetta/patcher";
|
|||
|
||||
const MAX_DISTINCT_TYPES = 500;
|
||||
|
||||
/**
|
||||
* Captures every FluxDispatcher action type dispatched over `durationMs`, with an optional
|
||||
* substring filter, and returns a count-sorted report. Built for the everyday plugin-dev question
|
||||
* "what actually fires when I do X" - patches FluxDispatcher.dispatch itself (the same `before`
|
||||
* technique every other patch in this repo uses) rather than any internal interceptor API, so it
|
||||
* observes without ever altering or swallowing a single dispatch. Auto-unpatches when the window
|
||||
* ends or the distinct-type cap is hit, so a runaway capture can't leak a patch or grow unbounded.
|
||||
*/
|
||||
// Captures every FluxDispatcher action type dispatched over durationMs and returns a count-sorted
|
||||
// report. Patches dispatch itself, observing without altering it, and auto-unpatches when the
|
||||
// window ends or the distinct-type cap is hit.
|
||||
export function captureFluxEvents(durationMs: number, filter?: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const counts = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -13,16 +13,9 @@ function safeStringify(value: any): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the props a component actually receives, for up to `maxCalls` renders or `durationMs`
|
||||
* (whichever comes first) - built for the everyday "what shape are these props really" question
|
||||
* that normally means digging through decompiled source. Only works for components exported as a
|
||||
* React.memo/forwardRef wrapper (i.e. they have a `.type` property React calls through) - that
|
||||
* covers most of what findByTypeName/findByName actually return, but a bare unwrapped function
|
||||
* component can't be patched this way (there's no property to intercept a plain function's own
|
||||
* call through; you'd need the module object it's exported from instead - grab that with the Eval
|
||||
* tool and `before("default", thatModule, ...)` the same way this file does).
|
||||
*/
|
||||
// Captures the props a component actually receives, for up to maxCalls renders or durationMs.
|
||||
// Only works for React.memo/forwardRef-wrapped components (they have a .type property to patch) -
|
||||
// a bare function component has no property to intercept its own calls through.
|
||||
export function captureComponentRenders(componentName: string, maxCalls: number, durationMs: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const name = componentName.trim();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type { LoggedAttachment, LoggedMessage } from "./types";
|
||||
|
||||
// Every field read below is part of Discord's own public message object schema - checked in both
|
||||
// camelCase and snake_case since MessageStore's cached objects don't consistently normalize every
|
||||
// field the same way across message sources (gateway vs. REST-backfilled history).
|
||||
// Checked in both camelCase and snake_case since MessageStore's cached objects don't consistently
|
||||
// normalize every field across message sources.
|
||||
function attachmentsOf(message: any): LoggedAttachment[] {
|
||||
const attachments = message?.attachments;
|
||||
if (!Array.isArray(attachments)) return [];
|
||||
|
|
|
|||
|
|
@ -14,15 +14,11 @@ const CLEANUP_FLAG = "__msgLoggerCleanup";
|
|||
const ChannelMessages = findByProps("_channelMessages");
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
|
||||
// Ids currently kept "alive" as a faked MESSAGE_UPDATE instead of a real delete, mapped to the
|
||||
// channel they belong to - tracked so onUnload can put them back the way it found them (dispatch a
|
||||
// real delete for each) rather than leaving the client's own message cache holding fake state
|
||||
// forever if the plugin gets disabled.
|
||||
// Ids currently kept "alive" as a faked MESSAGE_UPDATE instead of a real delete, mapped to their
|
||||
// channel - tracked so onUnload can dispatch a real delete for each on unload.
|
||||
export const fakedMessages = new Map<string, string>();
|
||||
|
||||
// Every past content version for a message, oldest first, current content NOT included (rowStyling
|
||||
// appends that itself from the live record). Same id-in-external-map pattern as fakedMessages -
|
||||
// row.message.id survives into the row JSON, a custom property doesn't (confirmed live).
|
||||
// Past content versions per message, oldest first, current content not included.
|
||||
export const editHistory = new Map<string, string[]>();
|
||||
|
||||
function currentUserId(): string | undefined {
|
||||
|
|
@ -130,9 +126,8 @@ function handleBulkDelete(event: any): any {
|
|||
|
||||
if (!kept.length) return undefined; // nothing kept inline - let the real bulk delete proceed as-is
|
||||
|
||||
// Dispatching more actions from inside a dispatch-in-progress is a real re-entrancy risk (Flux
|
||||
// implementations commonly disallow it) - deferred to a microtask, one dispatch per kept message,
|
||||
// running after this bulk-delete's own dispatch has fully finished.
|
||||
// Dispatching from inside a dispatch-in-progress risks Flux's re-entrancy guard - deferred to
|
||||
// a microtask, after this bulk-delete's own dispatch has finished.
|
||||
queueMicrotask(() => {
|
||||
for (const id of kept) {
|
||||
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,8 @@ import { after, instead } from "@vendetta/patcher";
|
|||
const TAG = "[MessageLogger]";
|
||||
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
|
||||
|
||||
// Discord wraps raw message JSON into a "MessageRecord" class before it ever reaches row
|
||||
// generation - a plain custom field set directly on the raw object doesn't reliably survive that
|
||||
// wrap. Confirmed against three independent existing plugins doing the same delete-as-update trick
|
||||
// (all thread a custom flag through this exact layer) - without this, the flag rowStyling.ts looks
|
||||
// for is gone by the time a row actually gets built.
|
||||
// Discord wraps raw message JSON into a MessageRecord class before it reaches row generation - a
|
||||
// plain custom field on the raw object doesn't reliably survive that wrap without this.
|
||||
export function patchMessageRecord(cleanups: (() => void)[]): boolean {
|
||||
const MessageRecordUtils = findByProps("updateMessageRecord", "createMessageRecord");
|
||||
const MessageRecord = findByName("MessageRecord", false);
|
||||
|
|
|
|||
|
|
@ -11,14 +11,9 @@ function textNode(text: string) {
|
|||
return { content: text, type: "text" };
|
||||
}
|
||||
|
||||
// Confirmed live via the decompiled Discord bundle: Discord's own "(edited)" tag isn't a plain text
|
||||
// node - it's colored through a dedicated `editedColor` field on the row that only applies to ONE
|
||||
// tag per row, which our per-history-entry tags can't use (there can be several, one per past
|
||||
// version). role-color-everywhere already found the workaround for coloring arbitrary text nodes on
|
||||
// this bridge: wrap the node in a `{type: "link", target: "usernameOnClick", ...}` shell and set a
|
||||
// `linkColor` inside it - text nodes themselves don't support a color field, but this wrapper does.
|
||||
// TEXT_MUTED is the same semantic token Discord's own muted/secondary text (timestamps, the real
|
||||
// edited tag) uses, confirmed live: resolves to #8a8a9a on this build's theme.
|
||||
// Discord's own "(edited)" tag is colored via a dedicated `editedColor` row field that only applies
|
||||
// once per row - no good for multiple per-history tags. Text nodes have no color field of their
|
||||
// own, so this wraps the tag in the same `link`/`linkColor` trick role-color-everywhere uses.
|
||||
function editedTagNode() {
|
||||
const color = resolveSemanticColorSafe(["TEXT_MUTED"], "#949BA4");
|
||||
return {
|
||||
|
|
@ -39,10 +34,8 @@ function editedTagNode() {
|
|||
}
|
||||
|
||||
// message.content in the row JSON is an array of {content, type} rich-text nodes, not a plain
|
||||
// string (confirmed live). Old versions get their own muted-colored "(edited)" tag stacked above
|
||||
// the current content - the current version keeps Discord's own native tag rather than getting a
|
||||
// second one from here, since that already renders correctly on a genuinely-edited message with no
|
||||
// help needed.
|
||||
// string. Old versions get their own muted "(edited)" tag stacked above the current content, which
|
||||
// keeps Discord's native tag since that already renders correctly on its own.
|
||||
function applyEditHistory(message: any) {
|
||||
const history = editHistory.get(message.id);
|
||||
if (!history?.length) return;
|
||||
|
|
@ -58,11 +51,9 @@ function applyEditHistory(message: any) {
|
|||
message.content = nodes;
|
||||
}
|
||||
|
||||
// Confirmed live: whatever builds the row JSON sent across the native bridge only carries known
|
||||
// schema fields through - a custom property set directly on the MessageRecord (confirmed present
|
||||
// there via eval) never survives into row.message here. message.id does survive, though, so
|
||||
// checking membership in fluxIntercept's own fakedMessages/editHistory maps (already tracked there
|
||||
// for onUnload cleanup / log capture) works where a custom flag on the row itself can't.
|
||||
// The row JSON sent across the native bridge only carries known schema fields - a custom property
|
||||
// on the message never survives, but message.id does, so we check membership in fluxIntercept's
|
||||
// own maps instead of a flag on the row itself.
|
||||
function handleRow(row: any) {
|
||||
const message = row?.message;
|
||||
if (!message?.id) return;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import { resolveSemanticColorSafe } from "@shared/lib/color";
|
|||
import { getLog, removeLogEntry } from "../lib/store";
|
||||
import type { LoggedMessage } from "../lib/types";
|
||||
|
||||
// Same Navigation/Navigator/close-button lookup already proven working elsewhere in this repo
|
||||
// (ViewRaw, RoseUtils' own log viewer) - reused verbatim including the fallback chain.
|
||||
// Same Navigation/Navigator/close-button lookup as this repo's ViewRaw plugin.
|
||||
const Navigation = findByProps("push", "pushLazy", "pop");
|
||||
const modalCloseButton =
|
||||
findByProps("getRenderCloseButton")?.getRenderCloseButton ??
|
||||
|
|
|
|||
|
|
@ -5,16 +5,9 @@ import { rawFindByTypeName } from "@shared/lib/rawFind";
|
|||
import PronounSection from "./ui/PronounSection";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
// @vendetta/metro's find() caches a negative result forever, including one that only missed
|
||||
// because this plugin's onLoad ran before UserProfileContent's module had registered yet - once
|
||||
// cached, no amount of retrying with find() would ever see the real module. rawFindByTypeName
|
||||
// scans window.modules directly instead, so retrying it actually works. Confirmed live: this
|
||||
// exact search + the findInReactTree logic below both work correctly once given a real reference
|
||||
// - the only thing broken was find()'s one-shot, permanently-cached lookup.
|
||||
//
|
||||
// Discord renamed the top-level profile component from "UserProfile" to "UserProfileContent" at
|
||||
// some point (confirmed against decompiled current-build source) - checking both names covers
|
||||
// whichever one a given Discord build actually uses.
|
||||
// find() caches a negative result forever, so retrying it after onLoad races module registration
|
||||
// never works - rawFindByTypeName scans window.modules directly instead. Checks both names since
|
||||
// Discord renamed the top-level profile component from "UserProfile" to "UserProfileContent".
|
||||
function getUserProfile(): any {
|
||||
return rawFindByTypeName("UserProfileContent") ?? rawFindByTypeName("UserProfile");
|
||||
}
|
||||
|
|
@ -25,8 +18,7 @@ function patchProfile(): (() => void) | false {
|
|||
|
||||
return after("type", UserProfile, (_: any, res: any) => {
|
||||
try {
|
||||
// Same story here - the bio card component is now named "UserProfileAboutMeCard",
|
||||
// was "UserProfileBio" in older builds.
|
||||
// "UserProfileAboutMeCard" now, "UserProfileBio" in older builds.
|
||||
const bioSection = findInReactTree(res, (r) =>
|
||||
Array.isArray(r?.props?.children) &&
|
||||
r.props.children.some((c: any) => c?.type?.name === "UserProfileAboutMeCard" || c?.type?.name === "UserProfileBio")
|
||||
|
|
@ -35,8 +27,7 @@ function patchProfile(): (() => void) | false {
|
|||
const children = bioSection?.props?.children;
|
||||
if (!Array.isArray(children)) return;
|
||||
|
||||
// userId is a direct prop on these cards now, not nested under displayProfile.userId
|
||||
// - keeping the old path as a fallback in case another build still shapes it that way.
|
||||
// userId is a direct prop on these cards now, falling back to the older nested path.
|
||||
const withProfile = children.find(
|
||||
(c: any) => typeof c?.props?.userId === "string" || typeof c?.props?.displayProfile?.userId === "string"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ export default function PronounSection({ userId }: { userId: string }) {
|
|||
|
||||
if (!pronouns || !UserProfileSection) return null;
|
||||
|
||||
// Had no explicit color at all - illegible black-on-black on Discord's dark theme, since RN's
|
||||
// Text defaults to black with no theming of its own.
|
||||
// RN's Text defaults to black with no theming - illegible on Discord's dark theme otherwise.
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -21,14 +21,9 @@ export interface QuestActions {
|
|||
fetchCurrentQuests?: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* rawFindByProps only checks that named properties exist on a module, not that they're real
|
||||
* functions - live-verified against this exact codebase that a decoy module matches
|
||||
* ("getAPIBaseURL"+"get"+"post") with "get" set to null, throwing "null is not a function" the
|
||||
* moment it's actually called. Discord's real internal REST client additionally has
|
||||
* getAPIBaseURL as a real function AND a V8APIError/V6OrEarlierAPIError export the decoy lacks -
|
||||
* this combo was confirmed live with an actual GET /quests/@me call before being trusted here.
|
||||
*/
|
||||
// rawFindByProps only checks that named properties exist, not that they're real functions - a
|
||||
// decoy module can match with e.g. "get" set to null. Also requiring a real getAPIBaseURL function
|
||||
// plus a V8APIError/V6OrEarlierAPIError export rules those decoys out.
|
||||
export const getRestClient = lazy<RestClient>(() => {
|
||||
const modules = window?.modules;
|
||||
if (!modules) return undefined;
|
||||
|
|
@ -57,15 +52,10 @@ export const getRestClient = lazy<RestClient>(() => {
|
|||
|
||||
export const getQuestStore = lazy<any>(() => rawFindByStoreName("QuestStore"));
|
||||
|
||||
/**
|
||||
* Only used for an optional, best-effort refresh (see questCompleter.ts) - completion itself goes
|
||||
* through direct REST calls (verified live end-to-end), not this module. Live-verified: patching
|
||||
* this module's exports to OBSERVE Discord's own internal calls does not work (same-chunk closure
|
||||
* calls bypass the exports object - see project_hook_mutation_doesnt_reach_internal_calls), but
|
||||
* that limitation is specific to patching/observing internal calls. A plugin calling a function on
|
||||
* this exports object directly, as an external caller, goes through the property lookup normally
|
||||
* and is unaffected by that limitation.
|
||||
*/
|
||||
// Only used for an optional, best-effort refresh - completion itself goes through direct REST
|
||||
// calls, not this module. Calling a function on this exports object directly (as an external
|
||||
// caller) works fine; PATCHING it wouldn't, since same-chunk internal calls bypass the exports
|
||||
// object.
|
||||
export const getQuestActions = lazy<QuestActions>(() => {
|
||||
const modules = window?.modules;
|
||||
if (!modules) return undefined;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import { getRestClient, getQuestStore, getQuestActions } from "./modules";
|
|||
|
||||
const TAG = "[QuestOrbs]";
|
||||
|
||||
// Live-verified against a real WATCH_VIDEO_ON_MOBILE completion: Discord's own client sends a
|
||||
// video-progress heartbeat roughly every 7-8s while the video is actually playing. Matching that
|
||||
// cadence keeps this real-time paced (timestamps reflect genuinely elapsed time, never faked/sped
|
||||
// up), per the explicit "real-time paced, safer" design choice.
|
||||
// Matches Discord's own ~7-8s video-progress heartbeat cadence - real-time paced, never sped up.
|
||||
const HEARTBEAT_INTERVAL_MS = 8000;
|
||||
|
||||
export interface QuestRunResult {
|
||||
|
|
@ -35,11 +32,7 @@ function questName(quest: any): string {
|
|||
return quest?.config?.messages?.questName ?? quest?.id ?? "unknown quest";
|
||||
}
|
||||
|
||||
/**
|
||||
* Request bodies are live-verified exact shapes from a captured real enroll/claim call - Discord's
|
||||
* wire format is snake_case (distinct from QuestStore's client-normalized camelCase quest
|
||||
* objects). traffic_metadata_sealed is copied verbatim from the quest object, not generated.
|
||||
*/
|
||||
// Discord's wire format is snake_case, distinct from QuestStore's camelCase quest objects.
|
||||
function questActionBody(quest: any, extra?: Record<string, unknown>) {
|
||||
return {
|
||||
location: 12,
|
||||
|
|
@ -57,12 +50,7 @@ async function enroll(rest: ReturnType<typeof getRestClient>, quest: any): Promi
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends real-time-paced heartbeats until the response reports completion. Response bodies here are
|
||||
* raw server wire format (snake_case) since this calls the REST client directly, not Discord's own
|
||||
* action creators - live-verified: completed_at (top-level) flips non-null exactly when the target
|
||||
* duration is reached.
|
||||
*/
|
||||
// Sends real-time-paced heartbeats until completed_at flips non-null.
|
||||
async function watchVideo(rest: ReturnType<typeof getRestClient>, quest: any): Promise<boolean> {
|
||||
const target: number = quest.config.taskConfigV2.tasks.WATCH_VIDEO_ON_MOBILE.target;
|
||||
let elapsed = 0;
|
||||
|
|
@ -84,13 +72,9 @@ async function watchVideo(rest: ReturnType<typeof getRestClient>, quest: any): P
|
|||
|
||||
type ClaimOutcome = "claimed" | "captcha" | "error";
|
||||
|
||||
/**
|
||||
* A first real claim attempt during discovery came back HTTP 400 with a captcha_key/captcha_sitekey
|
||||
* body (hCaptcha) - this function detects that shape and stops, it never attempts to solve or
|
||||
* bypass it. Whether Discord's REST client rejects the promise or resolves with ok:false on a
|
||||
* non-2xx wasn't pinned down live (both code paths are handled defensively here), so this checks
|
||||
* both a thrown error and a resolved-but-not-ok response for the captcha shape.
|
||||
*/
|
||||
// Detects a captcha_key response and stops - never attempts to solve or bypass it. Checks both a
|
||||
// thrown error and a resolved-but-not-ok response, since which path the REST client takes on a
|
||||
// non-2xx isn't pinned down.
|
||||
async function claim(rest: ReturnType<typeof getRestClient>, quest: any): Promise<ClaimOutcome> {
|
||||
try {
|
||||
const res = await rest!.post({
|
||||
|
|
@ -108,11 +92,8 @@ async function claim(rest: ReturnType<typeof getRestClient>, quest: any): Promis
|
|||
}
|
||||
}
|
||||
|
||||
/** Best-effort refresh so Discord's own UI (Quest Dock, Quests screen) doesn't show stale data
|
||||
* after this plugin completes quests via direct REST calls instead of Discord's own action
|
||||
* creators/dispatcher. Calling this function directly (not patching it) is a normal external call
|
||||
* through the exports object, unaffected by the same-chunk-closure limitation that blocks
|
||||
* PATCHING it - see modules.ts. Purely cosmetic: never blocks the real completion flow. */
|
||||
// Best-effort refresh so Discord's UI doesn't show stale data after completing quests via direct
|
||||
// REST calls instead of Discord's own action creators. Purely cosmetic.
|
||||
function refreshQuestStore(): void {
|
||||
try {
|
||||
getQuestActions()?.fetchCurrentQuests?.();
|
||||
|
|
|
|||
|
|
@ -12,13 +12,8 @@ const { View, Text } = ReactNative;
|
|||
const textColor = () => resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
const warningColor = () => resolveSemanticColorSafe(["TEXT_DANGER", "TEXT_NORMAL"], "#f0b232");
|
||||
|
||||
/**
|
||||
* Deliberately the first thing on this screen, not folded into the smaller NoteBox below it -
|
||||
* automating quest completion is a form of client modification, and Discord's Terms of Service
|
||||
* prohibit exactly that. This isn't a theoretical/fine-print risk: it can get the account
|
||||
* suspended or banned, same as any other unofficial client automation. Nothing about running this
|
||||
* plugin is guaranteed safe just because the requests themselves mirror real client traffic.
|
||||
*/
|
||||
// Deliberately the first thing on this screen, not folded into the NoteBox below it - this is a
|
||||
// real ban risk, not fine print.
|
||||
function BanWarning() {
|
||||
return (
|
||||
<View
|
||||
|
|
|
|||
|
|
@ -8,12 +8,8 @@ let unpatchAll: () => void = () => {};
|
|||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
// The previous release's force-disable (storage.forceDisabledV2) was based on a misdiagnosis -
|
||||
// what looked like a guild-icon false positive was actually the DM list rendering correctly.
|
||||
// The real issue was the size-multiplier growth model being disproportionate on larger avatars
|
||||
// (confirmed on-device: "way too big" on YouBar) - fixed in ringPatch.ts by switching to a
|
||||
// fixed-px additive growth instead of a percentage multiplier. Force re-enabling once now that
|
||||
// the actual defect is fixed, superseding the old force-disable flag.
|
||||
// Force re-enabling once, superseding the old force-disable flag from before the growth
|
||||
// model in ringPatch.ts was fixed to additive px instead of a percentage multiplier.
|
||||
if (storage.forceReenabledV3 !== true) {
|
||||
storage.enabled = true;
|
||||
storage.forceReenabledV3 = true;
|
||||
|
|
|
|||
|
|
@ -2,20 +2,9 @@ import { General } from "@vendetta/ui/components";
|
|||
import { before } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
|
||||
/**
|
||||
* Forked from AngelW0lf's Radial Status (BSD-3-Clause, see LICENSE). This repo's own first attempt
|
||||
* "generalized" upstream's exact wrapper-size check (32x32, borderRadius 16 only) to any square,
|
||||
* fully-circular wrapper, on the theory that avatar sizes differ across contexts - that broadened
|
||||
* match visibly corrupted member list rows and the profile status indicator on-device, and still
|
||||
* never matched YouBar's own indicator at all.
|
||||
*
|
||||
* Confirmed live via Key Inspector's Eval console (a capture across YouBar, the profile screen,
|
||||
* member lists, and DM lists) exactly which sizes are real presence-carrying wrappers: 24, 32, 40,
|
||||
* 50, 60, and 80 (all square, all borderRadius === width/2, all with a `user.id` string + a
|
||||
* `status` string on children[1]/[3], and every single one with exactly 5 children) - so this now
|
||||
* matches that confirmed set specifically, plus the same child-count check every real one shared,
|
||||
* rather than either the overly narrow single-size check or the overly broad any-circle one.
|
||||
*/
|
||||
// Forked from AngelW0lf's Radial Status (BSD-3-Clause, see LICENSE). Real presence-carrying
|
||||
// wrappers are square, borderRadius === width/2, with a user.id + status on children[1]/[3], and
|
||||
// exactly 5 children - matched against this confirmed set of sizes rather than any circular shape.
|
||||
const CONFIRMED_SIZES = new Set([24, 32, 40, 50, 60, 80]);
|
||||
|
||||
export default function patchRing(): () => void {
|
||||
|
|
@ -47,12 +36,8 @@ export default function patchRing(): () => void {
|
|||
if (!color) return; // no color configured for this status - leave the native dot alone
|
||||
|
||||
const baseSize = wrapper.style[circleIdx].width;
|
||||
// Growth is additive (a fixed number of px on each side), not a percentage multiplier -
|
||||
// a flat multiplier applied the same 30% growth everywhere, which meant a much bigger
|
||||
// ABSOLUTE size increase on large avatars (e.g. +24px at YouBar's 80px size) than on small
|
||||
// ones (+9.6px at a 32px member-list row) even though the relative growth was identical.
|
||||
// That's confirmed on-device as "slightly too big" on member list/profile and "way too big"
|
||||
// on YouBar - additive growth keeps the ring the same absolute thickness everywhere instead.
|
||||
// Additive growth (fixed px), not a percentage multiplier - a flat multiplier made the
|
||||
// ring way too thick on large avatars (YouBar) relative to small ones (member list).
|
||||
const thickness = storage.ringThickness ?? 2;
|
||||
const newSize = baseSize + thickness * 2;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
|||
import ColorInput from "@shared/ui/ColorInput";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
|
||||
// The ring patch itself does a generic colors[status] lookup, not one hardcoded to three statuses -
|
||||
// offline just needed its own configurable entry here to work the same way the other three already
|
||||
// do. Discord doesn't show a colored dot for offline at all normally, so this is opt-in (blank by
|
||||
// default) rather than given a default color like the other three.
|
||||
// Offline is opt-in (blank by default) since Discord doesn't show a colored dot for it normally.
|
||||
const STATUSES: { key: string; label: string; defaultColor?: string }[] = [
|
||||
{ key: "online", label: "Online", defaultColor: "#23A55A" },
|
||||
{ key: "idle", label: "Idle", defaultColor: "#F0B232" },
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import { findInReactTree } from "@vendetta/utils";
|
|||
const { FormSection, FormRow } = Forms;
|
||||
|
||||
// Pins a settings entry into Discord's own App Settings screen instead of leaving it buried under
|
||||
// Revenge's Plugins list - ported from Commands (kmmiio99o/vd-plugins), which calls through
|
||||
// window.bunny. This client only has vendetta, not bunny, so those lookups always came back
|
||||
// undefined and it silently fell back to copying the theme URL. Swapped to Vendetta's
|
||||
// findByProps/findByName, same as every other plugin in this repo.
|
||||
// Revenge's Plugins list - ported from Commands (kmmiio99o/vd-plugins), swapped from bunny to
|
||||
// Vendetta's findByProps/findByName.
|
||||
const tabsNavigationRef = findByProps("getRootNavigationRef");
|
||||
const settingConstants = findByProps("SETTING_RENDERER_CONFIG");
|
||||
const createListModule = findByProps("createList");
|
||||
|
|
@ -73,9 +71,7 @@ function patchPanelUI(tabs: any, patches: any[]) {
|
|||
}, true)
|
||||
);
|
||||
} catch {
|
||||
// This surface (the tablet/desktop-style settings panel) may not exist on this build at
|
||||
// all - patchTabsUI below covers the mobile settings list, which is what actually matters
|
||||
// here. A miss on this one shouldn't block that one.
|
||||
// This surface may not exist on this build - patchTabsUI below covers the mobile list.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import { resolveSemanticColorSafe } from "@shared/lib/color";
|
|||
|
||||
export { interpolateColor } from "@shared/lib/color";
|
||||
|
||||
// TEXT_NORMAL was a bare, un-guarded reference here (confirmed dead via Key Inspector - Discord
|
||||
// renamed its whole semanticColors scheme, TEXT_NORMAL -> TEXT_DEFAULT among others), unlike every
|
||||
// other token lookup in this repo which already goes through a multi-candidate fallback chain.
|
||||
export function defaultTextColor(): string | undefined {
|
||||
return resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ const { Text } = ReactNative;
|
|||
const SelectedGuildStore = findByStoreName("SelectedGuildStore");
|
||||
const GuildRoleStore = findByStoreName("GuildRoleStore");
|
||||
|
||||
// Colors the role section headers in the member list ("MODERATORS", "ADMINS", etc). The old
|
||||
// version patched View.prototype.render looking for a roleId prop - View isn't a class component
|
||||
// anymore and roleId doesn't exist by the time this reaches React. All we get is a pre-formatted
|
||||
// title string like "🚨 Moderator — 1" on a memo'd component called UserSectionInner, so we parse
|
||||
// the role name back out of that and match it against the guild's roles by name.
|
||||
// Colors the role section headers in the member list ("MODERATORS", "ADMINS", etc). Only a
|
||||
// pre-formatted title string like "🚨 Moderator — 1" is available on UserSectionInner, so the role
|
||||
// name is parsed back out of it and matched against the guild's roles by name.
|
||||
export default function patchMemberList(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
|
|
|||
|
|
@ -100,9 +100,8 @@ function handleRow(row: any) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Discord's Android client renders the message list natively - patched at the JS/native JSON
|
||||
// bridge rather than in a React tree, with a fallback to RowManager.generate for older builds.
|
||||
export default function patchRows(): () => void {
|
||||
const { NativeModules } = ReactNative;
|
||||
const DCDChatManager = NativeModules?.DCDChatManager;
|
||||
|
|
@ -119,12 +118,8 @@ export default function patchRows(): () => void {
|
|||
});
|
||||
}
|
||||
|
||||
// RowManager used to be looked up eagerly at module-import time with the cached findByName - a
|
||||
// plugin's top-level code can run before Discord's own code has required RowManager itself, and
|
||||
// Revenge's findByName permanently caches a negative result. Confirmed live via Key Inspector's
|
||||
// Eval console: a raw, uncached scan found RowManager.prototype.generate present once the module
|
||||
// had actually initialized - waitFor + a raw lookup retries until that happens instead of giving
|
||||
// up on the first (possibly premature) miss.
|
||||
// findByName caches a negative result permanently if it runs before RowManager registers -
|
||||
// waitFor + a raw lookup retries until it's actually there.
|
||||
const patches: (() => void)[] = [];
|
||||
const handle = waitFor(
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import { defaultTextColor } from "../lib/color";
|
|||
|
||||
const GuildMemberStore = findByStoreName("GuildMemberStore");
|
||||
|
||||
// Colors usernames in the "X is typing..." indicator. TYPING_WRAPPER_HEIGHT (what this used to
|
||||
// look up) doesn't exist anymore - the actual component is TypingIndicatorInner, which isn't a
|
||||
// top-level export, and it hands us channel + typingUserIds directly as props. Each typing user
|
||||
// gets their own inner <Text> element in the same order as typingUserIds, so we just zip them up.
|
||||
// Colors usernames in the "X is typing..." indicator. The component is TypingIndicatorInner, not
|
||||
// a top-level export, hands us channel + typingUserIds as props - zipped against each user's own
|
||||
// inner <Text> element in the same order.
|
||||
export default function patchTypingWrapper(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ import { findInReactTree } from "@vendetta/utils";
|
|||
|
||||
const { FormSection, FormRow } = Forms;
|
||||
|
||||
// The mobile settings list renders a section's `label` field directly as its header text - `title`
|
||||
// goes unused there (confirmed live: an internal-looking label like "ROSES_PLUGS" rendered verbatim
|
||||
// instead of a real title). Raiden's Themes' own working section lookup relies on the same thing
|
||||
// (label === title === "Revenge"), so both fields just carry the same human-readable string.
|
||||
// The mobile settings list renders a section's `label` as its header text, not `title` - both
|
||||
// fields just carry the same string.
|
||||
const SECTION_LABEL = "Rosie's Plugs";
|
||||
const SECTION_TITLE = "Rosie's Plugs";
|
||||
|
||||
|
|
@ -26,14 +24,10 @@ export interface SectionRow {
|
|||
page: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
// A section's own row-key list is a one-time snapshot (see patchTabsUI below), but SETTING_RENDERER_
|
||||
// CONFIG used to rebuild fresh from getRows() on every access - confirmed live, those two calls can
|
||||
// observe different results (discovery is live, plugin state can shift between them), leaving a key
|
||||
// the snapshot still references missing from the config. Discord's own settings code assumes every
|
||||
// referenced key always resolves and crashes ("Cannot read property 'parent' of undefined") the
|
||||
// moment it doesn't. Accumulating here instead of rebuilding means any key ever referenced by a
|
||||
// section stays resolvable for good - populated both eagerly (right when a section snapshot is
|
||||
// built) and lazily (on every config read), so there's no window where one is ahead of the other.
|
||||
// A section's row-key list is a one-time snapshot, but the live config used to rebuild fresh on
|
||||
// every access - the two could disagree and leave a key the snapshot references missing from the
|
||||
// config, which crashes Discord's settings code. Accumulating instead of rebuilding keeps any
|
||||
// key ever referenced resolvable for good.
|
||||
const rendererRowCache: Record<string, any> = {};
|
||||
|
||||
function cacheRows(rows: SectionRow[]) {
|
||||
|
|
@ -117,8 +111,7 @@ function patchPanelUI(getRows: () => SectionRow[], patches: (() => void)[]) {
|
|||
}, true)
|
||||
);
|
||||
} catch {
|
||||
// This surface (the tablet/desktop-style settings panel) may not exist on this build at
|
||||
// all - patchTabsUI below covers the mobile settings list, which is what actually matters.
|
||||
// This surface may not exist on this build - patchTabsUI below covers the mobile list.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,16 +121,10 @@ function patchTabsUI(getRows: () => SectionRow[], patches: (() => void)[]) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Raiden's Themes installs this exact same Object.defineProperty-override pattern on this same
|
||||
// property. Confirmed live: whichever plugin's override installs LAST completely replaces the
|
||||
// other's getter (defineProperty doesn't chain, it overwrites the whole descriptor) - freezing
|
||||
// the first plugin's rows as a one-time snapshot baked into the second plugin's closure. Any
|
||||
// plugin RosePlugs discovers afterward (installed mid-session, like a freshly-added plugin)
|
||||
// then never reaches SETTING_RENDERER_CONFIG again, even though the section still correctly
|
||||
// lists its key - the exact row-key/config mismatch that crashes Settings. Re-asserting this
|
||||
// override right before every read RosePlugs itself needs (not just once at setup) guarantees
|
||||
// RosePlugs' getter is always the most-recently-installed one when it actually matters, no
|
||||
// matter what order plugins load in or what either one does afterward.
|
||||
// Raiden's Themes installs this same Object.defineProperty override on the same property -
|
||||
// whichever installs last wins outright (defineProperty overwrites, it doesn't chain). Re-
|
||||
// asserting ours before every read we need keeps it the most-recently-installed one regardless
|
||||
// of plugin load order.
|
||||
let baseConfigValue: any = settingConstants.SETTING_RENDERER_CONFIG;
|
||||
|
||||
const configGetter = () => {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@ interface BrowserState {
|
|||
plugins: NexusPlugin[];
|
||||
}
|
||||
|
||||
// Confirmed live: installing from the detail sheet could get stuck on "Installing..." forever -
|
||||
// the promise from installPlugin wasn't reliably settling. This timeout is a safety net so a hung
|
||||
// call can't leave a row stuck indefinitely, on top of moving the action to an inline button.
|
||||
// installPlugin's promise doesn't always reliably settle - this timeout is a safety net so a hung
|
||||
// call can't leave a row stuck on "Installing..." forever.
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Timed out")), ms);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { React } from "@vendetta/metro/common";
|
|||
import { storage } from "@vendetta/plugin";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
|
||||
// Simplified from nexpid's original Module class (nexxutils) - same shape (id/label/settings/
|
||||
// handlers, per-module enable storage, error tracking), with the fancier UI-kit bits (custom
|
||||
// error viewer modal, choose-sheets, table rows) traded for this repo's plain @shared components.
|
||||
// Simplified from nexpid's original Module class (nexxutils), using this repo's plain @shared
|
||||
// components instead of the original's custom UI kit.
|
||||
export const vstorage = storage as {
|
||||
modules: Record<string, { enabled: boolean; options: Record<string, any> }>;
|
||||
};
|
||||
|
|
@ -70,14 +69,9 @@ export class Module<Settings extends Record<string, ModuleSetting>> {
|
|||
private started: boolean;
|
||||
private listeners: Set<() => void>;
|
||||
|
||||
// Assigned in the constructor body rather than as class-field initializers - this repo's
|
||||
// SWC build config lowers `class` to an ES5-style function for compatibility, and that pass
|
||||
// doesn't also handle the separate (newer) class-fields proposal, so field initializers like
|
||||
// `errors: Record<string, string> = {};` were silently getting dropped from the compiled
|
||||
// output entirely. Confirmed by reading the built bundle directly: the compiled constructor
|
||||
// only set id/label/meta/settings/handlers, nothing else - every Module instance was missing
|
||||
// errors/patches/started/listeners, which crashed the settings screen on `Object.entries
|
||||
// (module.errors)` and made every module's own patches silently no-op.
|
||||
// Assigned in the constructor body, not as class-field initializers - this repo's SWC build
|
||||
// config lowers class syntax without handling class-fields, so field initializers were
|
||||
// silently dropped from the compiled output.
|
||||
constructor({ id, label, meta, settings, handlers }: {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@ 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).
|
||||
// Injection technique ported from this repo's ViewRaw plugin. Generalized so multiple RoseUtils
|
||||
// modules can share one openLazy patch instead of each patching it separately.
|
||||
export interface MessageActionRow {
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
@ -64,9 +60,8 @@ function patchOpenLazy(): () => void {
|
|||
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.
|
||||
// Discord reuses the same lazy-loaded instance across opens - patch once, track the
|
||||
// active message on the instance itself.
|
||||
instance.__roseUtilsActiveMessage = msg.message;
|
||||
if (instance.__roseUtilsPatched) return;
|
||||
instance.__roseUtilsPatched = true;
|
||||
|
|
|
|||
|
|
@ -6,32 +6,14 @@ 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.
|
||||
// Developer Mode resets itself more than it should on Revenge - this re-asserts it every time the
|
||||
// user's settings proto changes rather than guessing why.
|
||||
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.
|
||||
// message.interaction/interactionMetadata/interaction_metadata - checked in that order to cover
|
||||
// whichever field a given build populates.
|
||||
function getCommandInteraction(message: any): { id: string; name?: string } | null {
|
||||
const interaction = message?.interaction ?? message?.interactionMetadata ?? message?.interaction_metadata;
|
||||
if (!interaction?.id) return null;
|
||||
|
|
@ -124,16 +106,14 @@ export default new Module({
|
|||
try {
|
||||
if (!DeveloperMode.getSetting()) DeveloperMode.updateSetting(true);
|
||||
} catch {
|
||||
// Best-effort - if this particular check throws, the next settings change retries it.
|
||||
// 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.
|
||||
// Fallback poll in case something resets it without that event firing.
|
||||
const interval = setInterval(enforce, 5000);
|
||||
this.patches.add(() => clearInterval(interval));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@ function copy(value: string, toastLabel: string) {
|
|||
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 [];
|
||||
|
|
|
|||
|
|
@ -9,15 +9,9 @@ import { shouldIgnore, type FilterOptions } from "./lib/filters";
|
|||
import { addLogEntry, clearLog, getLog } from "./lib/store";
|
||||
import openLogViewer from "./ui/LogViewerPage";
|
||||
|
||||
// Same core stores this plugin already relies on elsewhere (GuildStore/UserStore/etc. in
|
||||
// server-info-tools.ts) - MessageStore is equally fundamental, the client's own cache of every
|
||||
// message it currently has loaded. The whole capture mechanism depends on reading a message from
|
||||
// here *before* Discord's own MESSAGE_DELETE/MESSAGE_UPDATE handling removes/overwrites it - which
|
||||
// is exactly why this patches FluxDispatcher.dispatch itself (the same technique Key Inspector's own
|
||||
// flux event logger already proved out in this repo) instead of subscribing normally: a normal
|
||||
// FluxDispatcher.subscribe callback fires in registration order alongside every other listener,
|
||||
// including MessageStore's own, with no guarantee this one runs first - patching dispatch runs
|
||||
// before any listener at all, while the store's cache still holds the pre-delete/pre-edit message.
|
||||
// Patches FluxDispatcher.dispatch itself rather than subscribing normally, since a subscribe
|
||||
// callback has no guarantee of running before MessageStore's own - dispatch runs before any
|
||||
// listener, while the cache still holds the pre-delete/pre-edit message.
|
||||
const MessageStore = findByStoreName("MessageStore");
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import type { LoggedAttachment, LoggedEmbed, LoggedMessage, LoggedReaction } from "./types";
|
||||
|
||||
// Every field read below is part of Discord's own public message object schema, the same shape
|
||||
// message-dev-tools.ts and server-info-tools.ts already read elsewhere in this plugin - checked in
|
||||
// both camelCase and snake_case since MessageStore's cached objects don't consistently normalize
|
||||
// every field the same way across message sources (gateway vs. REST-backfilled history).
|
||||
// Checked in both camelCase and snake_case since MessageStore's cached objects don't consistently
|
||||
// normalize every field across message sources.
|
||||
function attachmentsOf(message: any): LoggedAttachment[] {
|
||||
const attachments = message?.attachments;
|
||||
if (!Array.isArray(attachments)) return [];
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { storage } from "@vendetta/plugin";
|
||||
import type { LoggedMessage } from "./types";
|
||||
|
||||
// Its own top-level storage key, deliberately separate from Module.ts's vstorage.modules[id].options
|
||||
// bag (which the generic settings UI treats as a flat map of small config values) - this can grow to
|
||||
// thousands of entries, which doesn't belong mixed into that.
|
||||
// Its own top-level storage key, separate from Module.ts's vstorage.modules[id].options bag - this
|
||||
// can grow to thousands of entries, which doesn't belong mixed into that.
|
||||
const logStorage = storage as { messageLoggerLog?: LoggedMessage[] };
|
||||
|
||||
export function getLog(): LoggedMessage[] {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import { resolveSemanticColorSafe } from "@shared/lib/color";
|
|||
import { getLog, removeLogEntry } from "../lib/store";
|
||||
import type { LoggedMessage } from "../lib/types";
|
||||
|
||||
// Same Navigation/Navigator/close-button lookup pattern already proven working in this repo's
|
||||
// ViewRaw plugin - reused verbatim rather than re-derived, including its fallback chain (findByName
|
||||
// misses Navigator on current builds, findByProps("Navigator") is the confirmed-working fallback).
|
||||
// Same Navigation/Navigator/close-button lookup pattern as this repo's ViewRaw plugin.
|
||||
const Navigation = findByProps("push", "pushLazy", "pop");
|
||||
const modalCloseButton =
|
||||
findByProps("getRenderCloseButton")?.getRenderCloseButton ??
|
||||
|
|
@ -20,9 +18,7 @@ const { View, Text, ScrollView, TouchableOpacity } = ReactNative;
|
|||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// Every raw Text below used to have no explicit color at all - illegible black-on-black on
|
||||
// Discord's dark theme, since RN's Text defaults to black with no theming of its own. Same
|
||||
// resolveSemanticColorSafe fallback-chain pattern used everywhere else in this repo.
|
||||
// RN's Text defaults to black with no theming - illegible on Discord's dark theme otherwise.
|
||||
function textColor(): string {
|
||||
return resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ export default new Module({
|
|||
},
|
||||
handlers: {
|
||||
onStart() {
|
||||
// getCommunicationDisabledUserMap doesn't exist anymore (confirmed against decompiled
|
||||
// current-build Discord source) - GuildMemberStore only exposes per-member lookups now,
|
||||
// via getMember(guildId, userId).communicationDisabledUntil.
|
||||
if (!this.storage.options.showTimeouts || !GuildMemberStore?.getMember) return;
|
||||
|
||||
this.patches.add(
|
||||
|
|
|
|||
|
|
@ -6,11 +6,6 @@ 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");
|
||||
|
|
|
|||
|
|
@ -8,12 +8,8 @@ import light from "./light.png";
|
|||
import { Module, ModuleCategory } from "../../lib/Module";
|
||||
import ClassicListenButton from "./components/ClassicListenButton";
|
||||
|
||||
// Confirmed against decompiled current-build Discord source (app/modules/now_playing/native/
|
||||
// UserActivitySpotify.tsx): SpotifyPlayButton is a plain function component now, not a class -
|
||||
// `after("render", SpotifyPlayButton.prototype, ...)` is why this threw "render is not a
|
||||
// function" on every activity render. Patching the exported property directly works for both
|
||||
// function and class components, and the function's own first argument already carries
|
||||
// { activity, style } directly - no need to dig through fiber internals for it anymore.
|
||||
// SpotifyPlayButton is a plain function component, not a class - patching the exported property
|
||||
// directly works for both, and its first argument already carries {activity, style}.
|
||||
const SpotifyModule = findByProps("SpotifyPlayButton");
|
||||
|
||||
export default new Module({
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ import { ModuleCategory, vstorage, type AnyModule, type ModuleSetting } from "..
|
|||
|
||||
const { View, Text, TouchableOpacity } = ReactNative;
|
||||
|
||||
// Raw Text below this point (the category header and bulk-action links) had no explicit color at
|
||||
// all - illegible black-on-black on Discord's dark theme, since RN's Text defaults to black with no
|
||||
// theming of its own. Same resolveSemanticColorSafe fallback-chain pattern used everywhere else.
|
||||
// RN's Text defaults to black with no theming - illegible on Discord's dark theme otherwise.
|
||||
const textColor = () => resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
|
||||
function resolveSubLabel(setting: ModuleSetting, value: any): string | undefined {
|
||||
|
|
@ -104,12 +102,8 @@ function ModuleSettingRow({ module, settingKey }: { module: AnyModule; settingKe
|
|||
|
||||
function ModuleSection({ module }: { module: AnyModule }) {
|
||||
module.useRefresh();
|
||||
// Belt and suspenders alongside the custom refresh() pub-sub above: module.storage.enabled is
|
||||
// read for the switch's `value` prop right below, and useProxy is the same storage-reactivity
|
||||
// mechanism every other plugin's settings screen in this repo already relies on for a toggle to
|
||||
// visually update immediately - this repo's own custom listeners system covers module.errors
|
||||
// too (plain in-memory state, not part of storage, so useProxy alone can't see it), which is
|
||||
// why that mechanism exists at all rather than being replaceable by useProxy outright.
|
||||
// useProxy covers storage reactivity for the toggle below; the custom refresh() above also
|
||||
// covers module.errors, which is plain in-memory state useProxy can't see.
|
||||
useProxy(module.storage);
|
||||
|
||||
const errorEntries = Object.entries(module.errors);
|
||||
|
|
@ -183,11 +177,7 @@ function CategoryHeader({
|
|||
const CATEGORY_ORDER: ModuleCategory[] = [ModuleCategory.Useful, ModuleCategory.Fixes, ModuleCategory.Fun];
|
||||
|
||||
export default function Settings() {
|
||||
// Every module's own toggle already re-renders its own ModuleSection via module.useRefresh()
|
||||
// inside ModuleSection - but the category headers here (enabled counts) live in this parent
|
||||
// component, which isn't otherwise subscribed to anything. Subscribing every module here too,
|
||||
// in this same fixed order every render, keeps those counts in sync with both individual
|
||||
// toggles and the bulk All/None actions below.
|
||||
// Subscribes so the category headers' enabled counts stay in sync with toggles/bulk actions.
|
||||
for (const module of modules) module.useRefresh();
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ function openDms() {
|
|||
}
|
||||
}
|
||||
|
||||
// Was previously only reachable via the native left server rail's own DM shortcut icon - moved
|
||||
// into the drawer's own grid instead, as the first tile, so DMs live in the same place as every
|
||||
// other server rather than in a spot that only exists on the rail this plugin otherwise hides.
|
||||
// Moved into the drawer's own grid as the first tile, since this plugin otherwise hides the
|
||||
// native rail DMs used to live on.
|
||||
export default function DmTile() {
|
||||
const selected = Flux?.useStateFromStores?.(
|
||||
[NavContext],
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@ function isNamed(name: string) {
|
|||
type?.type?.name === name || type?.type?.displayName === name;
|
||||
}
|
||||
|
||||
// Confirmed live (Key Inspector): find()'s own module-registry search can land on a different copy
|
||||
// of QuestDockContentExpanded than the one actually mounted in the live tree - the eager mutation
|
||||
// below silently patches the wrong reference while the real one keeps rendering Discord's own
|
||||
// (mostly empty) quest content. registerTypeDetector doesn't search the registry at all - it
|
||||
// inspects the exact `type` value passed to createElement/jsx the moment something actually
|
||||
// creates an element with it, so there's no "which copy" ambiguity to land on wrong.
|
||||
// find()'s module-registry search can land on a different copy of QuestDockContentExpanded than
|
||||
// the one actually mounted - registerTypeDetector instead inspects the exact `type` value passed
|
||||
// to createElement/jsx as it's used, so there's no "which copy" ambiguity.
|
||||
export function patchExpanded(
|
||||
cleanups: (() => void)[]
|
||||
): boolean {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@ import { after } from "@vendetta/patcher";
|
|||
interface Intercept {
|
||||
replacement: React.ComponentType<any>;
|
||||
extraProps?: Record<string, any>;
|
||||
/**
|
||||
* How many ancestor levels above this element are allowed to be collapsed to zero size along
|
||||
* with it (0 = none). See `COLLAPSE_STYLE` below for why replacing a component with one that
|
||||
* renders nothing is, on its own, not enough to reclaim its space.
|
||||
*/
|
||||
/** How many collapsed-to-zero-size ancestor levels above this element are also allowed to collapse (0 = none). */
|
||||
collapseAncestors?: number;
|
||||
}
|
||||
|
||||
|
|
@ -36,21 +32,12 @@ let typeDetectors: TypeDetector[] = [];
|
|||
const detectorKeys = new Set<string>();
|
||||
let isPatched = false;
|
||||
|
||||
/**
|
||||
* A React element created for something we're hiding, mapped to how many more ancestor levels may
|
||||
* still be collapsed with it. Elements are plain objects and are created child-first (a parent's
|
||||
* jsx() call can only run once its children exist), so a mark placed on a child is always already
|
||||
* present by the time the parent's own call is intercepted - which is what makes walking the
|
||||
* collapse *upward* possible at element-creation time at all.
|
||||
*/
|
||||
// Elements are created child-first, so a collapse mark on a child is already present by the time
|
||||
// the parent's own call is intercepted - that's what makes walking the collapse upward possible.
|
||||
const collapseMarks = new WeakMap<object, number>();
|
||||
|
||||
/**
|
||||
* Zeroes an element out in every direction a parent could have sized it. `display: "none"` alone
|
||||
* is enough on Yoga, but the explicit width/flex zeroes also cover a container that sets its
|
||||
* child's size through a style array further up, and `overflow: hidden` stops anything absolutely
|
||||
* positioned inside from still painting.
|
||||
*/
|
||||
// display: none alone doesn't reclaim space on every layout, so this also zeroes width/flex/margin
|
||||
// and hides overflow.
|
||||
const COLLAPSE_STYLE = {
|
||||
display: "none" as const,
|
||||
width: 0,
|
||||
|
|
@ -74,31 +61,20 @@ export function registerIntercept(
|
|||
intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors });
|
||||
}
|
||||
|
||||
// Same idea as registerIntercept, but matches on a predicate over the element's props instead of
|
||||
// an exact type reference - useful when the component has no reliable name to find it by, but
|
||||
// always passes some distinguishing prop. Pass replacement: null to render nothing instead.
|
||||
// Matches on a props predicate instead of an exact type reference, for components with no
|
||||
// reliable name. Pass replacement: null to render nothing.
|
||||
export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType<any> | null) {
|
||||
propsIntercepts.push({ predicate, replacement });
|
||||
}
|
||||
|
||||
// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept,
|
||||
// this doesn't swap in a different component, it just lets the real one render with different
|
||||
// input.
|
||||
// Rewrites props in place, keeping the same type.
|
||||
export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) {
|
||||
propsTransforms.push({ predicate, transform });
|
||||
}
|
||||
|
||||
// Purely observational: the first time an element is created whose type matches predicate, calls
|
||||
// onDetected(type) with the live reference. Doesn't touch rendering - pair it with
|
||||
// registerIntercept/registerPropsIntercept in the callback for that. Exists for components that
|
||||
// aren't findable by searching Metro's registry after the fact (races against first mount).
|
||||
//
|
||||
// key dedupes registrations - retrying registration every 200ms used to pile up duplicate
|
||||
// detectors, each re-tested against every element created anywhere in the app.
|
||||
//
|
||||
// persistent keeps the detector alive for every future match instead of firing once. Some type
|
||||
// references change across navigation (switching servers via this plugin's own drawer), so a
|
||||
// one-shot detector would only ever catch the first, now-abandoned reference.
|
||||
// Fires onDetected(type) the first time a matching element is created. For components with no
|
||||
// stable module export to search for after the fact. persistent keeps firing on every match
|
||||
// instead of once, for references that change across navigation.
|
||||
export function registerTypeDetector(
|
||||
key: string,
|
||||
predicate: (type: any) => boolean,
|
||||
|
|
@ -123,14 +99,14 @@ function runTypeDetectors(type: any) {
|
|||
try {
|
||||
matched = detector.predicate(type);
|
||||
} catch {
|
||||
// A bad predicate shouldn't block every other detector or every element from rendering.
|
||||
// Ignore
|
||||
}
|
||||
if (!matched) continue;
|
||||
|
||||
try {
|
||||
detector.onDetected(type);
|
||||
} catch {
|
||||
// Same - a detector's own handler throwing shouldn't take anything else down.
|
||||
// Ignore
|
||||
}
|
||||
if (!detector.persistent) {
|
||||
detector.justFired = true;
|
||||
|
|
@ -138,9 +114,6 @@ function runTypeDetectors(type: any) {
|
|||
}
|
||||
}
|
||||
|
||||
// Only rebuild the list when a one-shot detector actually fired. The previous version rebuilt
|
||||
// it on every single element creation app-wide, forever, because a persistent detector keeps
|
||||
// the list from ever being empty - an allocation per element on the hottest path in the app.
|
||||
if (consumed) {
|
||||
typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired);
|
||||
}
|
||||
|
|
@ -149,12 +122,7 @@ function runTypeDetectors(type: any) {
|
|||
declare module "./createElementIntercept" {}
|
||||
interface TypeDetector { justFired?: boolean }
|
||||
|
||||
/**
|
||||
* Returns the collapse depth a parent element should inherit from its children, or 0 for "leave
|
||||
* this element alone."
|
||||
*
|
||||
* Returns the *maximum* collapse budget among real children.
|
||||
*/
|
||||
/** Returns the collapse depth a parent should inherit from its children (max of any real child, 0 if none). */
|
||||
function inspectCollapseChild(child: any): number {
|
||||
if (child == null || child === false || typeof child !== "object") return 0;
|
||||
return collapseMarks.get(child) ?? 0;
|
||||
|
|
@ -188,7 +156,7 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
effectiveProps = transform(effectiveProps);
|
||||
}
|
||||
} catch {
|
||||
// A bad transform shouldn't block every other element from rendering.
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +168,7 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
return replacement ? { type: replacement, props: effectiveProps } : null;
|
||||
}
|
||||
} catch {
|
||||
// A bad predicate shouldn't block every other element from rendering.
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -214,9 +182,7 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
};
|
||||
}
|
||||
|
||||
// Nothing to swap here, but if this element's only child is one we've already collapsed, this
|
||||
// element is a wrapper that exists purely to hold it - zero it out too and pass the remaining
|
||||
// budget further up.
|
||||
// A wrapper whose only child was already collapsed - zero it out too and pass the remaining budget up.
|
||||
const inherited = inheritedCollapseDepth(effectiveProps, rest);
|
||||
if (inherited > 0) {
|
||||
return {
|
||||
|
|
@ -233,9 +199,8 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
return undefined;
|
||||
}
|
||||
|
||||
// Mutates the already-created element in place rather than substituting args before creation -
|
||||
// after() only sees the return value, not the call itself. A raw property reassignment on the
|
||||
// jsx-runtime object doesn't reliably reach real render calls; after() does.
|
||||
// after() only sees the return value, not the call itself, so we mutate the element in place
|
||||
// rather than substituting args beforehand.
|
||||
function applyResolved(res: any, type: any, props: any, rest: any[]) {
|
||||
if (!res || typeof res !== "object") return res;
|
||||
const resolved = resolveReplacement(type, props, rest);
|
||||
|
|
@ -265,11 +230,8 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
|
||||
}
|
||||
|
||||
// Discord's bundle compiles JSX through the automatic runtime (jsx/jsxs/jsxDEV) instead of
|
||||
// React.createElement, so patching createElement alone misses nearly everything. We scan for
|
||||
// the runtime by shape rather than name since names get mangled, patch every copy we find (a
|
||||
// bundle can have more than one), and keep scanning on an interval since Metro registers
|
||||
// modules lazily - a copy that hasn't been required yet at boot just isn't there to find.
|
||||
// Discord compiles JSX through jsx/jsxs/jsxDEV, not React.createElement - scan for those
|
||||
// runtimes by shape (names are mangled) and keep scanning since Metro registers lazily.
|
||||
const patchedJsxRuntimes = new WeakSet<any>();
|
||||
|
||||
function isJsxRuntime(m: any): boolean {
|
||||
|
|
@ -297,7 +259,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
const dflt = exports.default;
|
||||
if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt);
|
||||
} catch {
|
||||
// Ignore one module's bad shape and continue scanning.
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,13 +273,9 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
}
|
||||
}
|
||||
|
||||
// Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously
|
||||
// - an async scan would complete too late for the very first render of an early-mounting target.
|
||||
scanAndPatchJsxRuntimes();
|
||||
|
||||
// Fast while the app is still booting and chunks are being pulled in, then slow, then stop -
|
||||
// a full window.modules walk is not free and there's nothing left to catch once the UI has
|
||||
// settled.
|
||||
// Fast while the app boots, then slow, then stop.
|
||||
let ticks = 0;
|
||||
let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => {
|
||||
scanAndPatchJsxRuntimes();
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ import { registerPropsTransform, registerPropsIntercept } from "./createElementI
|
|||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
// Confirmed live: the quest dock's outer "island" wrapper - a ReanimatedView ten levels above the
|
||||
// drawer's own grid - carries an opaque card background (borderRadius: 24) via useAnimatedStyle.
|
||||
// It's not anything our own code renders, so there's no component reference to intercept - matching
|
||||
// the static part of its computed style is the only way to reach it. The color itself showed up as
|
||||
// two different string formats (rgba(...) vs hex) across two live reads of the same element, so
|
||||
// matching on the exact color string is unreliable - borderRadius plus "has some background color
|
||||
// at all" is the stable signal.
|
||||
// The quest dock's outer wrapper carries an opaque card background (borderRadius: 24) via
|
||||
// useAnimatedStyle - no component reference to intercept, so match on the computed style instead.
|
||||
// The color itself varies in format (rgba vs hex), so borderRadius + "has a background color" is
|
||||
// the stable signal, not the color string.
|
||||
function flatten(style: any): Record<string, any> {
|
||||
if (!style) return {};
|
||||
if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten));
|
||||
|
|
@ -20,11 +17,9 @@ function isQuestDockCard(props: any): boolean {
|
|||
return s.borderRadius === 24 && typeof s.backgroundColor === "string";
|
||||
}
|
||||
|
||||
// Removing the card's own color only revealed a second, separate layer underneath: the quest's own
|
||||
// promotional hero photo, rendered as a real <FastImageAndroid> pointed at a discordapp.com/quests/
|
||||
// URL - confirmed live (an actual promo image showed through once the color was gone). Matching by
|
||||
// URL shape, not by component, since the same image component renders ordinary guild/folder icons
|
||||
// everywhere else in the app too.
|
||||
// Removing the card color reveals a second layer underneath: the quest's promotional hero photo.
|
||||
// Matched by URL shape, not component, since the same image component renders ordinary guild/
|
||||
// folder icons everywhere else too.
|
||||
function getSourceUri(props: any): string | undefined {
|
||||
const source = props?.source;
|
||||
if (!source) return undefined;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@ import { findByProps } from "@vendetta/metro";
|
|||
let cached: any;
|
||||
let attempted = false;
|
||||
|
||||
/**
|
||||
* Discord ships a native gradient view (used for boost/nitro badges) but it isn't part of any
|
||||
* stable public API, so this is a best-effort lookup that quietly returns null if it can't be found -
|
||||
* gradient tags then fall back to a solid color instead of crashing anything.
|
||||
*/
|
||||
// Not a stable public API - best-effort lookup, falls back to a solid color if not found.
|
||||
export function getGradientComponent(): any {
|
||||
if (attempted) return cached;
|
||||
attempted = true;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,9 @@ import { findInReactTree } from "@vendetta/utils";
|
|||
import getTag from "../lib/getTag";
|
||||
import GradientTag from "../ui/GradientTag";
|
||||
|
||||
// Covers the name row shown in chat messages (next to the display name + timestamp) and channel headers.
|
||||
// findByName("DisplayName", false) stopped matching - confirmed live (Key Inspector's Eval console,
|
||||
// a full module-key scan) that the module still exports a property literally named DisplayName, but
|
||||
// findByName matches by the component's own runtime .name/.displayName, which doesn't survive
|
||||
// production minification the way the export's property key does. findByProps looks at property
|
||||
// keys instead, so the patch target changes from DisplayName.default to DisplayNameModule.DisplayName.
|
||||
// HeaderName's current name isn't confirmed yet - still guarded, just not fixed.
|
||||
// Covers the name row in chat messages and channel headers. findByName("DisplayName") stopped
|
||||
// matching under minification since it matches runtime .name, not the export's property key -
|
||||
// findByProps looks at the property key instead, so the target is DisplayNameModule.DisplayName.
|
||||
const DisplayNameModule = findByProps("DisplayName") as any;
|
||||
const HeaderName = findByName("HeaderName", false);
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
|
|
|
|||
|
|
@ -6,13 +6,9 @@ import GradientTag from "../ui/GradientTag";
|
|||
|
||||
const GuildStore = findByStoreName("GuildStore");
|
||||
|
||||
// The full profile doesn't render through UserRow at all - it's PrimaryInfo -> UserProfilePrimaryInfo,
|
||||
// and PrimaryInfo only shows up unrendered inside UserProfileContent's own output (no children to
|
||||
// search). UserProfilePrimaryInfo is the one that actually builds the name row, and it's not a
|
||||
// top-level export, so we grab its reference via createElementIntercept the first time it renders.
|
||||
// The row it builds is [DisplayName, [UserTagAndPronouns, GuildTag, ProfileBadgeRows]] - none of
|
||||
// those read custom text/color (UserTagAndPronouns is just the @handle, GuildTag is Discord's own
|
||||
// server-tag feature), so we push our GradientTag in as a sibling instead of trying to reuse one.
|
||||
// The full profile renders via PrimaryInfo -> UserProfilePrimaryInfo, which builds the name row
|
||||
// but isn't a top-level export - grabbed via createElementIntercept on first render instead, and
|
||||
// our GradientTag pushed in as a sibling of the row's existing children.
|
||||
export default function patchProfile(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,8 @@ import Icon from "../ui/Icon";
|
|||
const Tag = findByProps("getBotLabel");
|
||||
const { View, Text } = ReactNative;
|
||||
|
||||
/**
|
||||
* Discord's own Tag component ignores custom text/color props unless we push them into the
|
||||
* already-rendered tree ourselves - this is what actually makes our overrides visible. Now also
|
||||
* supports an `icon` prop (an IconDef from our icon library) and `iconColor` so tags can render
|
||||
* with or without text.
|
||||
*/
|
||||
// Discord's Tag component ignores custom text/color props unless pushed into the already-rendered
|
||||
// tree directly.
|
||||
export default () => {
|
||||
if (!Tag) return () => {};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import { getTypingAvatarURL } from "../lib/avatarUrl";
|
|||
const SIZE = 20;
|
||||
const OVERLAP = 8;
|
||||
|
||||
// No text label and no tap handler by design - purely decorative avatars replacing the
|
||||
// "X is typing..." text entirely. If a lot of people are typing, this wraps to a second
|
||||
// overlapping row rather than clipping or capping with a "+N" badge.
|
||||
// Purely decorative, replacing the "X is typing..." text entirely - wraps to a second row instead
|
||||
// of capping with a "+N" badge.
|
||||
export default function AvatarStack({ typingUserIds, guildId }: { typingUserIds: string[]; guildId: string | undefined }) {
|
||||
if (!typingUserIds?.length) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,9 @@ function avatarExtension(hash: string): string {
|
|||
return hash.startsWith("a_") ? "gif" : "png";
|
||||
}
|
||||
|
||||
// member.avatar / user.avatar confirmed live via devtools eval: UserStore.getCurrentUser().avatar
|
||||
// returned a real hash, and GuildMemberStore.getMember(guildId, userId).avatar exists as a field
|
||||
// and is correctly null for a user with no server-specific avatar set (the fallback-to-global
|
||||
// path is real and was exercised, not just theorized).
|
||||
//
|
||||
// The whole body is wrapped so a single bad input (e.g. a malformed userId reaching BigInt())
|
||||
// degrades to "this one avatar doesn't render" instead of throwing during the message list's
|
||||
// render pass - that render happens outside typingIndicator.tsx's own try/catch, which only
|
||||
// guards element creation, not AvatarStack's actual render.
|
||||
// Wrapped so a bad input (e.g. a malformed userId reaching BigInt()) degrades to "this one avatar
|
||||
// doesn't render" instead of throwing during the message list's render pass, which happens outside
|
||||
// typingIndicator.tsx's own try/catch.
|
||||
export function getTypingAvatarURL(guildId: string | undefined, userId: string, size = 32): string | null {
|
||||
try {
|
||||
if (!userId) return null;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@ import { findInReactTree } from "@vendetta/utils";
|
|||
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
import AvatarStack from "../components/AvatarStack";
|
||||
|
||||
// Mirrors role-color-everywhere/src/patches/typingWrapper.ts for the detection/wrapping part
|
||||
// (already proven live in this repo), but replaces the label's content with AvatarStack instead
|
||||
// of recoloring the existing per-user text nodes. This only needs props.typingUserIds directly,
|
||||
// so unlike the color-matching version it isn't affected by the "several people are typing..."
|
||||
// collapsed-string case.
|
||||
// Mirrors role-color-everywhere's typingWrapper.ts for detection/wrapping, but replaces the
|
||||
// label's content with AvatarStack instead of recoloring the existing text nodes.
|
||||
export default function patchTypingIndicator(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ const { View, ScrollView, TextInput, Text } = ReactNative;
|
|||
export default function RawPage({ message }: { message: any }) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const stringMessage = React.useMemo(() => JSON.stringify(cleanMessage(message), null, 4), [message.id]);
|
||||
// The search box previously hardcoded white text (illegible on a light theme) and the two
|
||||
// section labels below had no color at all (illegible black-on-black on a dark theme, RN's
|
||||
// Text default with no theming of its own).
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,18 +1,5 @@
|
|||
// Forked from bwlok/revenge-plugins (plugins/ViewRaw), GPLv3. Original authors: sapphire, Vendicated, Bwlok.
|
||||
// https://github.com/bwlok/revenge-plugins/tree/master/plugins/ViewRaw
|
||||
//
|
||||
// Changes in this fork:
|
||||
// - Fixed a crash on every message long-press ("Cannot read property 'type' of undefined") caused
|
||||
// by assuming Discord's ActionSheetRow icon shape without checking it first.
|
||||
// - RawPage rebuilt with a search box, syntax-highlighted JSON, and clearer copy actions.
|
||||
// - Action-sheet insertion rewritten using the technique from fshinz/Revenge-Plugins'
|
||||
// MoveForwardButton/FastCopyUserID: build the button from the real ActionSheetRow component
|
||||
// (via findByProps("ActionSheetRow"), with its own .Icon/.Group) instead of fabricating a fake
|
||||
// icon element by copying $$typeof/type off a sibling - and fall back to creating a brand new
|
||||
// group if no existing group can be found, rather than giving up.
|
||||
// - Guards against re-patching the same action sheet instance twice (Discord appears to reuse the
|
||||
// same lazy-loaded instance across opens), and against a missing Navigator lookup.
|
||||
// - Diagnostics (visible in Settings) record which strategy matched, or why none did.
|
||||
import { before, after } from "@vendetta/patcher";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
|
|
@ -83,9 +70,8 @@ const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, ms
|
|||
if (key !== "MessageLongPressActionSheet" || !msg?.message) return;
|
||||
|
||||
component.then((instance: any) => {
|
||||
// Discord appears to reuse the same lazy-loaded instance across every action sheet open, so
|
||||
// this only patches it once and tracks the active message on the instance itself - patching on
|
||||
// every open would stack duplicate buttons (and duplicate patches) after enough long-presses.
|
||||
// Discord reuses the same lazy-loaded instance across opens - patch once, track the active
|
||||
// message on the instance itself.
|
||||
instance.__viewRawActiveMessage = msg.message;
|
||||
if (instance.__viewRawPatched) return;
|
||||
instance.__viewRawPatched = true;
|
||||
|
|
@ -100,7 +86,7 @@ const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, ms
|
|||
|
||||
const navigator = buildNavigator(() => instance.__viewRawActiveMessage);
|
||||
|
||||
// Strategy 1: a plain list of buttons (older/simpler action sheet layouts).
|
||||
// Strategy 1: a plain list of buttons (older/simpler layouts).
|
||||
const buttons = findInReactTree(component, (x) => x?.[0]?.type?.name === "ButtonRow");
|
||||
if (buttons) {
|
||||
buttons.push(formRowButton(navigator));
|
||||
|
|
@ -108,9 +94,7 @@ const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, ms
|
|||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: real ActionSheetRow groups. Search every group for one that already has
|
||||
// ActionSheetRow-shaped children, insert there - and if none match, create a brand new
|
||||
// group at the top rather than giving up (this is the actual difference from before).
|
||||
// Strategy 2: real ActionSheetRow groups, falling back to a new group if none match.
|
||||
const groups = findInReactTree(
|
||||
component,
|
||||
(x) => Array.isArray(x) && x[0]?.type?.name === "ActionSheetRowGroup",
|
||||
|
|
@ -143,9 +127,7 @@ const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, ms
|
|||
}
|
||||
}
|
||||
|
||||
// Strategy 3: name-independent fallback. Look for any array where every element already
|
||||
// looks like an action sheet row (has both a label and an onPress) - a shape signature
|
||||
// rather than a name, so it survives renames that break strategies 1 and 2.
|
||||
// Strategy 3: name-independent fallback matching on shape (label + onPress) instead.
|
||||
const genericRowGroup = findInReactTree(
|
||||
component,
|
||||
(x) =>
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@ function stopRetrying() {
|
|||
}
|
||||
}
|
||||
|
||||
// YouBarNotificationsButton isn't registered in Metro until the YouBar itself has rendered at
|
||||
// least once, which hasn't necessarily happened yet by the time a plugin's onLoad runs - a single
|
||||
// attempt just misses it some of the time. Retries on an interval until it lands, then stops.
|
||||
// YouBarNotificationsButton isn't registered until YouBar itself has rendered at least once -
|
||||
// retries on an interval until it lands.
|
||||
function attempt() {
|
||||
try {
|
||||
const unpatch = patchYouBarButtons();
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { instead } from "@vendetta/patcher";
|
|||
import { storage } from "@vendetta/plugin";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
|
||||
// Keep the actual patch logic here dead simple - a previous attempt to fix the load-order race
|
||||
// by adding retry/re-render logic directly into this function made buttons stop appearing at all.
|
||||
// The retry now lives one level up in index.ts instead, calling this repeatedly until it succeeds.
|
||||
// Retry logic lives one level up in index.ts, calling this repeatedly until it succeeds - keep
|
||||
// this function itself simple.
|
||||
export let updateYouBar = () => {};
|
||||
|
||||
/** Returns null (not undefined) when YouBarNotificationsButton isn't registered in Metro yet, so a caller can tell "not ready" apart from "patched, here's the unpatch fn". */
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|||
|
||||
const ThemeStore = findByStoreName("ThemeStore");
|
||||
|
||||
// @vendetta/ui exposes the semanticColors token map but not a resolver function for it - every
|
||||
// plugin that needs one has to find it the same way, so it lives here instead of being
|
||||
// reimplemented per plugin.
|
||||
// @vendetta/ui exposes the semanticColors token map but not a resolver function for it.
|
||||
const rawResolveSemanticColor: (theme: any, semanticColor: any) => string | undefined =
|
||||
find((m: any) => m?.default?.internal?.resolveSemanticColor)?.default?.internal?.resolveSemanticColor ??
|
||||
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
|
||||
|
|
@ -23,11 +21,8 @@ export function resolveSemanticColor(token: any, theme: any = ThemeStore?.theme)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as resolveSemanticColor, but tries a list of token names in order and falls back to a
|
||||
* hardcoded hex color if every one of them is missing or fails to resolve - Discord renames these
|
||||
* tokens across versions often enough that a single hardcoded name isn't reliable on its own.
|
||||
*/
|
||||
// Tries a list of token names in order, falling back to a hardcoded hex color - Discord renames
|
||||
// these tokens across versions often enough that a single hardcoded name isn't reliable.
|
||||
export function resolveSemanticColorSafe(tokenNames: string[], fallbackHex: string, theme: any = ThemeStore?.theme): string {
|
||||
for (const name of tokenNames) {
|
||||
const token = (semanticColors as Record<string, any> | undefined)?.[name];
|
||||
|
|
|
|||
|
|
@ -39,30 +39,20 @@ export function registerIntercept(
|
|||
intercepts.set(original, { replacement, extraProps });
|
||||
}
|
||||
|
||||
// Same idea as registerIntercept, but matches on a predicate over the element's props instead of
|
||||
// an exact type reference - useful when the component has no reliable name to find it by, but
|
||||
// always passes some distinguishing prop. Pass replacement: null to render nothing instead.
|
||||
// Matches on a props predicate instead of an exact type reference, for components with no
|
||||
// reliable name. Pass replacement: null to render nothing.
|
||||
export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType<any> | null) {
|
||||
propsIntercepts.push({ predicate, replacement });
|
||||
}
|
||||
|
||||
// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept,
|
||||
// this doesn't swap in a different component, it just lets the real one render with different
|
||||
// input. Useful where patching a hook's exported property wouldn't work (same-chunk closures
|
||||
// often bypass the export entirely); every element creation goes through this one shared path
|
||||
// regardless of which chunk it came from, so there's no equivalent staleness problem here.
|
||||
// Rewrites props in place, keeping the same type.
|
||||
export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) {
|
||||
propsTransforms.push({ predicate, transform });
|
||||
}
|
||||
|
||||
// Purely observational: the first time an element is created whose type matches predicate, calls
|
||||
// onDetected(type) with the live reference. Doesn't touch rendering itself - pair it with
|
||||
// registerIntercept/registerPropsIntercept in the callback for that. This is how you find a
|
||||
// component that isn't a top-level module export and can't be searched for after the fact.
|
||||
//
|
||||
// key dedupes registrations (matters if the call site can run more than once, e.g. a retry loop).
|
||||
// persistent keeps the detector alive for every future match instead of firing once and dropping
|
||||
// - needed for components whose reference isn't stable for the whole session.
|
||||
// Fires onDetected(type) the first time a matching element is created. For components with no
|
||||
// stable module export to search for after the fact. persistent keeps firing on every match
|
||||
// instead of once, for references that change across navigation.
|
||||
export function registerTypeDetector(
|
||||
key: string,
|
||||
predicate: (type: any) => boolean,
|
||||
|
|
@ -87,14 +77,14 @@ function runTypeDetectors(type: any) {
|
|||
try {
|
||||
matched = detector.predicate(type);
|
||||
} catch {
|
||||
// A bad predicate shouldn't block every other detector or every element from rendering.
|
||||
// Ignore
|
||||
}
|
||||
if (!matched) continue;
|
||||
|
||||
try {
|
||||
detector.onDetected(type);
|
||||
} catch {
|
||||
// Same - a detector's own handler throwing shouldn't take anything else down.
|
||||
// Ignore
|
||||
}
|
||||
if (!detector.persistent) {
|
||||
detector.justFired = true;
|
||||
|
|
@ -102,9 +92,6 @@ function runTypeDetectors(type: any) {
|
|||
}
|
||||
}
|
||||
|
||||
// Only rebuild the list when a one-shot detector actually fired - rebuilding on every single
|
||||
// element creation app-wide would be an allocation per element on the hottest path in the app
|
||||
// whenever any persistent detector is registered (the list is then never empty).
|
||||
if (consumed) {
|
||||
typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired);
|
||||
}
|
||||
|
|
@ -122,7 +109,7 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
effectiveProps = transform(effectiveProps);
|
||||
}
|
||||
} catch {
|
||||
// A bad transform shouldn't block every other element from rendering.
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +121,7 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
return replacement ? { type: replacement, props: effectiveProps } : null;
|
||||
}
|
||||
} catch {
|
||||
// A bad predicate shouldn't block every other element from rendering.
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,12 +138,8 @@ function resolveReplacement(type: any, props: any, rest: any[]): { type: any; pr
|
|||
return undefined;
|
||||
}
|
||||
|
||||
// Mutates the already-created element in place rather than substituting args before creation.
|
||||
// This has to go through @vendetta/patcher's after() instead of a raw property reassignment on
|
||||
// the jsx-runtime object - a plain reassignment (what this file and server-drawer's copy used to
|
||||
// do) just doesn't reach real render calls here, after() does. Since after() only sees the return
|
||||
// value, not the original call, we mutate res.type/res.props after the fact instead of swapping
|
||||
// the type beforehand - same result, React just reads whatever's on the object it gets handed.
|
||||
// after() only sees the return value, not the call itself, so we mutate the element in place
|
||||
// rather than substituting args beforehand.
|
||||
function applyResolved(res: any, type: any, props: any, rest: any[]) {
|
||||
if (!res || typeof res !== "object") return res;
|
||||
const resolved = resolveReplacement(type, props, rest);
|
||||
|
|
@ -183,11 +166,8 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
|
||||
}
|
||||
|
||||
// Discord's bundle compiles JSX through the automatic runtime (jsx/jsxs/jsxDEV) instead of
|
||||
// React.createElement, so patching createElement alone misses nearly everything. We scan for
|
||||
// the runtime by shape rather than name since names get mangled, patch every copy we find (a
|
||||
// bundle can have more than one), and keep scanning on an interval since Metro registers
|
||||
// modules lazily - a copy that hasn't been required yet at boot just isn't there to find.
|
||||
// Discord compiles JSX through jsx/jsxs/jsxDEV, not React.createElement - scan for those
|
||||
// runtimes by shape (names are mangled) and keep scanning since Metro registers lazily.
|
||||
const patchedJsxRuntimes = new WeakSet<any>();
|
||||
|
||||
function isJsxRuntime(m: any): boolean {
|
||||
|
|
@ -215,7 +195,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
const dflt = exports.default;
|
||||
if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt);
|
||||
} catch {
|
||||
// Ignore one module's bad shape and continue scanning.
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,12 +209,9 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
}
|
||||
}
|
||||
|
||||
// Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously
|
||||
// - an async scan would complete too late for the very first render of an early-mounting target.
|
||||
scanAndPatchJsxRuntimes();
|
||||
|
||||
// Fast while the app is still booting and chunks are being pulled in, then slow, then stop - a
|
||||
// full window.modules walk is not free and there's nothing left to catch once the UI has settled.
|
||||
// Fast while the app boots, then slow, then stop.
|
||||
let ticks = 0;
|
||||
let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => {
|
||||
scanAndPatchJsxRuntimes();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
/**
|
||||
* Wraps a metro lookup so it isn't run until first actually used, and retries on every call until
|
||||
* it succeeds once (after which the result is cached forever). A plugin's top-level module code
|
||||
* runs as soon as its bundle is required, which can be before Discord's own code has required an
|
||||
* internal module it depends on - a one-shot findByX() at module scope can permanently cache
|
||||
* undefined even though the real module becomes available moments later. Only meant for values
|
||||
* used inside event handlers or plain (non-hook) render logic - wrapping something used as a
|
||||
* React hook (e.g. passed to useContext) in this can violate the Rules of Hooks, since the
|
||||
* resolved-or-not status is now allowed to change between renders of the same component instance.
|
||||
*/
|
||||
// Wraps a metro lookup so it isn't run until first used, retrying on every call until it succeeds
|
||||
// once (then cached forever). Only for values used in event handlers or plain render logic -
|
||||
// wrapping something used as a React hook can violate the Rules of Hooks, since resolved-or-not
|
||||
// can change between renders.
|
||||
export function lazy<T>(resolve: () => T | null | undefined): () => T | undefined {
|
||||
let cached: T | undefined;
|
||||
let resolved = false;
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ import { after, before } from "@vendetta/patcher";
|
|||
|
||||
export type RowHandler = (rows: any[]) => void;
|
||||
|
||||
/**
|
||||
* Chat rows are rendered natively - message data crosses the JS/native bridge as JSON via
|
||||
* DCDChatManager.updateRows on newer builds, or via RowManager.generate (one row per call) on
|
||||
* older ones. This runs `handler` against the row array either way, so callers don't have to
|
||||
* reimplement the bridge-vs-RowManager fallback themselves.
|
||||
*/
|
||||
// Chat rows are rendered natively - message data crosses the JS/native bridge as JSON via
|
||||
// DCDChatManager.updateRows on newer builds, or RowManager.generate on older ones. Runs `handler`
|
||||
// against the row array either way.
|
||||
export function patchRows(handler: RowHandler): () => void {
|
||||
const { NativeModules } = ReactNative;
|
||||
const DCDChatManager = NativeModules?.DCDChatManager;
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@ export interface PluginLogger {
|
|||
error(message: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a patch-application function so a single broken lookup only disables that one surface
|
||||
* instead of crashing the whole plugin's onLoad and taking every other patch down with it -
|
||||
* this is the root-cause fix for why Staff Tags and RoleColorEverywhere used to break completely.
|
||||
*/
|
||||
// Wraps a patch-application function so a single broken lookup only disables that one surface,
|
||||
// instead of crashing the whole plugin's onLoad and taking every other patch down with it.
|
||||
export function safePatch(pluginName: string, patchName: string, apply: () => () => void, logger: PluginLogger): () => void {
|
||||
try {
|
||||
return apply();
|
||||
|
|
|
|||
|
|
@ -1,21 +1,6 @@
|
|||
// Revenge's own metro finders (findByProps/findByName/findByTypeName/findByStoreName, and even
|
||||
// the bare `find`) cache their result per search - including a NEGATIVE result. Once a search
|
||||
// comes up empty, it's marked "not found" and every later call with the same search never
|
||||
// rescans window.modules again, even after the real module registers (confirmed by reading
|
||||
// Revenge's own source: metro/internals/modules.ts's getModules() returns early on a cached
|
||||
// NOT_FOUND flag). That makes retry loops pointless for any search whose first attempt happens
|
||||
// to run before the target is registered - every retry after that first miss hits the same
|
||||
// poisoned cache instead of actually looking again.
|
||||
//
|
||||
// This walks window.modules directly to bypass that cache - but critically, it never forces a
|
||||
// module to initialize (never calls window.__r on something that hasn't run yet). An earlier
|
||||
// version of this file did force-require everything on every scan, which is genuinely dangerous:
|
||||
// Metro only ever runs a module's factory once and caches whatever it produced forever, so
|
||||
// force-evaluating a module before whatever it depends on on is actually ready can permanently
|
||||
// wedge it into a broken state for the rest of the session - no amount of retrying fixes that,
|
||||
// since the factory never runs again. This only inspects modules Metro's own isInitialized flag
|
||||
// says have ALREADY been required by something else, which is entirely passive and safe to poll
|
||||
// repeatedly - it just waits for Discord's own code to naturally reach the module we want.
|
||||
// Revenge's own finders cache a negative result forever, so retry loops never see a module that
|
||||
// registers after the first miss. This walks window.modules directly instead, and only inspects
|
||||
// modules already marked isInitialized - never force-requires one, which can wedge Metro.
|
||||
declare const window: any;
|
||||
|
||||
export function rawFind<T = any>(predicate: (exports: any) => boolean): T | undefined {
|
||||
|
|
|
|||
|
|
@ -3,19 +3,10 @@ export interface WaitForHandle {
|
|||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls `find` every `intervalMs` (default 100) until it returns a truthy value, then calls
|
||||
* `onFound` with it and stops polling.
|
||||
*
|
||||
* Needed because a plugin's onLoad can run before every Metro module/component it depends on has
|
||||
* actually been required - on a cold app start that's a real race, not an edge case. A one-shot
|
||||
* `findByX(...)` lookup at that exact moment can come back undefined and, without a retry, never
|
||||
* try again for the rest of the session. `waitFor` checks immediately (so there's no added delay
|
||||
* once the target is already registered) and keeps retrying otherwise.
|
||||
*
|
||||
* Returns a handle whose `cancel()` stops the poll - call it from a plugin's unpatch/onUnload so a
|
||||
* pending lookup doesn't keep firing (or apply a patch) after the plugin's been turned off.
|
||||
*/
|
||||
// Polls `find` every `intervalMs` until it returns a truthy value, then calls `onFound` and stops.
|
||||
// Checks immediately first, so there's no added delay if the target is already registered. Call
|
||||
// the returned handle's cancel() from onUnload so a pending lookup can't fire after the plugin's
|
||||
// turned off.
|
||||
export function waitFor<T>(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
|
|
|||
|
|
@ -16,16 +16,12 @@ interface ColorInputProps {
|
|||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hex color field with a live swatch preview and tap-to-pick presets. Built entirely from RN
|
||||
* primitives (no Discord-internal component reuse), so it doesn't carry the same "might silently
|
||||
* stop rendering after a Discord update" risk as the plugins' UI patches do.
|
||||
*/
|
||||
// Hex color field with a live swatch preview and tap-to-pick presets. Built entirely from RN
|
||||
// primitives, so it doesn't carry the same "might silently stop rendering after a Discord update"
|
||||
// risk as the plugins' UI patches do.
|
||||
export default function ColorInput({ title, value, placeholder, onChange }: ColorInputProps) {
|
||||
const valid = !value || isValidHex(value);
|
||||
const swatchColor = isValidHex(value) ? value : (isValidHex(placeholder) ? placeholder : "#5865F2");
|
||||
// Same missing-color bug as NoteBox - Text with no explicit color defaults to black, illegible
|
||||
// on Discord's dark theme.
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ export interface ListItem {
|
|||
onPress?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled section listing tap-able rows, with a friendly note shown in place of an empty list.
|
||||
* Always pairs onPress with a visible trailing indicator - rows without one have been unreliable
|
||||
* about actually registering taps in testing.
|
||||
*/
|
||||
// A titled section listing tap-able rows, with a friendly note shown in place of an empty list.
|
||||
// Always pairs onPress with a visible trailing indicator - rows without one have been unreliable
|
||||
// about registering taps.
|
||||
export default function ListSection({ title, items, emptyText }: { title: string; items: ListItem[]; emptyText: string }) {
|
||||
return (
|
||||
<TableRowGroup title={title}>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@ import { resolveSemanticColorSafe } from "../lib/color";
|
|||
|
||||
const { View, Text } = ReactNative;
|
||||
|
||||
/**
|
||||
* Small bordered callout for explanatory text in a settings screen. Text color used to be
|
||||
* unset entirely - React Native's Text defaults to black with no explicit color, which is
|
||||
* illegible on Discord's dark theme (the default for most users). Explicit theme-aware color now,
|
||||
* same resolveSemanticColorSafe fallback-chain pattern used everywhere else in this repo.
|
||||
*/
|
||||
/** Small bordered callout for explanatory text in a settings screen. */
|
||||
export default function NoteBox({ children, style }: { children: any; style?: any }) {
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT", "TEXT_MUTED"], "#96989d");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { Forms } from "@vendetta/ui/components";
|
||||
|
||||
/**
|
||||
* Discord's redesigned "Table" row family (TableRow/TableRowGroup/TableSwitchRow/...) replaced
|
||||
* the old Forms.Form* components in the app itself a while ago - Forms.* still works (Vendetta's
|
||||
* compat layer maps it to the legacy components, see metro/common/components.ts in the loader),
|
||||
* but it's the deprecated skin. These are found the same way ViewRaw finds ActionSheetRow: a
|
||||
* direct findByProps lookup for the real component, since the vendetta.ui.components compat
|
||||
* object never exposed the Table family at all. Falls back to the matching legacy Form
|
||||
* component if a lookup ever comes back empty, same "isolate the failure" approach as the rest
|
||||
* of this repo - the fallback's prop names don't perfectly match (e.g. `icon` vs `leading`), so a
|
||||
* few things would render without an icon/arrow in that fallback path, but nothing crashes.
|
||||
*/
|
||||
// Discord's redesigned "Table" row family replaced Forms.Form* in the app itself, but the
|
||||
// vendetta.ui.components compat object never exposed it - found via direct findByProps instead,
|
||||
// falling back to the legacy Form component (whose prop names don't perfectly match) if a lookup
|
||||
// ever comes back empty.
|
||||
const find = (prop: string): any => findByProps(prop)?.[prop];
|
||||
|
||||
const RealTableRow: any = find("TableRow");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue