ServerDrawer: swap in the exact, unmodified original plugin for a clean baseline test
This commit is contained in:
parent
2d00277334
commit
1456803f3d
29 changed files with 377 additions and 1628 deletions
|
|
@ -6,7 +6,7 @@
|
|||
{ "name": "kmmiio99o" },
|
||||
{ "name": "fres" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"main": "src/index.tsx",
|
||||
"vendetta": {
|
||||
"icon": "ServerIcon"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import React from "react";
|
||||
import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native";
|
||||
import { semanticColors } from "@vendetta/ui";
|
||||
import { resolveSemanticColor } from "../lib/color";
|
||||
import { lazy } from "../lib/lazy";
|
||||
import { rawFindByValidProps } from "../lib/rawFind";
|
||||
import { getHaptic } from "../lib/commonModules";
|
||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
|
||||
const getTextStyleSheet = lazy(() => rawFindByValidProps<any>({
|
||||
TextStyleSheet: (v) => v?.["text-md/bold"] !== undefined,
|
||||
})?.TextStyleSheet);
|
||||
const TextStyleSheet = findByProps("TextStyleSheet")?.TextStyleSheet;
|
||||
const ThemeStore = findByStoreName("ThemeStore");
|
||||
const colorModule = findByProps("colors", "unsafe_rawColors");
|
||||
const colorResolver = colorModule?.internal ?? colorModule?.meta;
|
||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
||||
|
||||
function resolve(token: any, fallback: string): string {
|
||||
return resolveSemanticColor(token) ?? fallback;
|
||||
function resolve(color: any): string {
|
||||
const theme = ThemeStore?.theme;
|
||||
return (color && colorResolver?.resolveSemanticColor(theme, color)) || "#000000";
|
||||
}
|
||||
|
||||
const EDGE = 12;
|
||||
|
|
@ -47,9 +47,6 @@ export function ContextMenuModal({
|
|||
anchorY: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const TextStyleSheet = getTextStyleSheet();
|
||||
const Haptic = getHaptic();
|
||||
|
||||
const { width: winW, height: winH } = Dimensions.get("window");
|
||||
|
||||
const titleH = title ? PAD + 20 + DIVIDER_H : 0;
|
||||
|
|
@ -62,10 +59,10 @@ export function ContextMenuModal({
|
|||
if (top + menuH > winH - EDGE) top = anchorY - menuH - 10;
|
||||
if (top < EDGE) top = EDGE;
|
||||
|
||||
const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST, "#1e1f22");
|
||||
const borderColor = resolve(semanticColors?.BORDER_SUBTLE, "#3f4147");
|
||||
const textColor = resolve(semanticColors?.TEXT_STRONG, "#f2f3f5");
|
||||
const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE, "rgba(255,255,255,0.06)");
|
||||
const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST);
|
||||
const borderColor = resolve(semanticColors?.BORDER_SUBTLE);
|
||||
const textColor = resolve(semanticColors?.TEXT_STRONG);
|
||||
const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE);
|
||||
|
||||
const backdropOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const menuScale = React.useRef(new Animated.Value(0.5)).current;
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
import React from "react";
|
||||
import { View, Pressable, Image, StyleSheet } from "react-native";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { lazy } from "../lib/lazy";
|
||||
import { rawFindByFunctionProps, rawFindByName } from "../lib/rawFind";
|
||||
import { getFlux, getHaptic, getColorModule } from "../lib/commonModules";
|
||||
|
||||
const ICON = 48;
|
||||
|
||||
const ChatIcon = getAssetIDByName("ChatIcon");
|
||||
|
||||
const getChannelActions = lazy(() => rawFindByFunctionProps<any>("selectPrivateChannel"));
|
||||
const getSelectedChannelStore = lazy(() => rawFindByName<any>("SelectedChannelStore"));
|
||||
const getNavContext = lazy(() => rawFindByFunctionProps<any>("getGuildId"));
|
||||
|
||||
function openDms() {
|
||||
const Haptic = getHaptic();
|
||||
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
|
||||
const ChannelActions = getChannelActions();
|
||||
if (ChannelActions?.selectPrivateChannel) {
|
||||
const lastChannelId = getSelectedChannelStore()?.getLastSelectedChannelId?.();
|
||||
ChannelActions.selectPrivateChannel(lastChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
export default function DmTile() {
|
||||
const Flux = getFlux();
|
||||
const NavContext = getNavContext();
|
||||
const colors = getColorModule()?.colors;
|
||||
|
||||
const selected = Flux?.useStateFromStores?.(
|
||||
[NavContext],
|
||||
() => NavContext?.getGuildId?.() == null,
|
||||
) ?? false;
|
||||
|
||||
return (
|
||||
<Pressable onPress={openDms} style={st.outer}>
|
||||
<View style={[st.icon, { backgroundColor: selected ? (colors?.BG_ACCENT ?? "#5865f2") : "rgba(128,128,128,0.24)" }]}>
|
||||
<Image source={ChatIcon} style={{ width: 24, height: 24, tintColor: "#fff" }} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const st = StyleSheet.create({
|
||||
outer: { width: ICON, height: ICON },
|
||||
icon: { width: ICON, height: ICON, borderRadius: 16, alignItems: "center", justifyContent: "center" },
|
||||
});
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
import React from "react";
|
||||
import { View, Text, Animated, Pressable, Image, ViewStyle, StyleSheet } from "react-native";
|
||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
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 GuildItem from "./GuildItem";
|
||||
|
||||
const getGuildActions = lazy(() => rawFindByFunctionProps<any>("toggleGuildFolderExpand"));
|
||||
const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore"));
|
||||
const GuildActions = findByProps("toggleGuildFolderExpand");
|
||||
const Flux = findByProps("useStateFromStores");
|
||||
const GuildReadStateStore = findByStoreName("GuildReadStateStore");
|
||||
|
||||
const ICON = 48;
|
||||
const MINI = 16;
|
||||
|
|
@ -31,14 +28,7 @@ function folderColor(color?: number | null): string {
|
|||
}
|
||||
|
||||
function FolderBadge({ node }: { node: GuildNode }) {
|
||||
const Flux = getFlux();
|
||||
const GuildReadStateStore = getGuildReadStateStore();
|
||||
|
||||
// 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],
|
||||
() => {
|
||||
let sum = 0;
|
||||
|
|
@ -48,7 +38,7 @@ function FolderBadge({ node }: { node: GuildNode }) {
|
|||
return sum;
|
||||
},
|
||||
[node.children],
|
||||
) : 0;
|
||||
) ?? 0;
|
||||
|
||||
if (total > 0) {
|
||||
return (
|
||||
|
|
@ -63,23 +53,15 @@ function FolderBadge({ node }: { node: GuildNode }) {
|
|||
}
|
||||
|
||||
function FolderCover({ node }: { node: GuildNode }) {
|
||||
useProxy(storage);
|
||||
const col = folderColor(node.color);
|
||||
|
||||
return (
|
||||
<View style={fc.outer}>
|
||||
<View style={[fc.icon, { backgroundColor: col }]}>
|
||||
{storage.hideFolderIcons ? (
|
||||
<View style={fc.plainIcon}>
|
||||
<Image source={FOLDER_ASSET} style={fo.folderImg} tintColor="#fff" />
|
||||
{node.children.slice(0, 4).map((ch, i) => (
|
||||
<View key={ch.id} style={[fc.cell, POS[i]]}>
|
||||
<GuildIcon id={ch.id as string} size={MINI} />
|
||||
</View>
|
||||
) : (
|
||||
node.children.slice(0, 4).map((ch, i) => (
|
||||
<View key={ch.id} style={[fc.cell, POS[i]]}>
|
||||
<GuildIcon id={ch.id as string} size={MINI} />
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</View>
|
||||
<FolderBadge node={node} />
|
||||
</View>
|
||||
|
|
@ -90,7 +72,6 @@ const fc = StyleSheet.create({
|
|||
outer: { width: ICON, height: ICON },
|
||||
icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" },
|
||||
cell: { position: "absolute", width: MINI, height: MINI, borderRadius: 8, overflow: "hidden" },
|
||||
plainIcon: { width: ICON, height: ICON, alignItems: "center", justifyContent: "center" },
|
||||
});
|
||||
|
||||
function FadeIn({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -105,7 +86,7 @@ export default function FolderItem({ node, onPick }: { node: GuildNode; onPick:
|
|||
const open = useFolderExpanded(node.id);
|
||||
|
||||
const toggle = () => {
|
||||
getGuildActions()?.toggleGuildFolderExpand?.(node.id);
|
||||
GuildActions?.toggleGuildFolderExpand?.(node.id);
|
||||
};
|
||||
|
||||
if (open) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
import React from "react";
|
||||
import { View, Text, Image, StyleSheet } from "react-native";
|
||||
import { lazy } from "../lib/lazy";
|
||||
import { rawFindByStoreName } from "../lib/rawFind";
|
||||
import { getFlux, getColorModule } from "../lib/commonModules";
|
||||
import { findByStoreName, findByProps } from "@vendetta/metro";
|
||||
|
||||
const getGuildStore = lazy(() => rawFindByStoreName("GuildStore"));
|
||||
const GuildStore = findByStoreName("GuildStore");
|
||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
||||
|
||||
export default function GuildIcon({ id, size = 48 }: { id: string; size?: number }) {
|
||||
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;
|
||||
const g = GuildStore?.getGuild(id);
|
||||
if (!g) return null;
|
||||
const rad = size >= 40 ? 16 : 8;
|
||||
if (g.icon) {
|
||||
|
|
|
|||
|
|
@ -1,34 +1,19 @@
|
|||
import React from "react";
|
||||
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { lazy } from "../lib/lazy";
|
||||
import { rawFind, rawFindByStoreName } from "../lib/rawFind";
|
||||
import { getFlux, getHaptic } from "../lib/commonModules";
|
||||
import { GuildNode, useTheme } from "../utils/theme";
|
||||
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { GuildNode } from "../utils/theme";
|
||||
import GuildIcon from "./GuildIcon";
|
||||
import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal";
|
||||
|
||||
const ICON = 48;
|
||||
|
||||
const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore"));
|
||||
const getGuildStore = lazy(() => rawFindByStoreName("GuildStore"));
|
||||
const getSortedGuildStore = lazy(() => rawFindByStoreName("SortedGuildStore"));
|
||||
|
||||
// Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with
|
||||
// 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
|
||||
);
|
||||
const Flux = findByProps("useStateFromStores");
|
||||
const GuildReadStateStore = findByStoreName("GuildReadStateStore");
|
||||
const GuildStore = findByStoreName("GuildStore");
|
||||
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
||||
|
||||
function Badge({ guildId }: { guildId: string }) {
|
||||
const Flux = getFlux();
|
||||
const GuildReadStateStore = getGuildReadStateStore();
|
||||
|
||||
const mentionCount = Flux?.useStateFromStores?.(
|
||||
[GuildReadStateStore],
|
||||
() => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0,
|
||||
|
|
@ -62,28 +47,6 @@ function Badge({ guildId }: { guildId: string }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function GuildLabel({ guildId }: { guildId: string }) {
|
||||
useProxy(storage);
|
||||
const theme = useTheme();
|
||||
|
||||
const Flux = getFlux();
|
||||
const GuildStore = getGuildStore();
|
||||
|
||||
const name = Flux?.useStateFromStores?.(
|
||||
[GuildStore],
|
||||
() => GuildStore?.getGuild?.(guildId)?.name ?? "",
|
||||
[guildId],
|
||||
) ?? "";
|
||||
|
||||
if (storage.showGuildNames === false || !name) return null;
|
||||
|
||||
return (
|
||||
<Text numberOfLines={2} style={[lb.label, { color: theme.text }]}>
|
||||
{name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (id: string) => void }) {
|
||||
const viewRef = React.useRef<View>(null);
|
||||
const scale = React.useRef(new Animated.Value(1)).current;
|
||||
|
|
@ -100,11 +63,15 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
|||
|
||||
const showMenu = React.useCallback(() => {
|
||||
const guildId = node.id as string;
|
||||
const guild = getGuildStore()?.getGuild?.(guildId);
|
||||
const menuItemsFn = getMenuItemsFn();
|
||||
if (!guild || !menuItemsFn) return;
|
||||
const guild = GuildStore?.getGuild?.(guildId);
|
||||
const menuItemsFn = find((m) => m?.default?.name === "getGuildsBarGuildMenuItems")?.default
|
||||
?? find((m) => typeof m?.default === "function" && m.default.length <= 2 && /guild/i.test(m.default.name ?? ""))?.default;
|
||||
|
||||
const treeVersion = getSortedGuildStore()?.getGuildsTree?.()?.version;
|
||||
if (!guild || !menuItemsFn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeVersion = SortedGuildStore?.getGuildsTree?.()?.version;
|
||||
const rawItems = menuItemsFn(guildId, treeVersion);
|
||||
if (!rawItems?.length) return;
|
||||
|
||||
|
|
@ -129,7 +96,6 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
|||
}, [node.id]);
|
||||
|
||||
const handleLongPress = React.useCallback(() => {
|
||||
const Haptic = getHaptic();
|
||||
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_MEDIUM);
|
||||
showMenu();
|
||||
}, [showMenu]);
|
||||
|
|
@ -145,14 +111,11 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
|||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
>
|
||||
<View style={st.outer}>
|
||||
<View ref={viewRef} style={st.iconWrap} collapsable={false}>
|
||||
<Animated.View style={[st.icon, { transform: [{ scale }] }]}>
|
||||
<GuildIcon id={guildId} />
|
||||
</Animated.View>
|
||||
<Badge guildId={guildId} />
|
||||
</View>
|
||||
<GuildLabel guildId={guildId} />
|
||||
<View ref={viewRef} style={st.outer} collapsable={false}>
|
||||
<Animated.View style={[st.icon, { transform: [{ scale }] }]}>
|
||||
<GuildIcon id={guildId} />
|
||||
</Animated.View>
|
||||
<Badge guildId={guildId} />
|
||||
</View>
|
||||
</Pressable>
|
||||
<ContextMenuModal
|
||||
|
|
@ -168,21 +131,10 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
|||
}
|
||||
|
||||
const st = StyleSheet.create({
|
||||
outer: { width: ICON },
|
||||
iconWrap: { width: ICON, height: ICON },
|
||||
outer: { width: ICON, height: ICON },
|
||||
icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" },
|
||||
});
|
||||
|
||||
const lb = StyleSheet.create({
|
||||
label: {
|
||||
width: ICON,
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
const bd = StyleSheet.create({
|
||||
outline: {
|
||||
position: "absolute",
|
||||
|
|
|
|||
|
|
@ -1,46 +1,31 @@
|
|||
import React from "react";
|
||||
import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { lazy } from "../lib/lazy";
|
||||
import { rawFind, rawFindByFunctionProps } from "../lib/rawFind";
|
||||
import { getFlux, getHaptic, getColorModule } from "../lib/commonModules";
|
||||
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { GuildNode } from "../utils/theme";
|
||||
import GuildItem from "./GuildItem";
|
||||
import FolderItem from "./FolderItem";
|
||||
import DmTile from "./DmTile";
|
||||
|
||||
const getRootNav = lazy(() => rawFindByFunctionProps("getRootNavigationRef"));
|
||||
const getRouting = lazy(() => rawFindByFunctionProps("transitionToGuild"));
|
||||
const getCreateJoinGuildMod = lazy(() => rawFind((m: any) => typeof m?.handleCreateJoinGuildPress === "function"));
|
||||
const getCirclePlusIcon = lazy(() => rawFind((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon);
|
||||
const Flux = findByProps("useStateFromStores");
|
||||
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
||||
const RootNav = findByProps("getRootNavigationRef");
|
||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
||||
const Routing = findByProps("transitionToGuild");
|
||||
|
||||
const getExternalContext = lazy(() => rawFind((m: any) => m?.QuestDockExternalCoordinationContext)?.QuestDockExternalCoordinationContext);
|
||||
const getQuestDockMode = lazy(() => rawFind((m: any) => m?.QuestDockMode?.COLLAPSED != null)?.QuestDockMode);
|
||||
const getSortedGuildStore = lazy(() => rawFind((m: any) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === "SortedGuildStore"));
|
||||
const CreateJoinGuildMod = find((m: any) => typeof m?.handleCreateJoinGuildPress === "function");
|
||||
const CirclePlusIcon = find((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon;
|
||||
const rawColors = findByProps("colors", "unsafe_rawColors")?.unsafe_rawColors;
|
||||
|
||||
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 GAP = 6;
|
||||
const PAD = 12;
|
||||
|
||||
// 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
|
||||
// uncaught throw somewhere in FolderItem's render taking the whole grid down with it, not just the
|
||||
// one folder. A try/catch around JSX can't catch this - the exception happens during React's own
|
||||
// render pass, not synchronously in this function's body - only a class component's
|
||||
// getDerivedStateFromError can. Wrapping each folder individually means one broken folder
|
||||
// disappears instead of every other server in the drawer.
|
||||
class FolderErrorBoundary extends React.Component<{ children: React.ReactNode }, { errored: boolean }> {
|
||||
state = { errored: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { errored: true };
|
||||
}
|
||||
render() {
|
||||
if (this.state.errored) return null;
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function CreateJoinButton() {
|
||||
const scale = React.useRef(new Animated.Value(1)).current;
|
||||
const scaleDown = React.useCallback(() => {
|
||||
|
|
@ -51,21 +36,17 @@ function CreateJoinButton() {
|
|||
}, [scale]);
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const haptic = getHaptic();
|
||||
haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT);
|
||||
getCreateJoinGuildMod()?.handleCreateJoinGuildPress?.();
|
||||
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
|
||||
CreateJoinGuildMod?.handleCreateJoinGuildPress?.();
|
||||
}, []);
|
||||
|
||||
const CirclePlusIcon = getCirclePlusIcon();
|
||||
const rawColors = getColorModule()?.unsafe_rawColors;
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} onPressIn={scaleDown} onPressOut={scaleUp}>
|
||||
<Animated.View style={[st.createJoin, { backgroundColor: rawColors?.GREEN_360, transform: [{ scale }] }]}>
|
||||
<Animated.View style={[st.createJoin, { transform: [{ scale }] }]}>
|
||||
{CirclePlusIcon ? (
|
||||
<CirclePlusIcon size="md" color={rawColors?.WHITE} />
|
||||
<CirclePlusIcon size="md" color={createJoinIconColor} />
|
||||
) : (
|
||||
<Text style={[st.createJoinFallback, { color: rawColors?.WHITE }]}>{"+"}</Text>
|
||||
<Text style={st.createJoinFallback}>{"+"}</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
|
@ -73,23 +54,15 @@ function CreateJoinButton() {
|
|||
}
|
||||
|
||||
export default function ServerDrawerSheet({ gestureContext }: { gestureContext: any }) {
|
||||
useProxy(storage);
|
||||
|
||||
const pick = React.useCallback((id: string) => {
|
||||
const haptic = getHaptic();
|
||||
haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT);
|
||||
|
||||
const routing = getRouting();
|
||||
if (routing?.transitionToGuild) {
|
||||
routing.transitionToGuild(id);
|
||||
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
|
||||
if (Routing?.transitionToGuild) {
|
||||
Routing.transitionToGuild(id, null);
|
||||
} else {
|
||||
getRootNav()?.getRootNavigationRef?.()?.navigate("guilds", { guildId: id });
|
||||
RootNav?.getRootNavigationRef()?.navigate("guilds", { guildId: id });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const Flux = getFlux();
|
||||
const SortedGuildStore = getSortedGuildStore();
|
||||
|
||||
const nodes: GuildNode[] = Flux?.useStateFromStores?.(
|
||||
[SortedGuildStore],
|
||||
() => {
|
||||
|
|
@ -107,14 +80,12 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext:
|
|||
if (minH.get() !== h) minH.set(h);
|
||||
}, [minH]);
|
||||
|
||||
const ExternalContext = getExternalContext();
|
||||
const extCtx = ExternalContext ? React.useContext(ExternalContext) as any : null;
|
||||
const setMode = extCtx?.setRestingQuestDockMode;
|
||||
|
||||
const specs = ctx?.questDockWrapperSpecs;
|
||||
|
||||
React.useEffect(() => {
|
||||
const QuestDockMode = getQuestDockMode();
|
||||
if (!setMode || !QuestDockMode || !specs) return;
|
||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
const h = specs.get()?.height ?? 56;
|
||||
|
|
@ -139,14 +110,9 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext:
|
|||
style={[st.grid, { paddingHorizontal: padX, gap: GAP }]}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{!storage.hideDmTile && <DmTile />}
|
||||
{nodes.map((node) =>
|
||||
node.type === "folder"
|
||||
? (
|
||||
<FolderErrorBoundary key={node.id}>
|
||||
<FolderItem node={node} onPick={pick} />
|
||||
</FolderErrorBoundary>
|
||||
)
|
||||
? <FolderItem key={node.id} node={node} onPick={pick} />
|
||||
: <GuildItem key={node.id} node={node} onPick={pick} />
|
||||
)}
|
||||
<CreateJoinButton />
|
||||
|
|
@ -171,10 +137,12 @@ const st = StyleSheet.create({
|
|||
width: ICON,
|
||||
height: ICON,
|
||||
borderRadius: 16,
|
||||
backgroundColor: createJoinBg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
createJoinFallback: {
|
||||
color: createJoinIconColor,
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
lineHeight: 30,
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { patchQuestDockRender, patchQuestDockBase, patchMobileQuestDock, patchGetQuestAsset } from "./patches/fakeQuestDock";
|
||||
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
||||
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
||||
import { patchCreateElement } from "./lib/createElementIntercept";
|
||||
import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
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() {
|
||||
const patchers: Record<string, () => boolean> = {
|
||||
...(storage.fakeQuestDock !== false ? {
|
||||
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);
|
||||
|
||||
const pending = new Set(Object.keys(patchers));
|
||||
const applied = new Set<string>();
|
||||
|
||||
const attempt = () => {
|
||||
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);
|
||||
}
|
||||
|
||||
logger.log(`[ServerDrawer] onLoad: applied immediately - ${[...applied].join(", ") || "none"}${pending.size ? `, still waiting on ${[...pending].join(", ")}` : ""}`);
|
||||
}
|
||||
|
||||
function unpatchAll() {
|
||||
if (retryHandle) {
|
||||
clearInterval(retryHandle);
|
||||
retryHandle = undefined;
|
||||
}
|
||||
for (const fn of cleanups) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
// A patch failing to undo shouldn't block the others from unwinding.
|
||||
}
|
||||
}
|
||||
cleanups = [];
|
||||
}
|
||||
|
||||
export function restart() {
|
||||
unpatchAll();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
storage.hideGuildsBar ??= true;
|
||||
storage.showGuildNames ??= true;
|
||||
storage.autoCollapseFolders ??= false;
|
||||
storage.hideFolderIcons ??= false;
|
||||
storage.fakeQuestDock ??= true;
|
||||
|
||||
applyAll();
|
||||
},
|
||||
onUnload: () => unpatchAll(),
|
||||
settings: Settings,
|
||||
};
|
||||
40
plugins/server-drawer/src/index.tsx
Normal file
40
plugins/server-drawer/src/index.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { patchQuestDockRender } from "./patches/questDockRender";
|
||||
import { patchQuestDockBase } from "./patches/questDockBase";
|
||||
import { patchMobileQuestDock } from "./patches/mobileQuestDock";
|
||||
import { patchGetQuestAsset } from "./patches/getQuestAsset";
|
||||
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
||||
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
||||
import { patchCreateElement } from "./patches/createElementIntercept";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
export default {
|
||||
onLoad() {
|
||||
console.log(TAG, "onLoad");
|
||||
|
||||
let patched = 0;
|
||||
|
||||
patchCreateElement(cleanups);
|
||||
patched++;
|
||||
|
||||
if (patchQuestDockRender(cleanups)) patched++;
|
||||
if (patchQuestDockBase(cleanups)) patched++;
|
||||
if (patchMobileQuestDock(cleanups)) patched++;
|
||||
if (patchGetQuestAsset(cleanups)) patched++;
|
||||
if (patchExpanded(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 (patchHideGuildsBar(cleanups)) patched++;
|
||||
|
||||
console.log(TAG, `onLoad done — ${patched} patches applied, ${cleanups.length} cleanups`);
|
||||
},
|
||||
onUnload() {
|
||||
console.log(TAG, "onUnload");
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups.length = 0;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { semanticColors } from "@vendetta/ui";
|
||||
import { lazy } from "./lazy";
|
||||
import { rawFind, rawFindByStoreName } from "./rawFind";
|
||||
|
||||
export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||
|
||||
// Switched from the caching findByStoreName/find (permanently wrong if either runs before its
|
||||
// target module registers) to lazy + raw variants for the same timing reason established
|
||||
// elsewhere in this plugin.
|
||||
const getThemeStore = lazy(() => rawFindByStoreName<any>("ThemeStore"));
|
||||
|
||||
// @vendetta/ui exposes the semanticColors token map but not a resolver function for it.
|
||||
const getRawResolveSemanticColor = lazy((): ((theme: any, semanticColor: any) => string | undefined) | undefined =>
|
||||
rawFind((m: any) => m?.default?.internal?.resolveSemanticColor)?.default?.internal?.resolveSemanticColor ??
|
||||
rawFind((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor);
|
||||
|
||||
/** Resolves a semanticColors[TOKEN_NAME] descriptor to a real color for the current theme. */
|
||||
export function resolveSemanticColor(token: any, theme: any = getThemeStore()?.theme): string | undefined {
|
||||
if (!token) return undefined;
|
||||
try {
|
||||
return getRawResolveSemanticColor()?.(theme, token);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function resolveSemanticColorSafe(tokenNames: string[], fallbackHex: string, theme: any = getThemeStore()?.theme): string {
|
||||
for (const name of tokenNames) {
|
||||
const token = (semanticColors as Record<string, any> | undefined)?.[name];
|
||||
if (!token) continue;
|
||||
const resolved = resolveSemanticColor(token, theme);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return fallbackHex;
|
||||
}
|
||||
|
||||
export function isValidHex(value: string | undefined | null): value is string {
|
||||
return !!value && HEX_REGEX.test(value);
|
||||
}
|
||||
|
||||
/** Expands short form #abc to #aabbcc; leaves 6-digit hex untouched. */
|
||||
export function normalizeHex(hex: string): string {
|
||||
if (hex.length === 4) {
|
||||
return "#" + hex.slice(1).split("").map((c) => c + c).join("");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
export function interpolateColor(color1: string, color2: string, percentage: number): string {
|
||||
const hexToRgb = (hex: string) => (normalizeHex(hex).match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16));
|
||||
const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage));
|
||||
|
||||
return rgbToHex(interpolated);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { lazy } from "./lazy";
|
||||
import { rawFindByFunctionProps, rawFindByValidProps } from "./rawFind";
|
||||
|
||||
/**
|
||||
* Confirmed live (Key Inspector's Eval console, full window.modules scan): a single decoy module
|
||||
* matches dozens of common hook/utility property names at once - useStateFromStores,
|
||||
* triggerHapticFeedback, colors, transitionToGuild, bulkAck, and more - all with non-function/
|
||||
* placeholder values (a type-shape/declaration-style object, not real code). Since window.modules
|
||||
* iterates in ascending numeric id order and this decoy has a lower id than every real
|
||||
* implementation it collides with, a plain "does this property exist" check
|
||||
* (findByProps/rawFindByProps) reliably matches the decoy instead of the real thing - every single
|
||||
* time, not occasionally. This is what made tapping a server, marking as read, and haptics all
|
||||
* silently no-op even after this repo's own retry-loop fixes.
|
||||
*
|
||||
* rawFindByFunctionProps requires the matched property to actually be a function, which the decoy's
|
||||
* isn't. Enum/object-shaped properties (which the decoy also fakes) need rawFindByValidProps
|
||||
* instead, checking for a real value the decoy doesn't have.
|
||||
*
|
||||
* Centralized here because every component in this plugin independently needed the same handful of
|
||||
* lookups - keeping one copy means the decoy-avoidance logic can't drift out of sync across files.
|
||||
*/
|
||||
export const getFlux = lazy(() => rawFindByFunctionProps<any>("useStateFromStores"));
|
||||
|
||||
export const getHaptic = lazy(() => rawFindByValidProps<any>({
|
||||
triggerHapticFeedback: (v) => typeof v === "function",
|
||||
HapticFeedbackTypes: (v) => v?.SOFT !== undefined,
|
||||
}));
|
||||
|
||||
/** Has both `.colors` (semantic tokens like TEXT_NORMAL/BG_ACCENT) and `.unsafe_rawColors` (raw palette like WHITE/GREEN_360) on the same module. */
|
||||
export const getColorModule = lazy(() => rawFindByValidProps<any>({
|
||||
colors: (v) => v?.BG_ACCENT !== undefined,
|
||||
unsafe_rawColors: (v) => v?.WHITE !== undefined,
|
||||
}));
|
||||
|
||||
export const getBulkAckMod = lazy(() => rawFindByFunctionProps<any>("bulkAck", "ackChannel"));
|
||||
|
||||
export const getReadStateTypesMod = lazy(() => rawFindByValidProps<any>({
|
||||
ReadStateTypes: (v) => v?.CHANNEL !== undefined,
|
||||
UnreadSetting: (v) => v?.ALL_MESSAGES !== undefined,
|
||||
}));
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
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.
|
||||
*/
|
||||
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 propsIntercepts: PropsIntercept[] = [];
|
||||
const propsTransforms: PropsTransform[] = [];
|
||||
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.
|
||||
*/
|
||||
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(
|
||||
original: React.ComponentType<any>,
|
||||
replacement: React.ComponentType<any>,
|
||||
extraProps?: Record<string, any>,
|
||||
options?: { collapseAncestors?: number },
|
||||
) {
|
||||
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)[]) {
|
||||
if (isPatched) return;
|
||||
isPatched = true;
|
||||
|
||||
if (React.createElement) {
|
||||
cleanups.push(
|
||||
after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
|
||||
);
|
||||
} 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();
|
||||
propsIntercepts.length = 0;
|
||||
propsTransforms.length = 0;
|
||||
typeDetectors = [];
|
||||
detectorKeys.clear();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
/**
|
||||
* Reads React's internal fiber pointer directly off a native view instance and walks up to the
|
||||
* root, storing both under window.__serverDrawerFiberRoot/__serverDrawerFiberSelf for Eval scripts
|
||||
* to search afterward. Same technique Key Inspector's own Settings-screen capture uses, but kept
|
||||
* under separate globals - Key Inspector's capture only reaches whatever tree its own Settings
|
||||
* screen renders in (very likely a separate native modal/root from MainTabs' guild tab content),
|
||||
* so a capture point actually inside this plugin's own rendered output is what's needed to reach
|
||||
* anything under the Guilds tab (confirmed live: a Settings-screen capture couldn't find
|
||||
* QuestDockWithQuestContext at all, even navigating to the Guilds tab first).
|
||||
*/
|
||||
export function captureFiberRef(instance: any) {
|
||||
if (!instance) return;
|
||||
try {
|
||||
let fiber: any = null;
|
||||
|
||||
if (instance._internalFiberInstanceHandleDEV) {
|
||||
fiber = instance._internalFiberInstanceHandleDEV;
|
||||
} else if (instance._internalInstanceHandle) {
|
||||
fiber = instance._internalInstanceHandle;
|
||||
} else if (instance.__internalInstanceHandle) {
|
||||
fiber = instance.__internalInstanceHandle;
|
||||
} else {
|
||||
const fiberKey = Object.keys(instance).find(
|
||||
(k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$")
|
||||
);
|
||||
if (fiberKey) fiber = instance[fiberKey];
|
||||
}
|
||||
if (!fiber) return;
|
||||
|
||||
if (fiber.return === undefined && fiber.child === undefined) {
|
||||
const nested = fiber.stateNode ?? fiber.fiber ?? fiber._debugOwner;
|
||||
if (nested && (nested.return !== undefined || nested.child !== undefined)) {
|
||||
fiber = nested;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let root = fiber;
|
||||
let guard = 0;
|
||||
while (root.return && guard < 5000) {
|
||||
root = root.return;
|
||||
guard++;
|
||||
}
|
||||
|
||||
(window as any).__serverDrawerFiberRoot = root;
|
||||
(window as any).__serverDrawerFiberSelf = fiber;
|
||||
} catch {
|
||||
// Best-effort diagnostic capture - a failure here shouldn't affect the caller's own render.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function lazy<T>(resolve: () => T | null | undefined): () => T | undefined {
|
||||
let cached: T | undefined;
|
||||
let resolved = false;
|
||||
|
||||
return () => {
|
||||
if (resolved) return cached;
|
||||
const value = resolve();
|
||||
if (value != null) {
|
||||
cached = value;
|
||||
resolved = true;
|
||||
}
|
||||
return value ?? undefined;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
// Self-contained on purpose - this plugin does not import from @shared/*. Revenge's own
|
||||
// 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). Metro only ever
|
||||
// runs a module's factory once and caches whatever it produced forever, so force-evaluating one
|
||||
// before its own dependencies are 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.
|
||||
declare const window: any;
|
||||
|
||||
export function rawFind<T = any>(predicate: (exports: any) => boolean): T | undefined {
|
||||
const modules = window?.modules;
|
||||
if (!modules) return undefined;
|
||||
|
||||
for (const id in modules) {
|
||||
const def = modules[id];
|
||||
if (!def?.isInitialized) continue;
|
||||
|
||||
const exports = def.publicModule?.exports;
|
||||
if (!exports) continue;
|
||||
|
||||
try {
|
||||
if (predicate(exports)) return exports;
|
||||
if (exports.default != null && predicate(exports.default)) return exports.default;
|
||||
} catch {
|
||||
// A predicate throwing on one module's shape shouldn't stop the scan.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function rawFindAll<T = any>(predicate: (exports: any) => boolean): T[] {
|
||||
const modules = window?.modules;
|
||||
if (!modules) return [];
|
||||
|
||||
const results: T[] = [];
|
||||
const seen = new Set<any>();
|
||||
|
||||
for (const id in modules) {
|
||||
const def = modules[id];
|
||||
if (!def?.isInitialized) continue;
|
||||
|
||||
const exports = def.publicModule?.exports;
|
||||
if (!exports) continue;
|
||||
|
||||
try {
|
||||
if (predicate(exports) && !seen.has(exports)) {
|
||||
results.push(exports);
|
||||
seen.add(exports);
|
||||
}
|
||||
const dflt = exports.default;
|
||||
if (dflt != null && predicate(dflt) && !seen.has(dflt)) {
|
||||
results.push(dflt);
|
||||
seen.add(dflt);
|
||||
}
|
||||
} catch {
|
||||
// Ignore predicate errors and continue scanning.
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async batched scan: does the same as rawFindAll but yields between batches so the UI thread
|
||||
* isn't blocked for long. Returns a Promise of the matching exports array.
|
||||
*/
|
||||
export async function rawFindAllAsync<T = any>(
|
||||
predicate: (exports: any) => boolean,
|
||||
opts?: { batchSize?: number; pauseMs?: number; maxProcessed?: number },
|
||||
): Promise<T[]> {
|
||||
const modules = window?.modules;
|
||||
if (!modules) return [];
|
||||
|
||||
const keys = Object.keys(modules);
|
||||
const batchSize = opts?.batchSize ?? 200;
|
||||
const pauseMs = opts?.pauseMs ?? 0; // 0 uses setTimeout(...,0) to yield
|
||||
const maxProcessed = opts?.maxProcessed ?? Infinity;
|
||||
|
||||
const results: T[] = [];
|
||||
const seen = new Set<any>();
|
||||
let processed = 0;
|
||||
|
||||
for (let i = 0; i < keys.length; i += batchSize) {
|
||||
const end = Math.min(i + batchSize, keys.length);
|
||||
for (let j = i; j < end; j++) {
|
||||
if (processed++ >= maxProcessed) return results;
|
||||
const def = modules[keys[j]];
|
||||
if (!def?.isInitialized) continue;
|
||||
const exports = def.publicModule?.exports;
|
||||
if (!exports) continue;
|
||||
try {
|
||||
if (predicate(exports) && !seen.has(exports)) {
|
||||
results.push(exports as any);
|
||||
seen.add(exports);
|
||||
}
|
||||
const dflt = exports.default;
|
||||
if (dflt != null && predicate(dflt) && !seen.has(dflt)) {
|
||||
results.push(dflt as any);
|
||||
seen.add(dflt);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// yield to event loop so UI can update
|
||||
if (pauseMs === 0) {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
} else if (pauseMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, pauseMs));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function rawFindByTypeName<T = any>(name: string): T | undefined {
|
||||
return rawFind<T>((m) => m?.name === name || m?.displayName === name || m?.type?.name === name || m?.type?.displayName === name);
|
||||
}
|
||||
|
||||
export function rawFindByName<T = any>(name: string): T | undefined {
|
||||
return rawFind<T>((m) => m?.name === name || m?.displayName === name);
|
||||
}
|
||||
|
||||
export function rawFindByProps<T = any>(...props: string[]): T | undefined {
|
||||
return rawFind<T>((m) => props.every((p) => m?.[p] !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as rawFindByProps, but additionally requires every named property to be a function - not
|
||||
* just "defined." Confirmed live (Key Inspector's Eval console, direct window.modules scan) that
|
||||
* plain rawFindByProps can and does match a completely unrelated module whose properties happen to
|
||||
* share the same names as the real hook/utility functions being searched for, but hold non-function
|
||||
* values (e.g. a type-shape/declaration-style object) - and since window.modules iterates in
|
||||
* ascending numeric id order, a lower-id decoy module reliably wins over the real, higher-id
|
||||
* implementation *every single time*, not occasionally. This was confirmed to be the actual cause
|
||||
* of the Quest Dock hijack's unreliability: rawFindByProps("useMobileQuestDock") was silently
|
||||
* patching an object-shaped decoy at a lower module id instead of the real hook. Use this whenever
|
||||
* what's being searched for is specifically a function (hooks, utility functions), not just any
|
||||
* export with that name.
|
||||
*/
|
||||
export function rawFindByFunctionProps<T = any>(...props: string[]): T | undefined {
|
||||
return rawFind<T>((m) => props.every((p) => typeof m?.[p] === "function"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same idea as rawFindByFunctionProps, but for properties whose real value is an object/enum, not
|
||||
* a function (e.g. ReadStateTypes, HapticFeedbackTypes) - "is it a function" can't discriminate
|
||||
* those from the same decoy module rawFindByFunctionProps was built for, since the decoy's version
|
||||
* is *also* an object. Confirmed live that the decoy's enum-shaped properties don't hold the real
|
||||
* values (e.g. its ReadStateTypes/UnreadSetting come back null/empty on direct access, despite
|
||||
* matching a plain "property exists" check) - so this instead requires each named property to pass
|
||||
* its own validator against a value only the real implementation would actually have, e.g.:
|
||||
* rawFindByValidProps({ ReadStateTypes: (v) => v?.CHANNEL !== undefined }).
|
||||
*/
|
||||
export function rawFindByValidProps<T = any>(validators: Record<string, (value: any) => boolean>): T | undefined {
|
||||
const props = Object.keys(validators);
|
||||
return rawFind<T>((m) => props.every((p) => {
|
||||
try {
|
||||
return validators[p](m?.[p]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Matches Revenge's own byStoreName filter (metro/filters.ts): a zero-arg getName() returning
|
||||
// the store's name.
|
||||
export function rawFindByStoreName<T = any>(name: string): T | undefined {
|
||||
return rawFind<T>((m) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === name);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
export interface WaitForHandle {
|
||||
/** Stops polling. Safe to call even after `find` has already resolved. */
|
||||
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.
|
||||
*/
|
||||
export function waitFor<T>(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const immediate = find();
|
||||
if (immediate) {
|
||||
onFound(immediate);
|
||||
return { cancel() {} };
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
const value = find();
|
||||
if (value) {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
onFound(value);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { rawFindByStoreName } from "../lib/rawFind";
|
||||
|
||||
// Adapted from fres621's BetterFolders (autoCollapse.ts) - ported as a ServerDrawer feature
|
||||
// instead of a standalone plugin because BetterFolders' other half (hideIcons) patches a
|
||||
// "GuildContainer" component that no longer exists in current Discord builds at all, and its
|
||||
// icon-collapsing behavior only made sense for the native server rail, which ServerDrawer already
|
||||
// replaces. This half doesn't touch that component - it only reacts to the same folder-expand
|
||||
// store/event ServerDrawer's own FolderItem already uses, so it works standalone here.
|
||||
export function patchAutoCollapseFolders(cleanups: (() => void)[]): boolean {
|
||||
const ExpandedGuildFolderStore = rawFindByStoreName("ExpandedGuildFolderStore");
|
||||
if (!ExpandedGuildFolderStore?.getExpandedFolders) return false;
|
||||
|
||||
const listener = ({ folderId }: { folderId?: string | number }) => {
|
||||
if (!storage.autoCollapseFolders || folderId == null) return;
|
||||
|
||||
const expanded = ExpandedGuildFolderStore.getExpandedFolders();
|
||||
if (!(expanded instanceof Set) || !expanded.has(folderId)) return;
|
||||
|
||||
for (const id of expanded) {
|
||||
if (id !== folderId) {
|
||||
FluxDispatcher.dispatch({ type: "TOGGLE_GUILD_FOLDER_EXPAND", folderId: id });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
FluxDispatcher.subscribe("TOGGLE_GUILD_FOLDER_EXPAND", listener);
|
||||
cleanups.push(() => FluxDispatcher.unsubscribe("TOGGLE_GUILD_FOLDER_EXPAND", listener));
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,136 +1,59 @@
|
|||
import { find } from "@vendetta/metro";
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import { rawFind } from "../lib/rawFind";
|
||||
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
||||
import { registerIntercept, registerPropsTransform, registerTypeDetector } from "../lib/createElementIntercept";
|
||||
import { registerIntercept } from "./createElementIntercept";
|
||||
|
||||
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;
|
||||
function getGestureContext(): any {
|
||||
if (!cachedGestureContext) {
|
||||
cachedGestureContext = rawFind((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null;
|
||||
cachedGestureContext = find((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null;
|
||||
}
|
||||
return cachedGestureContext;
|
||||
}
|
||||
|
||||
// Memo-wrapped components have no own .name/.displayName - only their nested .type does.
|
||||
function isNamed(name: string) {
|
||||
return (type: any) => type?.name === name || type?.displayName === name ||
|
||||
type?.type?.name === name || type?.type?.displayName === name;
|
||||
}
|
||||
|
||||
const QUEST_DOCK_NAMES = [
|
||||
"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));
|
||||
export function patchExpanded(
|
||||
cleanups: (() => void)[]
|
||||
): boolean {
|
||||
const mod = find((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded");
|
||||
if (!mod?.type) {
|
||||
console.log(TAG, "WARN: QuestDockContentExpanded module not found yet (will retry)");
|
||||
console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)");
|
||||
return false;
|
||||
}
|
||||
|
||||
const orig = mod.type;
|
||||
registerIntercept(orig, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID });
|
||||
mod.type = ServerDrawerSheetWrapper;
|
||||
|
||||
registerIntercept(orig, ServerDrawerSheet, { gestureContext: getGestureContext() });
|
||||
|
||||
mod.type = function ServerDrawerPatch() {
|
||||
return <ServerDrawerSheet gestureContext={getGestureContext()} />;
|
||||
};
|
||||
cleanups.push(() => { mod.type = orig; });
|
||||
console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)");
|
||||
console.log(TAG, "PATCH: QuestDockContentExpanded replaced");
|
||||
return true;
|
||||
}
|
||||
|
||||
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));
|
||||
export function patchEmpty(
|
||||
name: string,
|
||||
cleanups: (() => void)[]
|
||||
): boolean {
|
||||
const mod = find((m) => m?.type?.displayName === name || m?.type?.name === name);
|
||||
if (!mod?.type) {
|
||||
console.log(TAG, `WARN: ${name} module not found yet (will retry)`);
|
||||
console.log(TAG, `WARN: ${name} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const orig = mod.type;
|
||||
function EmptyPatch() {
|
||||
const originalComponent = orig;
|
||||
|
||||
mod.type = function EmptyPatch() {
|
||||
return null;
|
||||
}
|
||||
mod.type = EmptyPatch;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(orig));
|
||||
} catch {
|
||||
// Best-effort compatibility shim, not something correctness depends on.
|
||||
if (originalComponent) {
|
||||
Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(originalComponent));
|
||||
mod.type.displayName = name;
|
||||
}
|
||||
mod.type.displayName = name;
|
||||
|
||||
cleanups.push(() => { mod.type = orig; });
|
||||
console.log(TAG, `PATCH: ${name} replaced (module mutation)`);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
56
plugins/server-drawer/src/patches/createElementIntercept.ts
Normal file
56
plugins/server-drawer/src/patches/createElementIntercept.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
|
||||
interface Intercept {
|
||||
replacement: React.ComponentType<any>;
|
||||
extraProps?: Record<string, any>;
|
||||
}
|
||||
|
||||
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||
let origCreateElement: typeof React.createElement | null = null;
|
||||
let isPatched = false;
|
||||
|
||||
export function registerIntercept(
|
||||
original: React.ComponentType<any>,
|
||||
replacement: React.ComponentType<any>,
|
||||
extraProps?: Record<string, any>,
|
||||
) {
|
||||
intercepts.set(original, { replacement, extraProps });
|
||||
}
|
||||
|
||||
export function patchCreateElement(cleanups: (() => void)[]) {
|
||||
if (isPatched) return;
|
||||
|
||||
// Guard against React.createElement being null in some environments
|
||||
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) {
|
||||
// Fallback to original createElement if somehow null (should not happen)
|
||||
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;
|
||||
|
||||
cleanups.push(() => {
|
||||
if (isPatched && origCreateElement) {
|
||||
React.createElement = origCreateElement;
|
||||
isPatched = false;
|
||||
}
|
||||
intercepts.clear();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind";
|
||||
|
||||
// Confirmed live: rawFindByFunctionProps never found any of these four on some accounts, across
|
||||
// multiple reload and account-switch cycles spanning over a minute - not a timing race, since
|
||||
// rawFind only scans modules Metro has *already* naturally initialized and never triggers one
|
||||
// itself. On an account where nothing in the normal boot path happens to touch this specific
|
||||
// module, passive scanning can retry forever and never find it. Revenge's own findByProps forces
|
||||
// a module to initialize if it isn't yet, which is exactly what's needed here - confirmed live via
|
||||
// direct eval that it finds this exact module instantly on an account where rawFind never did.
|
||||
// Tried first since it's free and carries no caching risk; findByProps is the fallback precisely
|
||||
// because a failed call there is cached negatively forever, so it's only worth risking once
|
||||
// rawFind has already had a real chance to succeed on its own.
|
||||
function findHookModule(...props: string[]): any {
|
||||
return rawFindByFunctionProps<any>(...props) ?? findByProps(...props);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
function buildFallbackQuest(userId: string | undefined) {
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const farFuture = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 365 * 5).toISOString();
|
||||
|
||||
return {
|
||||
id: "server-drawer",
|
||||
preview: false,
|
||||
config: {
|
||||
id: "server-drawer",
|
||||
configVersion: 2,
|
||||
startsAt: nowIso,
|
||||
expiresAt: farFuture,
|
||||
features: [],
|
||||
assets: {
|
||||
hero: FALLBACK_ASSET_URL, heroVideo: "", questBarHero: FALLBACK_ASSET_URL, questBarHeroBlurhash: "",
|
||||
questBarHeroVideo: "", gameTile: FALLBACK_ASSET_URL, logotype: FALLBACK_ASSET_URL,
|
||||
gameTileLight: FALLBACK_ASSET_URL, gameTileDark: FALLBACK_ASSET_URL,
|
||||
logotypeLight: FALLBACK_ASSET_URL, logotypeDark: FALLBACK_ASSET_URL,
|
||||
},
|
||||
colors: { primary: "#5865F2", secondary: "#000000" },
|
||||
messages: {
|
||||
questName: "Server Drawer",
|
||||
gameTitle: "All quests complete!",
|
||||
gamePublisher: "ServerDrawer plugin",
|
||||
},
|
||||
taskConfigV2: {
|
||||
tasks: {
|
||||
ACHIEVEMENT_IN_ACTIVITY: {
|
||||
type: "ACHIEVEMENT_IN_ACTIVITY",
|
||||
target: 1,
|
||||
eventName: "progress",
|
||||
messages: {
|
||||
taskTitle: "Server Drawer is active",
|
||||
taskDescription: "You've got no active Discord quests right now - enjoy the drawer!",
|
||||
},
|
||||
applications: [{ id: "0" }],
|
||||
},
|
||||
},
|
||||
joinOperator: "or",
|
||||
},
|
||||
rewardsConfig: {
|
||||
assignmentMethod: 1,
|
||||
rewards: [{
|
||||
type: 4,
|
||||
skuId: "0",
|
||||
messages: {
|
||||
redemptionInstructionsByPlatform: {},
|
||||
name: "Nothing - just vibes",
|
||||
nameWithArticle: "Nothing - just vibes",
|
||||
},
|
||||
orbQuantity: 0,
|
||||
premiumOrbQuantity: 0,
|
||||
}],
|
||||
rewardsExpireAt: farFuture,
|
||||
platforms: [0],
|
||||
},
|
||||
cosponsorMetadata: undefined,
|
||||
sharePolicy: "shareable_everywhere",
|
||||
ctaConfig: { android: undefined, ios: undefined, link: "", buttonLabel: "", subtitle: "" },
|
||||
},
|
||||
userStatus: {
|
||||
userId: userId ?? null,
|
||||
questId: "server-drawer",
|
||||
enrolledAt: nowIso,
|
||||
completedAt: null,
|
||||
claimedAt: null,
|
||||
claimedTier: null,
|
||||
orbQuantityClaimed: 0,
|
||||
lastStreamHeartbeatAt: null,
|
||||
streamProgressSeconds: 0,
|
||||
dismissedQuestContent: 0,
|
||||
progress: {},
|
||||
},
|
||||
targetedContent: [],
|
||||
trafficMetadataSealed: "",
|
||||
};
|
||||
}
|
||||
|
||||
// Each of these four does its own completely independent rawFind lookup rather than sharing one
|
||||
// resolved module reference across all of them - confirmed live (GuildsBar's own patch writeup)
|
||||
// that a metro search can land on a different module copy than another search for a related
|
||||
// property, even when both "succeed", so patching useMobileQuestDock and
|
||||
// useIsMobileQuestDockRendered off the same found object was never guaranteed to be the same
|
||||
// reference the real render path actually reads from. Matches the original plugin's four separate
|
||||
// files (questDockRender.ts, questDockBase.ts, mobileQuestDock.ts, getQuestAsset.ts) in spirit,
|
||||
// just retry-safe.
|
||||
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
|
||||
const mod = findHookModule("useMobileQuestDock");
|
||||
if (!mod?.useMobileQuestDock) {
|
||||
console.log(TAG, "WARN: useMobileQuestDock not found yet (will retry)");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.useMobileQuestDock;
|
||||
let cachedUserId: string | undefined;
|
||||
mod.useMobileQuestDock = function (this: unknown, ...args: any[]) {
|
||||
const real = orig.apply(this, args);
|
||||
if (real) return real;
|
||||
if (cachedUserId === undefined) {
|
||||
cachedUserId = rawFindByStoreName<any>("UserStore")?.getCurrentUser?.()?.id;
|
||||
}
|
||||
return buildFallbackQuest(cachedUserId);
|
||||
};
|
||||
cleanups.push(() => { mod.useMobileQuestDock = orig; });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
|
||||
const mod = findHookModule("useIsMobileQuestDockRendered");
|
||||
if (!mod?.useIsMobileQuestDockRendered) {
|
||||
console.log(TAG, "WARN: useIsMobileQuestDockRendered not found yet (will retry)");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.useIsMobileQuestDockRendered;
|
||||
mod.useIsMobileQuestDockRendered = function (this: unknown, ...args: any[]) {
|
||||
orig.apply(this, args);
|
||||
return true;
|
||||
};
|
||||
cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
|
||||
const mod = findHookModule("useIsMobileQuestDockRenderedBase");
|
||||
if (!mod?.useIsMobileQuestDockRenderedBase) {
|
||||
console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found yet (will retry)");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.useIsMobileQuestDockRenderedBase;
|
||||
mod.useIsMobileQuestDockRenderedBase = function (this: unknown, ...args: any[]) {
|
||||
orig.apply(this, args);
|
||||
return true;
|
||||
};
|
||||
cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function patchGetQuestAsset(cleanups: (() => void)[]): boolean {
|
||||
const mod = findHookModule("getQuestAsset");
|
||||
if (!mod?.getQuestAsset) {
|
||||
console.log(TAG, "WARN: getQuestAsset not found yet (will retry)");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.getQuestAsset;
|
||||
mod.getQuestAsset = function (this: unknown, ...args: any[]) {
|
||||
try {
|
||||
return orig.apply(this, args);
|
||||
} catch {
|
||||
return { url: null, isAnimated: false };
|
||||
}
|
||||
};
|
||||
cleanups.push(() => { mod.getQuestAsset = orig; });
|
||||
return true;
|
||||
}
|
||||
21
plugins/server-drawer/src/patches/getQuestAsset.ts
Normal file
21
plugins/server-drawer/src/patches/getQuestAsset.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
export function patchGetQuestAsset(cleanups: (() => void)[]): boolean {
|
||||
const mod = findByProps("getQuestAsset");
|
||||
if (!mod?.getQuestAsset) {
|
||||
console.log(TAG, "WARN: getQuestAsset not found");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.getQuestAsset;
|
||||
mod.getQuestAsset = function (...args: any[]) {
|
||||
try {
|
||||
return orig.apply(this, args);
|
||||
} catch {
|
||||
return { url: null, isAnimated: false };
|
||||
}
|
||||
};
|
||||
cleanups.push(() => { mod.getQuestAsset = orig; });
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,86 +1,94 @@
|
|||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { registerPropsTransform, registerTypeDetector, registerIntercept } from "../lib/createElementIntercept";
|
||||
import { captureFiberRef } from "../lib/fiberCapture";
|
||||
import { Pressable, Image, View } from "react-native";
|
||||
import { find, findByName, findByProps } from "@vendetta/metro";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { registerIntercept } from "./createElementIntercept";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
const SD_NOTHING_TEST_ID = "ServerDrawerNothing";
|
||||
|
||||
function Nothing() {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
const ChatIcon = getAssetIDByName("ChatIcon");
|
||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
||||
const ChannelActions = findByProps("selectPrivateChannel");
|
||||
const SelectedChannelStore = findByName("SelectedChannelStore");
|
||||
const FluxStores = findByProps("useStateFromStores");
|
||||
const useStateFromStores = FluxStores?.useStateFromStores;
|
||||
const GuildStore = findByProps("getGuildId");
|
||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
||||
const Routes = findByProps("ME");
|
||||
const ME = Routes?.ME ?? "/channels/@me";
|
||||
|
||||
// Confirmed live (Key Inspector's fiber capture): the parent creates GuildsBar via its OUTER
|
||||
// React.memo wrapper object, which has no own .name/.displayName at all - those only exist on the
|
||||
// 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";
|
||||
}
|
||||
|
||||
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;
|
||||
function openDms() {
|
||||
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
|
||||
if (ChannelActions?.selectPrivateChannel) {
|
||||
const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.();
|
||||
ChannelActions.selectPrivateChannel(lastChannelId);
|
||||
}
|
||||
for (const child of rest) if (inspect(child)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function useIsInDms(): boolean {
|
||||
try {
|
||||
return GuildStore ? useStateFromStores([GuildStore], () => {
|
||||
const guildId = GuildStore?.getGuildId?.();
|
||||
return guildId == null || guildId === ME;
|
||||
}) : false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function DmsGuildIcon() {
|
||||
const selected = useIsInDms();
|
||||
|
||||
const iconColor = selected
|
||||
? (colors?.WHITE ?? "#fff")
|
||||
: (colors?.INTERACTIVE_NORMAL ?? "#80848e");
|
||||
|
||||
return (
|
||||
<Pressable onPress={openDms} style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
||||
{selected ? (
|
||||
<View style={{
|
||||
position: "absolute", left: 0, top: "25%",
|
||||
width: 4, height: "50%", borderRadius: 2,
|
||||
backgroundColor: colors?.WHITE ?? "#fff",
|
||||
}} />
|
||||
) : null}
|
||||
<Image
|
||||
source={ChatIcon}
|
||||
style={{ width: 28, height: 28, tintColor: iconColor }}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function findGuildsBar(): any {
|
||||
const byName = findByName("GuildsBar");
|
||||
if (byName) return { default: byName };
|
||||
|
||||
let mod = find((m) => {
|
||||
try { return m?.default?.type?.name === "GuildsBar"; } catch { return false; }
|
||||
});
|
||||
if (mod?.default) return mod;
|
||||
|
||||
mod = find((m) => {
|
||||
try { return m?.default?.displayName === "GuildsBar"; } catch { return false; }
|
||||
});
|
||||
if (mod?.default) return mod;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function patchHideGuildsBar(cleanups: (() => void)[]): boolean {
|
||||
// Hard-enforce the GuildsBar parent's visibility by detecting the hidden Nothing child and
|
||||
// zeroing the rail wrapper itself. Confirmed live that the immediate parent carries an
|
||||
// explicit width: 72 in its own style, so display: none alone left the space reserved -
|
||||
// zeroing width/flex directly, the same way collapseAncestors does further up, actually
|
||||
// reclaims it.
|
||||
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",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const mod = findGuildsBar();
|
||||
if (!mod?.default) {
|
||||
console.log(TAG, "WARN: GuildsBar not found");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.default;
|
||||
|
||||
registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => {
|
||||
registerIntercept(realGuildsBar, Nothing, { testID: SD_NOTHING_TEST_ID }, { collapseAncestors: 6 });
|
||||
console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing");
|
||||
}, { persistent: true });
|
||||
cleanups.push(() => {
|
||||
// No per-call unregister needed - createElementIntercept clears all transforms/intercepts
|
||||
// together when its own patch unwinds.
|
||||
});
|
||||
console.log(TAG, "PATCH: watching for the real GuildsBar to appear");
|
||||
registerIntercept(orig, DmsGuildIcon);
|
||||
|
||||
mod.default = function DmsGuildsBar() {
|
||||
return React.createElement(DmsGuildIcon);
|
||||
};
|
||||
mod.default.displayName = "GuildsBar";
|
||||
cleanups.push(() => { mod.default = orig; });
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
30
plugins/server-drawer/src/patches/mobileQuestDock.ts
Normal file
30
plugins/server-drawer/src/patches/mobileQuestDock.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
|
||||
const mod = findByProps("useMobileQuestDock");
|
||||
if (!mod.useMobileQuestDock) {
|
||||
console.log(TAG, "WARN: useMobileQuestDock not found");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.useMobileQuestDock;
|
||||
mod.useMobileQuestDock = function (...args: any[]) {
|
||||
const real = orig.apply(this, args);
|
||||
if (real) return real;
|
||||
return {
|
||||
id: "server-drawer",
|
||||
config: {
|
||||
quest_content_type: 0,
|
||||
assets: { questBarHeroVideo: null, questBarHero: null },
|
||||
features: [],
|
||||
},
|
||||
userStatus: { enrolledAt: "2099-01-01", claimedAt: null },
|
||||
benefits: { rewards: [] },
|
||||
guildId: "0",
|
||||
tasks: [],
|
||||
};
|
||||
};
|
||||
cleanups.push(() => { mod.useMobileQuestDock = orig; });
|
||||
return true;
|
||||
}
|
||||
18
plugins/server-drawer/src/patches/questDockBase.ts
Normal file
18
plugins/server-drawer/src/patches/questDockBase.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
|
||||
const mod = findByProps("useIsMobileQuestDockRenderedBase");
|
||||
if (!mod?.useIsMobileQuestDockRenderedBase) {
|
||||
console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.useIsMobileQuestDockRenderedBase;
|
||||
mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) {
|
||||
orig.apply(this, args);
|
||||
return true;
|
||||
};
|
||||
cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; });
|
||||
return true;
|
||||
}
|
||||
18
plugins/server-drawer/src/patches/questDockRender.ts
Normal file
18
plugins/server-drawer/src/patches/questDockRender.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
|
||||
const mod = findByProps("useIsMobileQuestDockRendered");
|
||||
if (!mod?.useIsMobileQuestDockRendered) {
|
||||
console.log(TAG, "WARN: useIsMobileQuestDockRendered not found");
|
||||
return false;
|
||||
}
|
||||
const orig = mod.useIsMobileQuestDockRendered;
|
||||
mod.useIsMobileQuestDockRendered = function (...args: any[]) {
|
||||
orig.apply(this, args);
|
||||
return true;
|
||||
};
|
||||
cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; });
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
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, illegible on Discord's
|
||||
* dark theme (the default for most users).
|
||||
*/
|
||||
export default function NoteBox({ children, style }: { children: any; style?: any }) {
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT", "TEXT_MUTED"], "#96989d");
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(128,128,128,0.25)"
|
||||
},
|
||||
style
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: 12.5, lineHeight: 18, opacity: 0.75, color: textColor }}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { TableRowGroup, TableSwitchRow } from "@shared/ui/table";
|
||||
import SettingsScaffold from "./SettingsScaffold";
|
||||
import NoteBox from "./NoteBox";
|
||||
import { restart } from "../index";
|
||||
|
||||
|
||||
export default function Settings() {
|
||||
useProxy(storage);
|
||||
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
Server Drawer takes over the Quest Dock (the strip above the tab bar) to show your
|
||||
servers as a grid instead - tap to expand, long-press a server for its usual context
|
||||
menu, the chat bubble jumps to DMs, and the + button creates or joins a server
|
||||
through Discord's own modal.
|
||||
</NoteBox>
|
||||
<TableRowGroup title="Layout">
|
||||
<TableSwitchRow
|
||||
label="Hide the left server rail"
|
||||
subLabel="Your servers and DMs already live in the drawer, so the rail is redundant"
|
||||
value={storage.hideGuildsBar !== false}
|
||||
onValueChange={(v: boolean) => {
|
||||
storage.hideGuildsBar = v;
|
||||
restart();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Show server names"
|
||||
subLabel="Word-wrapped name below each server's icon"
|
||||
value={storage.showGuildNames !== false}
|
||||
onValueChange={(v: boolean) => { storage.showGuildNames = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Hide the DMs tile"
|
||||
subLabel="Remove the DMs tile from the drawer entirely"
|
||||
value={!!storage.hideDmTile}
|
||||
onValueChange={(v: boolean) => { storage.hideDmTile = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Show drawer with no active quest"
|
||||
subLabel="Discord only mounts the Quest Dock (and this drawer along with it) when a real quest is active - this feeds it a placeholder so the drawer still shows up otherwise"
|
||||
value={storage.fakeQuestDock !== false}
|
||||
onValueChange={(v: boolean) => {
|
||||
storage.fakeQuestDock = v;
|
||||
restart();
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Folders">
|
||||
<TableSwitchRow
|
||||
label="Auto-collapse other folders"
|
||||
subLabel="Opening a folder collapses any other folder that was already open"
|
||||
value={!!storage.autoCollapseFolders}
|
||||
onValueChange={(v: boolean) => { storage.autoCollapseFolders = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Hide icons in collapsed folders"
|
||||
subLabel="Show a plain folder icon instead of a 4-server preview"
|
||||
value={!!storage.hideFolderIcons}
|
||||
onValueChange={(v: boolean) => { storage.hideFolderIcons = v; }}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
<NoteBox>
|
||||
The folder options above are ported from fres621's BetterFolders - its other half
|
||||
(hiding icons in the native left server rail) patched a component that no longer
|
||||
exists in current Discord builds, so it's been rebuilt here instead, where it
|
||||
actually applies to what you see.
|
||||
</NoteBox>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
|
||||
const { ScrollView, View } = ReactNative;
|
||||
|
||||
/** Consistent wrapper for the settings screen: scroll view + bottom breathing room. */
|
||||
export default function SettingsScaffold({ children }: { children: any }) {
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{children}
|
||||
<View style={{ height: 24 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +1,19 @@
|
|||
import { lazy } from "../lib/lazy";
|
||||
import { rawFindByStoreName } from "../lib/rawFind";
|
||||
import { getFlux, getColorModule } from "../lib/commonModules";
|
||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
|
||||
const getExpandedGuildFolderStore = lazy(() => rawFindByStoreName("ExpandedGuildFolderStore"));
|
||||
const Flux = findByProps("useStateFromStores");
|
||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
||||
const ExpandedGuildFolderStore = findByStoreName("ExpandedGuildFolderStore");
|
||||
|
||||
export function useTheme() {
|
||||
const colors = getColorModule()?.colors;
|
||||
return {
|
||||
text: colors?.TEXT_NORMAL ?? colors?.TEXT_DEFAULT ?? "#dbdee1",
|
||||
text: colors?.TEXT_NORMAL ?? "#dbdee1",
|
||||
folder: "#5865f2",
|
||||
hover: colors?.STATE_LAYER_PRESS ?? "rgba(255,255,255,0.06)",
|
||||
};
|
||||
}
|
||||
|
||||
export function useFolderExpanded(folderId: string | number): boolean {
|
||||
const Flux = getFlux();
|
||||
const ExpandedGuildFolderStore = getExpandedGuildFolderStore();
|
||||
// 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).
|
||||
if (!Flux?.useStateFromStores || !ExpandedGuildFolderStore) return false;
|
||||
return Flux.useStateFromStores(
|
||||
return Flux?.useStateFromStores?.(
|
||||
[ExpandedGuildFolderStore],
|
||||
() => {
|
||||
const folders = ExpandedGuildFolderStore?.getExpandedFolders?.();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue