ServerDrawer: restore retry-loop infrastructure with everything learned tonight
This commit is contained in:
parent
2a2d0c942a
commit
88d3210fae
12 changed files with 723 additions and 215 deletions
|
|
@ -1,17 +1,17 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native";
|
import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native";
|
||||||
import { semanticColors } from "@vendetta/ui";
|
import { semanticColors } from "@vendetta/ui";
|
||||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
import { resolveSemanticColor } from "../lib/color";
|
||||||
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFindByValidProps } from "../lib/rawFind";
|
||||||
|
import { getHaptic } from "../lib/commonModules";
|
||||||
|
|
||||||
const TextStyleSheet = findByProps("TextStyleSheet")?.TextStyleSheet;
|
const getTextStyleSheet = lazy(() => rawFindByValidProps<any>({
|
||||||
const ThemeStore = findByStoreName("ThemeStore");
|
TextStyleSheet: (v) => v?.["text-md/bold"] !== undefined,
|
||||||
const colorModule = findByProps("colors", "unsafe_rawColors");
|
})?.TextStyleSheet);
|
||||||
const colorResolver = colorModule?.internal ?? colorModule?.meta;
|
|
||||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
|
||||||
|
|
||||||
function resolve(color: any): string {
|
function resolve(token: any, fallback: string): string {
|
||||||
const theme = ThemeStore?.theme;
|
return resolveSemanticColor(token) ?? fallback;
|
||||||
return (color && colorResolver?.resolveSemanticColor(theme, color)) || "#000000";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDGE = 12;
|
const EDGE = 12;
|
||||||
|
|
@ -47,6 +47,9 @@ export function ContextMenuModal({
|
||||||
anchorY: number;
|
anchorY: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const TextStyleSheet = getTextStyleSheet();
|
||||||
|
const Haptic = getHaptic();
|
||||||
|
|
||||||
const { width: winW, height: winH } = Dimensions.get("window");
|
const { width: winW, height: winH } = Dimensions.get("window");
|
||||||
|
|
||||||
const titleH = title ? PAD + 20 + DIVIDER_H : 0;
|
const titleH = title ? PAD + 20 + DIVIDER_H : 0;
|
||||||
|
|
@ -59,10 +62,10 @@ export function ContextMenuModal({
|
||||||
if (top + menuH > winH - EDGE) top = anchorY - menuH - 10;
|
if (top + menuH > winH - EDGE) top = anchorY - menuH - 10;
|
||||||
if (top < EDGE) top = EDGE;
|
if (top < EDGE) top = EDGE;
|
||||||
|
|
||||||
const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST);
|
const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST, "#1e1f22");
|
||||||
const borderColor = resolve(semanticColors?.BORDER_SUBTLE);
|
const borderColor = resolve(semanticColors?.BORDER_SUBTLE, "#3f4147");
|
||||||
const textColor = resolve(semanticColors?.TEXT_STRONG);
|
const textColor = resolve(semanticColors?.TEXT_STRONG, "#f2f3f5");
|
||||||
const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE);
|
const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE, "rgba(255,255,255,0.06)");
|
||||||
|
|
||||||
const backdropOpacity = React.useRef(new Animated.Value(0)).current;
|
const backdropOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
const menuScale = React.useRef(new Animated.Value(0.5)).current;
|
const menuScale = React.useRef(new Animated.Value(0.5)).current;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,33 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Pressable, Image, StyleSheet } from "react-native";
|
import { View, Pressable, Image, StyleSheet } from "react-native";
|
||||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
import { findByProps, findByName } from "@vendetta/metro";
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFindByFunctionProps, rawFindByName } from "../lib/rawFind";
|
||||||
|
import { getFlux, getHaptic, getColorModule } from "../lib/commonModules";
|
||||||
|
|
||||||
const ICON = 48;
|
const ICON = 48;
|
||||||
|
|
||||||
const ChatIcon = getAssetIDByName("ChatIcon");
|
const ChatIcon = getAssetIDByName("ChatIcon");
|
||||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
|
||||||
const ChannelActions = findByProps("selectPrivateChannel");
|
const getChannelActions = lazy(() => rawFindByFunctionProps<any>("selectPrivateChannel"));
|
||||||
const SelectedChannelStore = findByName("SelectedChannelStore");
|
const getSelectedChannelStore = lazy(() => rawFindByName<any>("SelectedChannelStore"));
|
||||||
const Flux = findByProps("useStateFromStores");
|
const getNavContext = lazy(() => rawFindByFunctionProps<any>("getGuildId"));
|
||||||
const NavContext = findByProps("getGuildId");
|
|
||||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
|
||||||
|
|
||||||
function openDms() {
|
function openDms() {
|
||||||
|
const Haptic = getHaptic();
|
||||||
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
|
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
|
||||||
|
const ChannelActions = getChannelActions();
|
||||||
if (ChannelActions?.selectPrivateChannel) {
|
if (ChannelActions?.selectPrivateChannel) {
|
||||||
const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.();
|
const lastChannelId = getSelectedChannelStore()?.getLastSelectedChannelId?.();
|
||||||
ChannelActions.selectPrivateChannel(lastChannelId);
|
ChannelActions.selectPrivateChannel(lastChannelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DmTile() {
|
export default function DmTile() {
|
||||||
|
const Flux = getFlux();
|
||||||
|
const NavContext = getNavContext();
|
||||||
|
const colors = getColorModule()?.colors;
|
||||||
|
|
||||||
const selected = Flux?.useStateFromStores?.(
|
const selected = Flux?.useStateFromStores?.(
|
||||||
[NavContext],
|
[NavContext],
|
||||||
() => NavContext?.getGuildId?.() == null,
|
() => NavContext?.getGuildId?.() == null,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Animated, Pressable, Image, ViewStyle, StyleSheet } from "react-native";
|
import { View, Text, Animated, Pressable, Image, ViewStyle, StyleSheet } from "react-native";
|
||||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
|
||||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { useFolderExpanded, GuildNode } from "../utils/theme";
|
import { useFolderExpanded, GuildNode } from "../utils/theme";
|
||||||
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind";
|
||||||
|
import { getFlux } from "../lib/commonModules";
|
||||||
import GuildIcon from "./GuildIcon";
|
import GuildIcon from "./GuildIcon";
|
||||||
import GuildItem from "./GuildItem";
|
import GuildItem from "./GuildItem";
|
||||||
|
|
||||||
const GuildActions = findByProps("toggleGuildFolderExpand");
|
const getGuildActions = lazy(() => rawFindByFunctionProps<any>("toggleGuildFolderExpand"));
|
||||||
const Flux = findByProps("useStateFromStores");
|
const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore"));
|
||||||
const GuildReadStateStore = findByStoreName("GuildReadStateStore");
|
|
||||||
|
|
||||||
const ICON = 48;
|
const ICON = 48;
|
||||||
const MINI = 16;
|
const MINI = 16;
|
||||||
|
|
@ -30,9 +31,13 @@ function folderColor(color?: number | null): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderBadge({ node }: { node: GuildNode }) {
|
function FolderBadge({ node }: { node: GuildNode }) {
|
||||||
// Same guard as useFolderExpanded in utils/theme.ts - GuildReadStateStore is a one-shot lookup
|
const Flux = getFlux();
|
||||||
// with no retry, and passing an undefined store into useStateFromStores throws instead of
|
const GuildReadStateStore = getGuildReadStateStore();
|
||||||
// failing safely, which would take the whole drawer down, not just this badge.
|
|
||||||
|
// Guards against an undefined store, not just a falsy return value - confirmed live that
|
||||||
|
// passing [undefined] into useStateFromStores throws rather than failing safely, which used to
|
||||||
|
// take the whole drawer down (this only ever runs when there's an actual folder to render, so
|
||||||
|
// an account with folders showed nothing at all while one without rendered fine).
|
||||||
const total = (Flux?.useStateFromStores && GuildReadStateStore) ? Flux.useStateFromStores(
|
const total = (Flux?.useStateFromStores && GuildReadStateStore) ? Flux.useStateFromStores(
|
||||||
[GuildReadStateStore],
|
[GuildReadStateStore],
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -100,7 +105,7 @@ export default function FolderItem({ node, onPick }: { node: GuildNode; onPick:
|
||||||
const open = useFolderExpanded(node.id);
|
const open = useFolderExpanded(node.id);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
GuildActions?.toggleGuildFolderExpand?.(node.id);
|
getGuildActions()?.toggleGuildFolderExpand?.(node.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Image, StyleSheet } from "react-native";
|
import { View, Text, Image, StyleSheet } from "react-native";
|
||||||
import { findByStoreName, findByProps } from "@vendetta/metro";
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFindByStoreName } from "../lib/rawFind";
|
||||||
|
import { getFlux, getColorModule } from "../lib/commonModules";
|
||||||
|
|
||||||
const GuildStore = findByStoreName("GuildStore");
|
const getGuildStore = lazy(() => rawFindByStoreName("GuildStore"));
|
||||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
|
||||||
|
|
||||||
export default function GuildIcon({ id, size = 48 }: { id: string; size?: number }) {
|
export default function GuildIcon({ id, size = 48 }: { id: string; size?: number }) {
|
||||||
const g = GuildStore?.getGuild(id);
|
const Flux = getFlux();
|
||||||
|
const GuildStore = getGuildStore();
|
||||||
|
const colors = getColorModule()?.colors;
|
||||||
|
// Guild data streams in progressively after connect, so a one-shot GuildStore.getGuild(id)
|
||||||
|
// taken at mount can miss it entirely and never recover - subscribing means the icon fills in
|
||||||
|
// as soon as its guild finishes loading instead of staying blank for the rest of the session.
|
||||||
|
const g = Flux?.useStateFromStores?.([GuildStore], () => GuildStore?.getGuild?.(id), [id]) ?? null;
|
||||||
if (!g) return null;
|
if (!g) return null;
|
||||||
const rad = size >= 40 ? 16 : 8;
|
const rad = size >= 40 ? 16 : 8;
|
||||||
if (g.icon) {
|
if (g.icon) {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,34 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
||||||
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { GuildNode } from "../utils/theme";
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFind, rawFindByStoreName } from "../lib/rawFind";
|
||||||
|
import { getFlux, getHaptic } from "../lib/commonModules";
|
||||||
|
import { GuildNode, useTheme } from "../utils/theme";
|
||||||
import GuildIcon from "./GuildIcon";
|
import GuildIcon from "./GuildIcon";
|
||||||
import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal";
|
import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal";
|
||||||
|
|
||||||
const ICON = 48;
|
const ICON = 48;
|
||||||
|
|
||||||
const Flux = findByProps("useStateFromStores");
|
const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore"));
|
||||||
const GuildReadStateStore = findByStoreName("GuildReadStateStore");
|
const getGuildStore = lazy(() => rawFindByStoreName("GuildStore"));
|
||||||
const GuildStore = findByStoreName("GuildStore");
|
const getSortedGuildStore = lazy(() => rawFindByStoreName("SortedGuildStore"));
|
||||||
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
|
||||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
// Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with
|
||||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
// no named export - matches the original plugin's own heuristic (a short function whose name
|
||||||
|
// mentions "guild"). Rendered through ContextMenuModal since there's no accessible way to mount
|
||||||
|
// Discord's own native menu component standalone outside its usual host, but every item/action in
|
||||||
|
// it is Discord's real menu, not a custom-built one.
|
||||||
|
const getMenuItemsFn = lazy(() =>
|
||||||
|
rawFind((m) => m?.default?.name === "getGuildsBarGuildMenuItems")?.default
|
||||||
|
?? rawFind((m) => typeof m?.default === "function" && m.default.length <= 2 && /guild/i.test(m.default.name ?? ""))?.default
|
||||||
|
);
|
||||||
|
|
||||||
function Badge({ guildId }: { guildId: string }) {
|
function Badge({ guildId }: { guildId: string }) {
|
||||||
|
const Flux = getFlux();
|
||||||
|
const GuildReadStateStore = getGuildReadStateStore();
|
||||||
|
|
||||||
const mentionCount = Flux?.useStateFromStores?.(
|
const mentionCount = Flux?.useStateFromStores?.(
|
||||||
[GuildReadStateStore],
|
[GuildReadStateStore],
|
||||||
() => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0,
|
() => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0,
|
||||||
|
|
@ -52,6 +64,10 @@ function Badge({ guildId }: { guildId: string }) {
|
||||||
|
|
||||||
function GuildLabel({ guildId }: { guildId: string }) {
|
function GuildLabel({ guildId }: { guildId: string }) {
|
||||||
useProxy(storage);
|
useProxy(storage);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const Flux = getFlux();
|
||||||
|
const GuildStore = getGuildStore();
|
||||||
|
|
||||||
const name = Flux?.useStateFromStores?.(
|
const name = Flux?.useStateFromStores?.(
|
||||||
[GuildStore],
|
[GuildStore],
|
||||||
|
|
@ -62,7 +78,7 @@ function GuildLabel({ guildId }: { guildId: string }) {
|
||||||
if (storage.showGuildNames === false || !name) return null;
|
if (storage.showGuildNames === false || !name) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={2} style={[lb.label, { color: colors?.TEXT_NORMAL ?? "#dbdee1" }]}>
|
<Text numberOfLines={2} style={[lb.label, { color: theme.text }]}>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
@ -84,15 +100,11 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
||||||
|
|
||||||
const showMenu = React.useCallback(() => {
|
const showMenu = React.useCallback(() => {
|
||||||
const guildId = node.id as string;
|
const guildId = node.id as string;
|
||||||
const guild = GuildStore?.getGuild?.(guildId);
|
const guild = getGuildStore()?.getGuild?.(guildId);
|
||||||
const menuItemsFn = find((m) => m?.default?.name === "getGuildsBarGuildMenuItems")?.default
|
const menuItemsFn = getMenuItemsFn();
|
||||||
?? find((m) => typeof m?.default === "function" && m.default.length <= 2 && /guild/i.test(m.default.name ?? ""))?.default;
|
if (!guild || !menuItemsFn) return;
|
||||||
|
|
||||||
if (!guild || !menuItemsFn) {
|
const treeVersion = getSortedGuildStore()?.getGuildsTree?.()?.version;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const treeVersion = SortedGuildStore?.getGuildsTree?.()?.version;
|
|
||||||
const rawItems = menuItemsFn(guildId, treeVersion);
|
const rawItems = menuItemsFn(guildId, treeVersion);
|
||||||
if (!rawItems?.length) return;
|
if (!rawItems?.length) return;
|
||||||
|
|
||||||
|
|
@ -117,6 +129,7 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
||||||
}, [node.id]);
|
}, [node.id]);
|
||||||
|
|
||||||
const handleLongPress = React.useCallback(() => {
|
const handleLongPress = React.useCallback(() => {
|
||||||
|
const Haptic = getHaptic();
|
||||||
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_MEDIUM);
|
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_MEDIUM);
|
||||||
showMenu();
|
showMenu();
|
||||||
}, [showMenu]);
|
}, [showMenu]);
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,23 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native";
|
import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native";
|
||||||
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { useProxy } from "@vendetta/storage";
|
import { useProxy } from "@vendetta/storage";
|
||||||
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFind, rawFindByFunctionProps } from "../lib/rawFind";
|
||||||
|
import { getFlux, getHaptic, getColorModule } from "../lib/commonModules";
|
||||||
import { GuildNode } from "../utils/theme";
|
import { GuildNode } from "../utils/theme";
|
||||||
import GuildItem from "./GuildItem";
|
import GuildItem from "./GuildItem";
|
||||||
import FolderItem from "./FolderItem";
|
import FolderItem from "./FolderItem";
|
||||||
import DmTile from "./DmTile";
|
import DmTile from "./DmTile";
|
||||||
|
|
||||||
const Flux = findByProps("useStateFromStores");
|
const getRootNav = lazy(() => rawFindByFunctionProps("getRootNavigationRef"));
|
||||||
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
const getRouting = lazy(() => rawFindByFunctionProps("transitionToGuild"));
|
||||||
const RootNav = findByProps("getRootNavigationRef");
|
const getCreateJoinGuildMod = lazy(() => rawFind((m: any) => typeof m?.handleCreateJoinGuildPress === "function"));
|
||||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
const getCirclePlusIcon = lazy(() => rawFind((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon);
|
||||||
const Routing = findByProps("transitionToGuild");
|
|
||||||
|
|
||||||
const CreateJoinGuildMod = find((m: any) => typeof m?.handleCreateJoinGuildPress === "function");
|
const getExternalContext = lazy(() => rawFind((m: any) => m?.QuestDockExternalCoordinationContext)?.QuestDockExternalCoordinationContext);
|
||||||
const CirclePlusIcon = find((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon;
|
const getQuestDockMode = lazy(() => rawFind((m: any) => m?.QuestDockMode?.COLLAPSED != null)?.QuestDockMode);
|
||||||
const rawColors = findByProps("colors", "unsafe_rawColors")?.unsafe_rawColors;
|
const getSortedGuildStore = lazy(() => rawFind((m: any) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === "SortedGuildStore"));
|
||||||
|
|
||||||
const createJoinBg = rawColors?.GREEN_360;
|
|
||||||
const createJoinIconColor = rawColors?.WHITE;
|
|
||||||
|
|
||||||
const ExternalCoordinationMod = find((m: any) => m?.QuestDockExternalCoordinationContext);
|
|
||||||
const ExternalContext = ExternalCoordinationMod?.QuestDockExternalCoordinationContext;
|
|
||||||
const QuestDockMode = find((m: any) => m?.QuestDockMode?.COLLAPSED != null)?.QuestDockMode;
|
|
||||||
|
|
||||||
const ICON = 48;
|
const ICON = 48;
|
||||||
const GAP = 6;
|
const GAP = 6;
|
||||||
|
|
@ -32,11 +26,10 @@ const PAD = 12;
|
||||||
// Confirmed live: an account with folders showed no drawer at all - not even the DM tile, other
|
// Confirmed live: an account with folders showed no drawer at all - not even the DM tile, other
|
||||||
// servers, or the create/join button, which have nothing to do with folders. That points at an
|
// servers, or the create/join button, which have nothing to do with folders. That points at an
|
||||||
// uncaught throw somewhere in FolderItem's render taking the whole grid down with it, not just the
|
// uncaught throw somewhere in FolderItem's render taking the whole grid down with it, not just the
|
||||||
// one folder - React unmounts the nearest parent when a child throws during render unless
|
// one folder. A try/catch around JSX can't catch this - the exception happens during React's own
|
||||||
// something catches it, and nothing here did. A class component is the only way to actually catch
|
// render pass, not synchronously in this function's body - only a class component's
|
||||||
// a render-time throw (a try/catch around JSX doesn't do anything - the exception happens during
|
// getDerivedStateFromError can. Wrapping each folder individually means one broken folder
|
||||||
// React's own render pass, not synchronously in this function's body). Wrapping each folder
|
// disappears instead of every other server in the drawer.
|
||||||
// individually means one broken folder disappears instead of every other server in the drawer.
|
|
||||||
class FolderErrorBoundary extends React.Component<{ children: React.ReactNode }, { errored: boolean }> {
|
class FolderErrorBoundary extends React.Component<{ children: React.ReactNode }, { errored: boolean }> {
|
||||||
state = { errored: false };
|
state = { errored: false };
|
||||||
static getDerivedStateFromError() {
|
static getDerivedStateFromError() {
|
||||||
|
|
@ -58,17 +51,21 @@ function CreateJoinButton() {
|
||||||
}, [scale]);
|
}, [scale]);
|
||||||
|
|
||||||
const onPress = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
|
const haptic = getHaptic();
|
||||||
CreateJoinGuildMod?.handleCreateJoinGuildPress?.();
|
haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT);
|
||||||
|
getCreateJoinGuildMod()?.handleCreateJoinGuildPress?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const CirclePlusIcon = getCirclePlusIcon();
|
||||||
|
const rawColors = getColorModule()?.unsafe_rawColors;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} onPressIn={scaleDown} onPressOut={scaleUp}>
|
<Pressable onPress={onPress} onPressIn={scaleDown} onPressOut={scaleUp}>
|
||||||
<Animated.View style={[st.createJoin, { backgroundColor: createJoinBg, transform: [{ scale }] }]}>
|
<Animated.View style={[st.createJoin, { backgroundColor: rawColors?.GREEN_360, transform: [{ scale }] }]}>
|
||||||
{CirclePlusIcon ? (
|
{CirclePlusIcon ? (
|
||||||
<CirclePlusIcon size="md" color={createJoinIconColor} />
|
<CirclePlusIcon size="md" color={rawColors?.WHITE} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[st.createJoinFallback, { color: createJoinIconColor }]}>{"+"}</Text>
|
<Text style={[st.createJoinFallback, { color: rawColors?.WHITE }]}>{"+"}</Text>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -79,14 +76,20 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext:
|
||||||
useProxy(storage);
|
useProxy(storage);
|
||||||
|
|
||||||
const pick = React.useCallback((id: string) => {
|
const pick = React.useCallback((id: string) => {
|
||||||
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
|
const haptic = getHaptic();
|
||||||
if (Routing?.transitionToGuild) {
|
haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT);
|
||||||
Routing.transitionToGuild(id, null);
|
|
||||||
|
const routing = getRouting();
|
||||||
|
if (routing?.transitionToGuild) {
|
||||||
|
routing.transitionToGuild(id);
|
||||||
} else {
|
} else {
|
||||||
RootNav?.getRootNavigationRef()?.navigate("guilds", { guildId: id });
|
getRootNav()?.getRootNavigationRef?.()?.navigate("guilds", { guildId: id });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const Flux = getFlux();
|
||||||
|
const SortedGuildStore = getSortedGuildStore();
|
||||||
|
|
||||||
const nodes: GuildNode[] = Flux?.useStateFromStores?.(
|
const nodes: GuildNode[] = Flux?.useStateFromStores?.(
|
||||||
[SortedGuildStore],
|
[SortedGuildStore],
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -104,12 +107,14 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext:
|
||||||
if (minH.get() !== h) minH.set(h);
|
if (minH.get() !== h) minH.set(h);
|
||||||
}, [minH]);
|
}, [minH]);
|
||||||
|
|
||||||
|
const ExternalContext = getExternalContext();
|
||||||
const extCtx = ExternalContext ? React.useContext(ExternalContext) as any : null;
|
const extCtx = ExternalContext ? React.useContext(ExternalContext) as any : null;
|
||||||
const setMode = extCtx?.setRestingQuestDockMode;
|
const setMode = extCtx?.setRestingQuestDockMode;
|
||||||
|
|
||||||
const specs = ctx?.questDockWrapperSpecs;
|
const specs = ctx?.questDockWrapperSpecs;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
const QuestDockMode = getQuestDockMode();
|
||||||
if (!setMode || !QuestDockMode || !specs) return;
|
if (!setMode || !QuestDockMode || !specs) return;
|
||||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||||
const h = specs.get()?.height ?? 56;
|
const h = specs.get()?.height ?? 56;
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,95 @@
|
||||||
|
import { logger } from "@vendetta";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { patchQuestDockRender, patchQuestDockBase, patchMobileQuestDock, patchGetQuestAsset } from "./patches/fakeQuestDock";
|
import { patchQuestDockRender, patchQuestDockBase, patchMobileQuestDock, patchGetQuestAsset } from "./patches/fakeQuestDock";
|
||||||
import { patchQuestDockSlot, patchEmpty } from "./patches/contentPatch";
|
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
||||||
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
||||||
import { patchCreateElement } from "./lib/createElementIntercept";
|
import { patchCreateElement } from "./lib/createElementIntercept";
|
||||||
import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders";
|
import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders";
|
||||||
import Settings from "./ui/Settings";
|
import Settings from "./ui/Settings";
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
|
||||||
let cleanups: (() => void)[] = [];
|
let cleanups: (() => void)[] = [];
|
||||||
|
let retryHandle: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
// Each of these functions already checks its own lookup and returns false instead of patching
|
||||||
|
// when it comes up empty - this wrapper adds one more layer so a lookup that throws outright
|
||||||
|
// can't take the rest of onLoad's patches down with it.
|
||||||
|
function tryPatch(name: string, fn: () => boolean): boolean {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[ServerDrawer] "${name}" patch threw, skipping: ${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Quest Dock (and the internals it depends on) isn't guaranteed to be registered in Metro yet
|
||||||
|
// at the exact moment onLoad runs. Confirmed live across many reload cycles tonight: without a
|
||||||
|
// retry loop, each of this plugin's independent module lookups is effectively an independent
|
||||||
|
// coin-flip on any given boot - not tied to any one account or feature, just whichever modules
|
||||||
|
// happened to already be registered when onLoad ran. Retrying whatever hasn't landed yet on a fast
|
||||||
|
// interval until everything's applied (or it gives up after ~10s) is what makes this reliable
|
||||||
|
// instead of "sometimes works."
|
||||||
function applyAll() {
|
function applyAll() {
|
||||||
console.log(TAG, "onLoad");
|
const patchers: Record<string, () => boolean> = {
|
||||||
|
...(storage.fakeQuestDock !== false ? {
|
||||||
let patched = 0;
|
questDockRender: () => patchQuestDockRender(cleanups),
|
||||||
|
questDockBase: () => patchQuestDockBase(cleanups),
|
||||||
|
mobileQuestDock: () => patchMobileQuestDock(cleanups),
|
||||||
|
getQuestAsset: () => patchGetQuestAsset(cleanups),
|
||||||
|
} : {}),
|
||||||
|
expanded: () => patchExpanded(cleanups),
|
||||||
|
collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups),
|
||||||
|
enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups),
|
||||||
|
unenrolledHeader: () => patchEmpty("QuestDockUnenrolledHeader", cleanups),
|
||||||
|
enrolledBody: () => patchEmpty("QuestDockEnrolledBody", cleanups),
|
||||||
|
unenrolledBody: () => patchEmpty("QuestDockUnenrolledBody", cleanups),
|
||||||
|
autoCollapseFolders: () => patchAutoCollapseFolders(cleanups),
|
||||||
|
...(storage.hideGuildsBar !== false ? { hideGuildsBar: () => patchHideGuildsBar(cleanups) } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
patchCreateElement(cleanups);
|
patchCreateElement(cleanups);
|
||||||
patched++;
|
|
||||||
|
|
||||||
if (storage.fakeQuestDock !== false) {
|
const pending = new Set(Object.keys(patchers));
|
||||||
if (patchQuestDockRender(cleanups)) patched++;
|
const applied = new Set<string>();
|
||||||
if (patchQuestDockBase(cleanups)) patched++;
|
|
||||||
if (patchMobileQuestDock(cleanups)) patched++;
|
const attempt = () => {
|
||||||
if (patchGetQuestAsset(cleanups)) patched++;
|
for (const name of pending) {
|
||||||
|
if (tryPatch(name, patchers[name])) {
|
||||||
|
applied.add(name);
|
||||||
|
pending.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.size === 0 && retryHandle) {
|
||||||
|
clearInterval(retryHandle);
|
||||||
|
retryHandle = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attempt();
|
||||||
|
|
||||||
|
if (pending.size > 0) {
|
||||||
|
let ticks = 0;
|
||||||
|
retryHandle = setInterval(() => {
|
||||||
|
attempt();
|
||||||
|
if (++ticks >= 50 && retryHandle) { // ~10s at 200ms
|
||||||
|
clearInterval(retryHandle);
|
||||||
|
retryHandle = undefined;
|
||||||
|
if (pending.size > 0) {
|
||||||
|
logger.error(`[ServerDrawer] Gave up waiting on: ${[...pending].join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
}
|
}
|
||||||
if (patchQuestDockSlot("QuestDockContentExpanded", cleanups)) patched++;
|
|
||||||
if (patchEmpty("QuestDockContentCollapsed", cleanups)) patched++;
|
|
||||||
if (patchEmpty("QuestDockEnrolledHeader", cleanups)) patched++;
|
|
||||||
if (patchEmpty("QuestDockUnenrolledHeader", cleanups)) patched++;
|
|
||||||
if (patchEmpty("QuestDockEnrolledBody", cleanups)) patched++;
|
|
||||||
if (patchEmpty("QuestDockUnenrolledBody", cleanups)) patched++;
|
|
||||||
if (patchAutoCollapseFolders(cleanups)) patched++;
|
|
||||||
if (storage.hideGuildsBar !== false && patchHideGuildsBar(cleanups)) patched++;
|
|
||||||
|
|
||||||
console.log(TAG, `onLoad done - ${patched} patches applied, ${cleanups.length} cleanups`);
|
logger.log(`[ServerDrawer] onLoad: applied immediately - ${[...applied].join(", ") || "none"}${pending.size ? `, still waiting on ${[...pending].join(", ")}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unpatchAll() {
|
function unpatchAll() {
|
||||||
|
if (retryHandle) {
|
||||||
|
clearInterval(retryHandle);
|
||||||
|
retryHandle = undefined;
|
||||||
|
}
|
||||||
for (const fn of cleanups) {
|
for (const fn of cleanups) {
|
||||||
try {
|
try {
|
||||||
fn();
|
fn();
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,346 @@
|
||||||
import { React } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import { after } from "@vendetta/patcher";
|
||||||
|
|
||||||
interface Intercept {
|
interface Intercept {
|
||||||
replacement: React.ComponentType<any>;
|
replacement: React.ComponentType<any>;
|
||||||
extraProps?: Record<string, 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.
|
||||||
|
*/
|
||||||
|
collapseAncestors?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsIntercept {
|
||||||
|
predicate: (props: any) => boolean;
|
||||||
|
replacement: React.ComponentType<any> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsTransform {
|
||||||
|
predicate: (props: any) => boolean;
|
||||||
|
transform: (props: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeDetector {
|
||||||
|
key: string;
|
||||||
|
predicate: (type: any) => boolean;
|
||||||
|
onDetected: (type: any) => void;
|
||||||
|
persistent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||||
let origCreateElement: typeof React.createElement | null = null;
|
const propsIntercepts: PropsIntercept[] = [];
|
||||||
|
const propsTransforms: PropsTransform[] = [];
|
||||||
|
let typeDetectors: TypeDetector[] = [];
|
||||||
|
const detectorKeys = new Set<string>();
|
||||||
let isPatched = false;
|
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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
const COLLAPSE_STYLE = {
|
||||||
|
display: "none" as const,
|
||||||
|
width: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 0,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
overflow: "hidden" as const,
|
||||||
|
};
|
||||||
|
|
||||||
export function registerIntercept(
|
export function registerIntercept(
|
||||||
original: React.ComponentType<any>,
|
original: React.ComponentType<any>,
|
||||||
replacement: React.ComponentType<any>,
|
replacement: React.ComponentType<any>,
|
||||||
extraProps?: Record<string, any>,
|
extraProps?: Record<string, any>,
|
||||||
|
options?: { collapseAncestors?: number },
|
||||||
) {
|
) {
|
||||||
intercepts.set(original, { replacement, extraProps });
|
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.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
export function registerTypeDetector(
|
||||||
|
key: string,
|
||||||
|
predicate: (type: any) => boolean,
|
||||||
|
onDetected: (type: any) => void,
|
||||||
|
options?: { persistent?: boolean },
|
||||||
|
) {
|
||||||
|
if (detectorKeys.has(key)) return;
|
||||||
|
detectorKeys.add(key);
|
||||||
|
typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTypeDetector(key: string): boolean {
|
||||||
|
return detectorKeys.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTypeDetectors(type: any) {
|
||||||
|
if (typeDetectors.length === 0) return;
|
||||||
|
|
||||||
|
let consumed = false;
|
||||||
|
for (const detector of typeDetectors) {
|
||||||
|
let matched = false;
|
||||||
|
try {
|
||||||
|
matched = detector.predicate(type);
|
||||||
|
} catch {
|
||||||
|
// A bad predicate shouldn't block every other detector or every element from rendering.
|
||||||
|
}
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
detector.onDetected(type);
|
||||||
|
} catch {
|
||||||
|
// Same - a detector's own handler throwing shouldn't take anything else down.
|
||||||
|
}
|
||||||
|
if (!detector.persistent) {
|
||||||
|
detector.justFired = true;
|
||||||
|
consumed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
function inspectCollapseChild(child: any): number {
|
||||||
|
if (child == null || child === false || typeof child !== "object") return 0;
|
||||||
|
return collapseMarks.get(child) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inheritedCollapseDepth(props: any, rest: any[] = []): number {
|
||||||
|
let deepest = 0;
|
||||||
|
|
||||||
|
const children = props?.children;
|
||||||
|
if (children != null) {
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
for (const child of children) deepest = Math.max(deepest, inspectCollapseChild(child));
|
||||||
|
} else {
|
||||||
|
deepest = Math.max(deepest, inspectCollapseChild(children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of rest) deepest = Math.max(deepest, inspectCollapseChild(child));
|
||||||
|
return deepest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */
|
||||||
|
function resolveReplacement(type: any, props: any, rest: any[]): { type: any; props: any; collapse?: number } | null | undefined {
|
||||||
|
runTypeDetectors(type);
|
||||||
|
|
||||||
|
let effectiveProps = props;
|
||||||
|
if (effectiveProps) {
|
||||||
|
for (const { predicate, transform } of propsTransforms) {
|
||||||
|
try {
|
||||||
|
if (predicate(effectiveProps, type, rest)) {
|
||||||
|
effectiveProps = transform(effectiveProps);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A bad transform shouldn't block every other element from rendering.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveProps) {
|
||||||
|
for (const { predicate, replacement } of propsIntercepts) {
|
||||||
|
try {
|
||||||
|
if (predicate(effectiveProps, type, rest)) {
|
||||||
|
return replacement ? { type: replacement, props: effectiveProps } : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A bad predicate shouldn't block every other element from rendering.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = intercepts.get(type);
|
||||||
|
if (entry) {
|
||||||
|
return {
|
||||||
|
type: entry.replacement,
|
||||||
|
props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps,
|
||||||
|
collapse: entry.collapseAncestors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const inherited = inheritedCollapseDepth(effectiveProps, rest);
|
||||||
|
if (inherited > 0) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
props: { ...effectiveProps, style: [effectiveProps?.style, COLLAPSE_STYLE] },
|
||||||
|
collapse: inherited - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveProps !== props) {
|
||||||
|
return { type, props: effectiveProps };
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
function applyResolved(res: any, type: any, props: any, rest: any[]) {
|
||||||
|
if (!res || typeof res !== "object") return res;
|
||||||
|
const resolved = resolveReplacement(type, props, rest);
|
||||||
|
if (resolved === null) {
|
||||||
|
res.type = () => null;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (resolved) {
|
||||||
|
res.type = resolved.type;
|
||||||
|
res.props = resolved.props ?? null;
|
||||||
|
if (resolved.collapse && resolved.collapse > 0) {
|
||||||
|
collapseMarks.set(res, resolved.collapse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchCreateElement(cleanups: (() => void)[]) {
|
export function patchCreateElement(cleanups: (() => void)[]) {
|
||||||
if (isPatched) return;
|
if (isPatched) return;
|
||||||
|
|
||||||
origCreateElement = React.createElement;
|
|
||||||
if (!origCreateElement) {
|
|
||||||
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patched = function (type: any, props: any, ...rest: any[]) {
|
|
||||||
if (!origCreateElement) {
|
|
||||||
return React.createElement(type, props, ...rest);
|
|
||||||
}
|
|
||||||
const entry = intercepts.get(type);
|
|
||||||
if (entry) {
|
|
||||||
const newProps = entry.extraProps
|
|
||||||
? { ...props, ...entry.extraProps }
|
|
||||||
: props;
|
|
||||||
return origCreateElement.call(React, entry.replacement, newProps, ...rest);
|
|
||||||
}
|
|
||||||
return origCreateElement.call(React, type, props, ...rest);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(patched, origCreateElement);
|
|
||||||
React.createElement = patched as typeof React.createElement;
|
|
||||||
isPatched = true;
|
isPatched = true;
|
||||||
|
|
||||||
cleanups.push(() => {
|
if (React.createElement) {
|
||||||
if (isPatched && origCreateElement) {
|
cleanups.push(
|
||||||
React.createElement = origCreateElement;
|
after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
|
||||||
isPatched = false;
|
);
|
||||||
|
} else {
|
||||||
|
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.
|
||||||
|
const patchedJsxRuntimes = new WeakSet<any>();
|
||||||
|
|
||||||
|
function isJsxRuntime(m: any): boolean {
|
||||||
|
return typeof m?.jsx === "function" || typeof m?.jsxs === "function" || typeof m?.jsxDEV === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchJsxObject(runtime: any) {
|
||||||
|
if (patchedJsxRuntimes.has(runtime)) return;
|
||||||
|
patchedJsxRuntimes.add(runtime);
|
||||||
|
for (const key of ["jsx", "jsxs", "jsxDEV"] as const) {
|
||||||
|
if (typeof runtime[key] !== "function") continue;
|
||||||
|
cleanups.push(
|
||||||
|
after(key, runtime, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchJsxModule(def: any) {
|
||||||
|
if (!def?.publicModule?.exports) return;
|
||||||
|
const exports = def.publicModule.exports;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isJsxRuntime(exports)) patchJsxObject(exports);
|
||||||
|
|
||||||
|
const dflt = exports.default;
|
||||||
|
if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt);
|
||||||
|
} catch {
|
||||||
|
// Ignore one module's bad shape and continue scanning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanAndPatchJsxRuntimes() {
|
||||||
|
const modules = (window as any)?.modules;
|
||||||
|
if (!modules) return;
|
||||||
|
for (const id in modules) {
|
||||||
|
const def = modules[id];
|
||||||
|
if (!def?.isInitialized) continue;
|
||||||
|
patchJsxModule(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let ticks = 0;
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => {
|
||||||
|
scanAndPatchJsxRuntimes();
|
||||||
|
if (++ticks === 50 && timer) { // ~5s at 100ms
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = setInterval(() => {
|
||||||
|
scanAndPatchJsxRuntimes();
|
||||||
|
if (++ticks >= 75 && timer) { // + ~25s at 1s
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
isPatched = false;
|
||||||
intercepts.clear();
|
intercepts.clear();
|
||||||
|
propsIntercepts.length = 0;
|
||||||
|
propsTransforms.length = 0;
|
||||||
|
typeDetectors = [];
|
||||||
|
detectorKeys.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import { React } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
import { rawFind } from "../lib/rawFind";
|
import { rawFind } from "../lib/rawFind";
|
||||||
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
||||||
import { registerIntercept } from "../lib/createElementIntercept";
|
import { registerIntercept, registerPropsTransform, registerTypeDetector } from "../lib/createElementIntercept";
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
const TAG = "[ServerDrawer]";
|
||||||
|
|
||||||
|
// rawFind, not @vendetta/metro's find - Revenge's own find() permanently caches a "not found"
|
||||||
|
// result per search and never rescans, which makes retrying pointless after the first failed
|
||||||
|
// attempt. Confirmed live tonight across many reload cycles: without retry, roughly a third of the
|
||||||
|
// independent lookups this plugin needs fail on any given boot, at random - not specific to any
|
||||||
|
// one account or feature.
|
||||||
let cachedGestureContext: any = null;
|
let cachedGestureContext: any = null;
|
||||||
function getGestureContext(): any {
|
function getGestureContext(): any {
|
||||||
if (!cachedGestureContext) {
|
if (!cachedGestureContext) {
|
||||||
|
|
@ -13,51 +18,119 @@ function getGestureContext(): any {
|
||||||
return cachedGestureContext;
|
return cachedGestureContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memo-wrapped components have no own .name/.displayName - only their nested .type does.
|
||||||
function isNamed(name: string) {
|
function isNamed(name: string) {
|
||||||
return (type: any) => type?.name === name || type?.displayName === name ||
|
return (type: any) => type?.name === name || type?.displayName === name ||
|
||||||
type?.type?.name === name || type?.type?.displayName === name;
|
type?.type?.name === name || type?.type?.displayName === name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchQuestDockSlot(name: string, cleanups: (() => void)[]): boolean {
|
const QUEST_DOCK_NAMES = [
|
||||||
const mod = rawFind((m) => isNamed(name)(m?.type));
|
"QuestDock",
|
||||||
|
"QuestDockBase",
|
||||||
|
"QuestDockWithQuestContext",
|
||||||
|
"QuestDockMain",
|
||||||
|
"QuestDockContent",
|
||||||
|
"QuestDockExpanded",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isQuestDock(type: any): boolean {
|
||||||
|
return QUEST_DOCK_NAMES.some((name) => isNamed(name)(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SD_DRAWER_TEST_ID = "ServerDrawer";
|
||||||
|
|
||||||
|
function hasChildWithTestID(children: any, rest: any[], testID: string): boolean {
|
||||||
|
const inspect = (child: any) => child != null && typeof child === "object" && child.props?.testID === testID;
|
||||||
|
if (children != null) {
|
||||||
|
if (Array.isArray(children)) { if (children.some(inspect)) return true; }
|
||||||
|
else if (inspect(children)) return true;
|
||||||
|
}
|
||||||
|
for (const child of rest) if (inspect(child)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerDrawerSheetWrapper() {
|
||||||
|
return <ServerDrawerSheet gestureContext={getGestureContext()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two independent mechanisms, both active. Mutating the found module's own `.type` property makes
|
||||||
|
* the drawer render immediately if the target module happens to already be registered in Metro by
|
||||||
|
* the time this runs, but that's a real boot-time race.
|
||||||
|
*
|
||||||
|
* registerTypeDetector has no such timing dependency - it inspects the `type` argument passed
|
||||||
|
* directly to createElement/jsx at the moment something actually creates an element with it,
|
||||||
|
* whenever that happens, early or late. Combining both means the drawer shows as fast as possible
|
||||||
|
* when the race is won, and still shows correctly even when it's lost.
|
||||||
|
*
|
||||||
|
* Only QuestDockContentExpanded is swapped for the drawer - QuestDockContentCollapsed is patched to
|
||||||
|
* render nothing (see patchEmpty below), same as every other sub-section. Confirmed live that
|
||||||
|
* patching both to show the full drawer causes it to render twice: Discord creates both elements
|
||||||
|
* simultaneously in some cases (very likely a collapse/expand transition state), not just
|
||||||
|
* whichever one restingQuestDockMode currently selects.
|
||||||
|
*/
|
||||||
|
export function patchExpanded(cleanups: (() => void)[]): boolean {
|
||||||
|
const questDockTypes = new Set<any>();
|
||||||
|
|
||||||
|
// Make the quest dock's wrapper(s) transparent - the ServerDrawerSheet content still renders on
|
||||||
|
// top, but the panel/drawer background that used to be behind the quest menu is removed.
|
||||||
|
registerPropsTransform(
|
||||||
|
(props: any, type: any, rest: any[]) =>
|
||||||
|
questDockTypes.has(type) ||
|
||||||
|
hasChildWithTestID(props?.children, rest, SD_DRAWER_TEST_ID),
|
||||||
|
(props: any) => ({ ...props, style: [props?.style, { backgroundColor: "transparent" }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
registerTypeDetector("ServerDrawer.QuestDockBg", isQuestDock, (real) => {
|
||||||
|
questDockTypes.add(real);
|
||||||
|
console.log(TAG, "PATCH: QuestDock wrapper set transparent", real?.name ?? real?.displayName);
|
||||||
|
}, { persistent: true });
|
||||||
|
|
||||||
|
registerTypeDetector("ServerDrawer.Expanded", isNamed("QuestDockContentExpanded"), (real) => {
|
||||||
|
registerIntercept(real, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID });
|
||||||
|
console.log(TAG, "PATCH: QuestDockContentExpanded replaced (type detector)");
|
||||||
|
}, { persistent: true });
|
||||||
|
|
||||||
|
const mod = rawFind((m) => isNamed("QuestDockContentExpanded")(m?.type));
|
||||||
if (!mod?.type) {
|
if (!mod?.type) {
|
||||||
console.log(TAG, `WARN: ${name} not found (will retry)`);
|
console.log(TAG, "WARN: QuestDockContentExpanded module not found yet (will retry)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orig = mod.type;
|
const orig = mod.type;
|
||||||
|
registerIntercept(orig, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID });
|
||||||
registerIntercept(orig, ServerDrawerSheet, { gestureContext: getGestureContext() });
|
mod.type = ServerDrawerSheetWrapper;
|
||||||
|
|
||||||
mod.type = function ServerDrawerPatch() {
|
|
||||||
return <ServerDrawerSheet gestureContext={getGestureContext()} />;
|
|
||||||
};
|
|
||||||
cleanups.push(() => { mod.type = orig; });
|
cleanups.push(() => { mod.type = orig; });
|
||||||
console.log(TAG, `PATCH: ${name} replaced`);
|
console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchEmpty(name: string, cleanups: (() => void)[]): boolean {
|
export function patchEmpty(name: string, cleanups: (() => void)[]): boolean {
|
||||||
|
registerTypeDetector(`ServerDrawer.Empty.${name}`, isNamed(name), (real) => {
|
||||||
|
registerIntercept(real, function EmptyPatch() { return null; });
|
||||||
|
console.log(TAG, `PATCH: ${name} replaced (type detector)`);
|
||||||
|
}, { persistent: true });
|
||||||
|
|
||||||
const mod = rawFind((m) => isNamed(name)(m?.type));
|
const mod = rawFind((m) => isNamed(name)(m?.type));
|
||||||
if (!mod?.type) {
|
if (!mod?.type) {
|
||||||
console.log(TAG, `WARN: ${name} not found`);
|
console.log(TAG, `WARN: ${name} module not found yet (will retry)`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orig = mod.type;
|
const orig = mod.type;
|
||||||
const originalComponent = orig;
|
function EmptyPatch() {
|
||||||
|
|
||||||
mod.type = function EmptyPatch() {
|
|
||||||
return null;
|
return null;
|
||||||
};
|
|
||||||
|
|
||||||
if (originalComponent) {
|
|
||||||
try {
|
|
||||||
Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(originalComponent));
|
|
||||||
} catch {
|
|
||||||
// Best-effort - not every descriptor can be copied onto another function.
|
|
||||||
}
|
|
||||||
mod.type.displayName = name;
|
|
||||||
}
|
}
|
||||||
|
mod.type = EmptyPatch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(orig));
|
||||||
|
} catch {
|
||||||
|
// Best-effort compatibility shim, not something correctness depends on.
|
||||||
|
}
|
||||||
|
mod.type.displayName = name;
|
||||||
|
|
||||||
cleanups.push(() => { mod.type = orig; });
|
cleanups.push(() => { mod.type = orig; });
|
||||||
|
console.log(TAG, `PATCH: ${name} replaced (module mutation)`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { findByProps } from "@vendetta/metro";
|
import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind";
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
const TAG = "[ServerDrawer]";
|
||||||
|
|
||||||
|
// A real, always-available Discord CDN URL - confirmed live that an empty string still resolves
|
||||||
|
// "successfully" through Discord's buildUrl (concatenates onto the CDN base rather than being
|
||||||
|
// recognized as absolute) but produces a malformed URL, and a full external https:// URL gets
|
||||||
|
// double-prefixed the same way ("https://cdn.discordapp.com/https://..."). Neither crashes -
|
||||||
|
// getQuestAsset below is guarded regardless - so this is cosmetic, not load-bearing.
|
||||||
const FALLBACK_ASSET_URL = "https://cdn.discordapp.com/embed/avatars/0.png";
|
const FALLBACK_ASSET_URL = "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
|
||||||
function buildFallbackQuest(userId: string | undefined) {
|
function buildFallbackQuest(userId: string | undefined) {
|
||||||
|
|
@ -83,26 +88,27 @@ function buildFallbackQuest(userId: string | undefined) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each of these four does its own completely independent findByProps() lookup, matching the
|
// Each of these four does its own completely independent rawFind lookup rather than sharing one
|
||||||
// original plugin's four separate files (questDockRender.ts, questDockBase.ts, mobileQuestDock.ts,
|
// resolved module reference across all of them - confirmed live (GuildsBar's own patch writeup)
|
||||||
// getQuestAsset.ts) exactly, rather than sharing one resolved module reference across all of them.
|
// that a metro search can land on a different module copy than another search for a related
|
||||||
// Confirmed elsewhere in this repo (GuildsBar's own patch writeup) that a metro search can land on
|
// property, even when both "succeed", so patching useMobileQuestDock and
|
||||||
// a different module copy than another search for a related property, even when both "succeed" -
|
// useIsMobileQuestDockRendered off the same found object was never guaranteed to be the same
|
||||||
// so patching useMobileQuestDock and useIsMobileQuestDockRendered off the same found object is not
|
// reference the real render path actually reads from. Matches the original plugin's four separate
|
||||||
// guaranteed to be the same reference the real render path actually reads from.
|
// files (questDockRender.ts, questDockBase.ts, mobileQuestDock.ts, getQuestAsset.ts) in spirit,
|
||||||
|
// just retry-safe.
|
||||||
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
|
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
|
||||||
const mod = findByProps("useMobileQuestDock");
|
const mod = rawFindByFunctionProps<any>("useMobileQuestDock");
|
||||||
if (!mod?.useMobileQuestDock) {
|
if (!mod?.useMobileQuestDock) {
|
||||||
console.log(TAG, "WARN: useMobileQuestDock not found");
|
console.log(TAG, "WARN: useMobileQuestDock not found yet (will retry)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const orig = mod.useMobileQuestDock;
|
const orig = mod.useMobileQuestDock;
|
||||||
let cachedUserId: string | undefined;
|
let cachedUserId: string | undefined;
|
||||||
mod.useMobileQuestDock = function (...args: any[]) {
|
mod.useMobileQuestDock = function (this: unknown, ...args: any[]) {
|
||||||
const real = orig.apply(this, args);
|
const real = orig.apply(this, args);
|
||||||
if (real) return real;
|
if (real) return real;
|
||||||
if (cachedUserId === undefined) {
|
if (cachedUserId === undefined) {
|
||||||
cachedUserId = findByProps("getCurrentUser")?.getCurrentUser?.()?.id;
|
cachedUserId = rawFindByStoreName<any>("UserStore")?.getCurrentUser?.()?.id;
|
||||||
}
|
}
|
||||||
return buildFallbackQuest(cachedUserId);
|
return buildFallbackQuest(cachedUserId);
|
||||||
};
|
};
|
||||||
|
|
@ -111,13 +117,13 @@ export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
|
export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
|
||||||
const mod = findByProps("useIsMobileQuestDockRendered");
|
const mod = rawFindByFunctionProps<any>("useIsMobileQuestDockRendered");
|
||||||
if (!mod?.useIsMobileQuestDockRendered) {
|
if (!mod?.useIsMobileQuestDockRendered) {
|
||||||
console.log(TAG, "WARN: useIsMobileQuestDockRendered not found");
|
console.log(TAG, "WARN: useIsMobileQuestDockRendered not found yet (will retry)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const orig = mod.useIsMobileQuestDockRendered;
|
const orig = mod.useIsMobileQuestDockRendered;
|
||||||
mod.useIsMobileQuestDockRendered = function (...args: any[]) {
|
mod.useIsMobileQuestDockRendered = function (this: unknown, ...args: any[]) {
|
||||||
orig.apply(this, args);
|
orig.apply(this, args);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
@ -126,13 +132,13 @@ export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
|
export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
|
||||||
const mod = findByProps("useIsMobileQuestDockRenderedBase");
|
const mod = rawFindByFunctionProps<any>("useIsMobileQuestDockRenderedBase");
|
||||||
if (!mod?.useIsMobileQuestDockRenderedBase) {
|
if (!mod?.useIsMobileQuestDockRenderedBase) {
|
||||||
console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found");
|
console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found yet (will retry)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const orig = mod.useIsMobileQuestDockRenderedBase;
|
const orig = mod.useIsMobileQuestDockRenderedBase;
|
||||||
mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) {
|
mod.useIsMobileQuestDockRenderedBase = function (this: unknown, ...args: any[]) {
|
||||||
orig.apply(this, args);
|
orig.apply(this, args);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
@ -141,13 +147,13 @@ export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchGetQuestAsset(cleanups: (() => void)[]): boolean {
|
export function patchGetQuestAsset(cleanups: (() => void)[]): boolean {
|
||||||
const mod = findByProps("getQuestAsset");
|
const mod = rawFindByFunctionProps<any>("getQuestAsset");
|
||||||
if (!mod?.getQuestAsset) {
|
if (!mod?.getQuestAsset) {
|
||||||
console.log(TAG, "WARN: getQuestAsset not found");
|
console.log(TAG, "WARN: getQuestAsset not found yet (will retry)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const orig = mod.getQuestAsset;
|
const orig = mod.getQuestAsset;
|
||||||
mod.getQuestAsset = function (...args: any[]) {
|
mod.getQuestAsset = function (this: unknown, ...args: any[]) {
|
||||||
try {
|
try {
|
||||||
return orig.apply(this, args);
|
return orig.apply(this, args);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,86 @@
|
||||||
import { find, findByName } from "@vendetta/metro";
|
import React from "react";
|
||||||
import { registerIntercept } from "../lib/createElementIntercept";
|
import { View } from "react-native";
|
||||||
|
import { registerPropsTransform, registerTypeDetector, registerIntercept } from "../lib/createElementIntercept";
|
||||||
|
import { captureFiberRef } from "../lib/fiberCapture";
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
const TAG = "[ServerDrawer]";
|
||||||
|
const SD_NOTHING_TEST_ID = "ServerDrawerNothing";
|
||||||
|
|
||||||
function Nothing() {
|
function Nothing() {
|
||||||
return null;
|
return React.createElement(View, {
|
||||||
|
ref: captureFiberRef,
|
||||||
|
style: {
|
||||||
|
display: "none",
|
||||||
|
width: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 0,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function findGuildsBar(): any {
|
// Confirmed live (Key Inspector's fiber capture): the parent creates GuildsBar via its OUTER
|
||||||
const byName = findByName("GuildsBar");
|
// React.memo wrapper object, which has no own .name/.displayName at all - those only exist on the
|
||||||
if (byName) return { default: byName };
|
// memo's inner function, at type.type.name/type.type.displayName.
|
||||||
|
function isGuildsBar(type: any): boolean {
|
||||||
|
return type?.name === "GuildsBar" || type?.displayName === "GuildsBar" ||
|
||||||
|
type?.type?.name === "GuildsBar" || type?.type?.displayName === "GuildsBar";
|
||||||
|
}
|
||||||
|
|
||||||
let mod = find((m) => {
|
function hasChildWithTestID(children: any, rest: any[], testID: string): boolean {
|
||||||
try { return m?.default?.type?.name === "GuildsBar"; } catch { return false; }
|
const inspect = (child: any) => child != null && typeof child === "object" && child.props?.testID === testID;
|
||||||
});
|
if (children != null) {
|
||||||
if (mod?.default) return mod;
|
if (Array.isArray(children)) { if (children.some(inspect)) return true; }
|
||||||
|
else if (inspect(children)) return true;
|
||||||
mod = find((m) => {
|
}
|
||||||
try { return m?.default?.displayName === "GuildsBar"; } catch { return false; }
|
for (const child of rest) if (inspect(child)) return true;
|
||||||
});
|
return false;
|
||||||
if (mod?.default) return mod;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchHideGuildsBar(cleanups: (() => void)[]): boolean {
|
export function patchHideGuildsBar(cleanups: (() => void)[]): boolean {
|
||||||
const mod = findGuildsBar();
|
// Hard-enforce the GuildsBar parent's visibility by detecting the hidden Nothing child and
|
||||||
if (!mod?.default) {
|
// zeroing the rail wrapper itself. Confirmed live that the immediate parent carries an
|
||||||
console.log(TAG, "WARN: GuildsBar not found");
|
// explicit width: 72 in its own style, so display: none alone left the space reserved -
|
||||||
return false;
|
// zeroing width/flex directly, the same way collapseAncestors does further up, actually
|
||||||
}
|
// reclaims it.
|
||||||
const orig = mod.default;
|
registerPropsTransform(
|
||||||
|
(props: any, _type: any, rest: any[]) =>
|
||||||
|
hasChildWithTestID(props?.children, rest, SD_NOTHING_TEST_ID),
|
||||||
|
(props: any) => ({
|
||||||
|
...props,
|
||||||
|
style: [
|
||||||
|
props?.style,
|
||||||
|
{
|
||||||
|
display: "none",
|
||||||
|
width: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 0,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
registerIntercept(orig, Nothing);
|
registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => {
|
||||||
|
registerIntercept(realGuildsBar, Nothing, { testID: SD_NOTHING_TEST_ID }, { collapseAncestors: 6 });
|
||||||
mod.default = function HiddenGuildsBar() {
|
console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing");
|
||||||
return null;
|
}, { persistent: true });
|
||||||
};
|
cleanups.push(() => {
|
||||||
mod.default.displayName = "GuildsBar";
|
// No per-call unregister needed - createElementIntercept clears all transforms/intercepts
|
||||||
cleanups.push(() => { mod.default = orig; });
|
// together when its own patch unwinds.
|
||||||
|
});
|
||||||
|
console.log(TAG, "PATCH: watching for the real GuildsBar to appear");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,25 @@
|
||||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
import { lazy } from "../lib/lazy";
|
||||||
|
import { rawFindByStoreName } from "../lib/rawFind";
|
||||||
|
import { getFlux, getColorModule } from "../lib/commonModules";
|
||||||
|
|
||||||
const Flux = findByProps("useStateFromStores");
|
const getExpandedGuildFolderStore = lazy(() => rawFindByStoreName("ExpandedGuildFolderStore"));
|
||||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
|
||||||
const ExpandedGuildFolderStore = findByStoreName("ExpandedGuildFolderStore");
|
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
|
const colors = getColorModule()?.colors;
|
||||||
return {
|
return {
|
||||||
text: colors?.TEXT_NORMAL ?? "#dbdee1",
|
text: colors?.TEXT_NORMAL ?? colors?.TEXT_DEFAULT ?? "#dbdee1",
|
||||||
folder: "#5865f2",
|
folder: "#5865f2",
|
||||||
hover: colors?.STATE_LAYER_PRESS ?? "rgba(255,255,255,0.06)",
|
hover: colors?.STATE_LAYER_PRESS ?? "rgba(255,255,255,0.06)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFolderExpanded(folderId: string | number): boolean {
|
export function useFolderExpanded(folderId: string | number): boolean {
|
||||||
// Confirmed live: an account with folders and one without exercise different code paths (this
|
const Flux = getFlux();
|
||||||
// hook is only ever called by FolderItem, which only exists when there's a folder to render) -
|
const ExpandedGuildFolderStore = getExpandedGuildFolderStore();
|
||||||
// an account with no folders rendered fine while one with folders showed nothing at all.
|
// Guards against an undefined store, not just a falsy return value - confirmed live that
|
||||||
// ExpandedGuildFolderStore is a one-shot findByStoreName at module-eval time with no retry, so
|
// passing [undefined] into useStateFromStores throws rather than failing safely, which used to
|
||||||
// if it hadn't registered yet, this passed [undefined] into useStateFromStores - which throws
|
// take the whole drawer down (this only ever runs when there's an actual folder to render, so
|
||||||
// rather than returning safely, taking down the whole drawer render, not just this one folder.
|
// an account with folders showed nothing at all while one without rendered fine).
|
||||||
if (!Flux?.useStateFromStores || !ExpandedGuildFolderStore) return false;
|
if (!Flux?.useStateFromStores || !ExpandedGuildFolderStore) return false;
|
||||||
return Flux.useStateFromStores(
|
return Flux.useStateFromStores(
|
||||||
[ExpandedGuildFolderStore],
|
[ExpandedGuildFolderStore],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue