diff --git a/plugins/ServerDrawer/manifest.json b/plugins/ServerDrawer/manifest.json index aea7135..32e11e7 100644 --- a/plugins/ServerDrawer/manifest.json +++ b/plugins/ServerDrawer/manifest.json @@ -1,10 +1,13 @@ { "name": "Server Drawer", - "description": "Server Drawer above the YouBar, manage your servers differently!", + "description": "Server Drawer above the YouBar, manage your servers differently! Now fully hides the Quest Dock/GuildsBar, adds a DM tile, optional server-name labels, and a settings screen.", "authors": [ { "name": "kmmiio99o", "id": "879393496627306587" + }, + { + "name": "Rosie" } ], "main": "src/index.tsx", diff --git a/plugins/ServerDrawer/src/components/DmTile.tsx b/plugins/ServerDrawer/src/components/DmTile.tsx new file mode 100644 index 0000000..8fd97f4 --- /dev/null +++ b/plugins/ServerDrawer/src/components/DmTile.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { View, Pressable, Image, StyleSheet } from "react-native"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { findByProps, findByName } from "@vendetta/metro"; + +const ICON = 48; + +const ChatIcon = getAssetIDByName("ChatIcon"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const ChannelActions = findByProps("selectPrivateChannel"); +const SelectedChannelStore = findByName("SelectedChannelStore"); +const Flux = findByProps("useStateFromStores"); +const NavContext = findByProps("getGuildId"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +function openDms() { + Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT); + if (ChannelActions?.selectPrivateChannel) { + const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.(); + ChannelActions.selectPrivateChannel(lastChannelId); + } +} + +// Moved into the drawer's own grid as the first tile, since this plugin otherwise hides the +// native rail DMs used to live on. +export default function DmTile() { + const selected = Flux?.useStateFromStores?.( + [NavContext], + () => NavContext?.getGuildId?.() == null, + ) ?? false; + + return ( + + + + + + ); +} + +const st = StyleSheet.create({ + outer: { width: ICON, height: ICON }, + icon: { width: ICON, height: ICON, borderRadius: 16, alignItems: "center", justifyContent: "center" }, +}); diff --git a/plugins/ServerDrawer/src/components/FolderItem.tsx b/plugins/ServerDrawer/src/components/FolderItem.tsx index 0076189..76088df 100644 --- a/plugins/ServerDrawer/src/components/FolderItem.tsx +++ b/plugins/ServerDrawer/src/components/FolderItem.tsx @@ -82,7 +82,7 @@ function FadeIn({ children }: { children: React.ReactNode }) { return {children}; } -export default function FolderItem({ node, onPick }: { node: GuildNode; onPick: (id: string) => void }) { +export default function FolderItem({ node, onPick, showNames }: { node: GuildNode; onPick: (id: string) => void; showNames?: boolean }) { const open = useFolderExpanded(node.id); const toggle = () => { @@ -99,7 +99,7 @@ export default function FolderItem({ node, onPick }: { node: GuildNode; onPick: {node.children.map((ch) => ( - + ))} diff --git a/plugins/ServerDrawer/src/components/GuildItem.tsx b/plugins/ServerDrawer/src/components/GuildItem.tsx index a00ffb8..d3fa502 100644 --- a/plugins/ServerDrawer/src/components/GuildItem.tsx +++ b/plugins/ServerDrawer/src/components/GuildItem.tsx @@ -12,6 +12,7 @@ const GuildReadStateStore = findByStoreName("GuildReadStateStore"); const GuildStore = findByStoreName("GuildStore"); const SortedGuildStore = findByStoreName("SortedGuildStore"); const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; function Badge({ guildId }: { guildId: string }) { const mentionCount = Flux?.useStateFromStores?.( @@ -47,7 +48,7 @@ function Badge({ guildId }: { guildId: string }) { return null; } -export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (id: string) => void }) { +export default function GuildItem({ node, onPick, showNames }: { node: GuildNode; onPick: (id: string) => void; showNames?: boolean }) { const viewRef = React.useRef(null); const scale = React.useRef(new Animated.Value(1)).current; const scaleDown = () => Animated.spring(scale, { toValue: 0.85, useNativeDriver: true }).start(); @@ -102,6 +103,12 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: ( const guildId = node.id as string; + const name = Flux?.useStateFromStores?.( + [GuildStore], + () => GuildStore?.getGuild?.(guildId)?.name ?? "", + [guildId], + ) ?? ""; + return ( <> - - - - - + + + + + + + + {showNames && ( + + {name} + + )} { Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT); if (Routing?.transitionToGuild) { @@ -110,10 +115,11 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext: style={[st.grid, { paddingHorizontal: padX, gap: GAP }]} onLayout={onLayout} > + {!storage.hideDmTile && } {nodes.map((node) => node.type === "folder" - ? - : + ? + : )} diff --git a/plugins/ServerDrawer/src/index.tsx b/plugins/ServerDrawer/src/index.tsx index d88cde9..27445e3 100644 --- a/plugins/ServerDrawer/src/index.tsx +++ b/plugins/ServerDrawer/src/index.tsx @@ -4,13 +4,19 @@ import { patchMobileQuestDock } from "./patches/mobileQuestDock"; import { patchGetQuestAsset } from "./patches/getQuestAsset"; import { patchExpanded, patchEmpty } from "./patches/contentPatch"; import { patchHideGuildsBar } from "./patches/hideGuildsBar"; +import { patchTransparentBackground } from "./patches/transparentBackground"; import { patchCreateElement } from "./patches/createElementIntercept"; +import { storage } from "@vendetta/plugin"; +import Settings from "./ui/Settings"; const TAG = "[ServerDrawer]"; const cleanups: (() => void)[] = []; export default { onLoad() { + storage.hideDmTile ??= false; + storage.showGuildNames ??= false; + console.log(TAG, "onLoad"); let patched = 0; @@ -29,6 +35,7 @@ export default { if (patchEmpty("QuestDockEnrolledBody", cleanups)) patched++; if (patchEmpty("QuestDockUnenrolledBody", cleanups)) patched++; if (patchHideGuildsBar(cleanups)) patched++; + if (patchTransparentBackground(cleanups)) patched++; console.log(TAG, `onLoad done — ${patched} patches applied, ${cleanups.length} cleanups`); }, @@ -37,4 +44,5 @@ export default { for (const fn of cleanups) fn(); cleanups.length = 0; }, + settings: Settings, }; diff --git a/plugins/ServerDrawer/src/patches/contentPatch.tsx b/plugins/ServerDrawer/src/patches/contentPatch.tsx index 735bcad..13c0ec8 100644 --- a/plugins/ServerDrawer/src/patches/contentPatch.tsx +++ b/plugins/ServerDrawer/src/patches/contentPatch.tsx @@ -1,7 +1,7 @@ import { find } from "@vendetta/metro"; import { React } from "@vendetta/metro/common"; import ServerDrawerSheet from "../components/ServerDrawerSheet"; -import { registerIntercept } from "./createElementIntercept"; +import { registerIntercept, registerTypeDetector } from "./createElementIntercept"; const TAG = "[ServerDrawer]"; @@ -13,13 +13,26 @@ function getGestureContext(): any { return cachedGestureContext; } +function isNamed(name: string) { + return (type: any) => type?.name === name || type?.displayName === name || + type?.type?.name === name || type?.type?.displayName === name; +} + +// find()'s module-registry search can land on a different copy of QuestDockContentExpanded than +// the one actually mounted - registerTypeDetector instead inspects the exact `type` value passed +// to createElement/jsx as it's used, so there's no "which copy" ambiguity. export function patchExpanded( cleanups: (() => void)[] ): boolean { + registerTypeDetector("ServerDrawer.Expanded", isNamed("QuestDockContentExpanded"), (real) => { + registerIntercept(real, ServerDrawerSheet, { gestureContext: getGestureContext() }); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced (type detector)"); + }, { persistent: true }); + const mod = find((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded"); if (!mod?.type) { console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)"); - return false; + return true; // type detector is already watching regardless } const orig = mod.type; @@ -29,7 +42,7 @@ export function patchExpanded( return ; }; cleanups.push(() => { mod.type = orig; }); - console.log(TAG, "PATCH: QuestDockContentExpanded replaced"); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)"); return true; } @@ -37,10 +50,15 @@ 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 = find((m) => m?.type?.displayName === name || m?.type?.name === name); if (!mod?.type) { console.log(TAG, `WARN: ${name} not found`); - return false; + return true; // type detector is already watching regardless } const orig = mod.type; const originalComponent = orig; diff --git a/plugins/ServerDrawer/src/patches/createElementIntercept.ts b/plugins/ServerDrawer/src/patches/createElementIntercept.ts index c803c2d..6c7b5cd 100644 --- a/plugins/ServerDrawer/src/patches/createElementIntercept.ts +++ b/plugins/ServerDrawer/src/patches/createElementIntercept.ts @@ -1,56 +1,304 @@ import { React } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; interface Intercept { replacement: React.ComponentType; extraProps?: Record; + /** How many collapsed-to-zero-size ancestor levels above this element are also allowed to collapse (0 = none). */ + collapseAncestors?: number; +} + +interface PropsIntercept { + predicate: (props: any) => boolean; + replacement: React.ComponentType | 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, Intercept>(); -let origCreateElement: typeof React.createElement | null = null; +const propsIntercepts: PropsIntercept[] = []; +const propsTransforms: PropsTransform[] = []; +let typeDetectors: TypeDetector[] = []; +const detectorKeys = new Set(); let isPatched = false; +// Elements are created child-first, so a collapse mark on a child is already present by the time +// the parent's own call is intercepted - that's what makes walking the collapse upward possible. +const collapseMarks = new WeakMap(); + +// display: none alone doesn't reclaim space on every layout, so this also zeroes width/flex/margin +// and hides overflow. +const COLLAPSE_STYLE = { + display: "none" as const, + width: 0, + 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, replacement: React.ComponentType, extraProps?: Record, + options?: { collapseAncestors?: number }, ) { - intercepts.set(original, { replacement, extraProps }); + intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors }); +} + +// Matches on a props predicate instead of an exact type reference, for components with no +// reliable name. Pass replacement: null to render nothing. +export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType | null) { + propsIntercepts.push({ predicate, replacement }); +} + +// Rewrites props in place, keeping the same type. +export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) { + propsTransforms.push({ predicate, transform }); +} + +// Fires onDetected(type) the first time a matching element is created. For components with no +// stable module export to search for after the fact. persistent keeps firing on every match +// instead of once, for references that change across navigation. +export function registerTypeDetector( + key: string, + predicate: (type: any) => boolean, + 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 { + // Ignore + } + if (!matched) continue; + + try { + detector.onDetected(type); + } catch { + // Ignore + } + if (!detector.persistent) { + detector.justFired = true; + consumed = true; + } + } + + if (consumed) { + typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired); + } +} + +declare module "./createElementIntercept" {} +interface TypeDetector { justFired?: boolean } + +/** Returns the collapse depth a parent should inherit from its children (max of any real child, 0 if none). */ +function inspectCollapseChild(child: any): number { + if (child == null || child === false || typeof child !== "object") return 0; + return collapseMarks.get(child) ?? 0; +} + +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 { + // Ignore + } + } + } + + if (effectiveProps) { + for (const { predicate, replacement } of propsIntercepts) { + try { + if (predicate(effectiveProps, type, rest)) { + return replacement ? { type: replacement, props: effectiveProps } : null; + } + } catch { + // Ignore + } + } + } + + const entry = intercepts.get(type); + if (entry) { + return { + type: entry.replacement, + props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps, + collapse: entry.collapseAncestors, + }; + } + + // A wrapper whose only child was already collapsed - zero it out too and pass the remaining budget up. + const inherited = inheritedCollapseDepth(effectiveProps, rest); + if (inherited > 0) { + return { + type, + props: { ...effectiveProps, style: [effectiveProps?.style, COLLAPSE_STYLE] }, + collapse: inherited - 1, + }; + } + + if (effectiveProps !== props) { + return { type, props: effectiveProps }; + } + + return undefined; +} + +// after() only sees the return value, not the call itself, so we mutate the element in place +// rather than substituting args beforehand. +function applyResolved(res: any, type: any, props: any, rest: any[]) { + if (!res || typeof res !== "object") return res; + const resolved = resolveReplacement(type, props, rest); + 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; - - // 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; + 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 compiles JSX through jsx/jsxs/jsxDEV, not React.createElement - scan for those + // runtimes by shape (names are mangled) and keep scanning since Metro registers lazily. + const patchedJsxRuntimes = new WeakSet(); + + 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 + } + } + + 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); + } + } + + scanAndPatchJsxRuntimes(); + + // Fast while the app boots, then slow, then stop. + let ticks = 0; + let timer: ReturnType | 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(); }); } diff --git a/plugins/ServerDrawer/src/patches/hideGuildsBar.tsx b/plugins/ServerDrawer/src/patches/hideGuildsBar.tsx index 2b5babc..17e57af 100644 --- a/plugins/ServerDrawer/src/patches/hideGuildsBar.tsx +++ b/plugins/ServerDrawer/src/patches/hideGuildsBar.tsx @@ -1,94 +1,78 @@ import React from "react"; -import { Pressable, Image, View } from "react-native"; -import { find, findByName, findByProps } from "@vendetta/metro"; -import { getAssetIDByName } from "@vendetta/ui/assets"; -import { registerIntercept } from "./createElementIntercept"; +import { View } from "react-native"; +import { registerPropsTransform, registerTypeDetector, registerIntercept } from "./createElementIntercept"; const TAG = "[ServerDrawer]"; +const SD_NOTHING_TEST_ID = "ServerDrawerNothing"; -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"; +function Nothing() { + return React.createElement(View, { + style: { + display: "none", + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden", + }, + }); +} -function openDms() { - Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT); - if (ChannelActions?.selectPrivateChannel) { - const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.(); - ChannelActions.selectPrivateChannel(lastChannelId); +// 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 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 ( - - {selected ? ( - - ) : null} - - - ); -} - -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; + for (const child of rest) if (inspect(child)) return true; + return false; } export function patchHideGuildsBar(cleanups: (() => void)[]): boolean { - const mod = findGuildsBar(); - if (!mod?.default) { - console.log(TAG, "WARN: GuildsBar not found"); - return false; - } - const orig = mod.default; + // Rendering GuildsBar as nothing alone still leaves its space reserved - the immediate parent + // carries an explicit width in its own style, so display: none on the child doesn't reclaim + // it. This zeroes the parent wrapper too once it spots the hidden marker as a child. + registerPropsTransform( + (props: any, _type: any, rest: any[]) => + hasChildWithTestID(props?.children, rest, SD_NOTHING_TEST_ID), + (props: any) => ({ + ...props, + style: [ + props?.style, + { + display: "none", + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden", + }, + ], + }), + ); - registerIntercept(orig, DmsGuildIcon); - - mod.default = function DmsGuildsBar() { - return React.createElement(DmsGuildIcon); - }; - mod.default.displayName = "GuildsBar"; - cleanups.push(() => { mod.default = orig; }); + 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 }); + console.log(TAG, "PATCH: watching for the real GuildsBar to appear"); return true; } diff --git a/plugins/ServerDrawer/src/patches/transparentBackground.ts b/plugins/ServerDrawer/src/patches/transparentBackground.ts new file mode 100644 index 0000000..cfda6f9 --- /dev/null +++ b/plugins/ServerDrawer/src/patches/transparentBackground.ts @@ -0,0 +1,46 @@ +import { registerPropsTransform, registerPropsIntercept } from "./createElementIntercept"; + +const TAG = "[ServerDrawer]"; + +// The quest dock's outer wrapper carries an opaque card background (borderRadius: 24) via +// useAnimatedStyle - no component reference to intercept, so match on the computed style instead. +// The color itself varies in format (rgba vs hex), so borderRadius + "has a background color" is +// the stable signal, not the color string. +function flatten(style: any): Record { + if (!style) return {}; + if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten)); + return style; +} + +function isQuestDockCard(props: any): boolean { + const s = flatten(props?.style); + return s.borderRadius === 24 && typeof s.backgroundColor === "string"; +} + +// Removing the card color reveals a second layer underneath: the quest's promotional hero photo. +// Matched by URL shape, not component, since the same image component renders ordinary guild/ +// folder icons everywhere else too. +function getSourceUri(props: any): string | undefined { + const source = props?.source; + if (!source) return undefined; + if (Array.isArray(source)) return source[0]?.uri; + return source.uri; +} + +function isQuestHeroImage(props: any): boolean { + const uri = getSourceUri(props); + return typeof uri === "string" && uri.includes("/quests/"); +} + +export function patchTransparentBackground(cleanups: (() => void)[]): boolean { + registerPropsTransform( + (props: any) => isQuestDockCard(props), + (props: any) => ({ + ...props, + style: [props?.style, { backgroundColor: "transparent" }], + }), + ); + registerPropsIntercept(isQuestHeroImage, null); + console.log(TAG, "PATCH: watching for the quest dock card background and hero image"); + return true; +} diff --git a/plugins/ServerDrawer/src/ui/NoteBox.tsx b/plugins/ServerDrawer/src/ui/NoteBox.tsx new file mode 100644 index 0000000..50a5055 --- /dev/null +++ b/plugins/ServerDrawer/src/ui/NoteBox.tsx @@ -0,0 +1,29 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { findByProps } from "@vendetta/metro"; + +const { View, Text } = ReactNative; + +const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +/** Small bordered callout for explanatory text in a settings screen. */ +export default function NoteBox({ children, style }: { children: any; style?: any }) { + const textColor = colors?.TEXT_MUTED ?? colors?.TEXT_NORMAL ?? "#96989d"; + + return ( + + {children} + + ); +} diff --git a/plugins/ServerDrawer/src/ui/Settings.tsx b/plugins/ServerDrawer/src/ui/Settings.tsx new file mode 100644 index 0000000..37bcb1a --- /dev/null +++ b/plugins/ServerDrawer/src/ui/Settings.tsx @@ -0,0 +1,36 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; +import { TableRowGroup, TableSwitchRow } from "../utils/table"; +import NoteBox from "./NoteBox"; + +const { ScrollView, View } = ReactNative; + +export default function Settings() { + useProxy(storage); + + return ( + + + 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, and the + button creates or joins a server through Discord's own modal. + + + { storage.hideDmTile = v; }} + /> + { storage.showGuildNames = v; }} + /> + + + + ); +} diff --git a/plugins/ServerDrawer/src/utils/table.ts b/plugins/ServerDrawer/src/utils/table.ts new file mode 100644 index 0000000..af5f258 --- /dev/null +++ b/plugins/ServerDrawer/src/utils/table.ts @@ -0,0 +1,14 @@ +import { findByProps } from "@vendetta/metro"; +import { Forms } from "@vendetta/ui/components"; + +// Discord's redesigned "Table" row family replaced Forms.Form* in the app itself, but the +// vendetta.ui.components compat object never exposed it - found via direct findByProps instead, +// falling back to the legacy Form component (whose prop names don't perfectly match) if a lookup +// ever comes back empty. +const find = (prop: string): any => findByProps(prop)?.[prop]; + +const RealTableRow: any = find("TableRow"); + +export const TableRow: any = RealTableRow ?? Forms.FormRow; +export const TableRowGroup: any = find("TableRowGroup") ?? Forms.FormSection; +export const TableSwitchRow: any = find("TableSwitchRow") ?? Forms.FormSwitchRow;