ServerDrawer: fully hide GuildsBar/Quest Dock, add DM tile, server-name labels, and a settings screen
This commit is contained in:
parent
7de7922579
commit
3a2cd8300f
13 changed files with 592 additions and 129 deletions
|
|
@ -1,10 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "Server Drawer",
|
"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": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"name": "kmmiio99o",
|
"name": "kmmiio99o",
|
||||||
"id": "879393496627306587"
|
"id": "879393496627306587"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rosie"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"main": "src/index.tsx",
|
"main": "src/index.tsx",
|
||||||
|
|
|
||||||
44
plugins/ServerDrawer/src/components/DmTile.tsx
Normal file
44
plugins/ServerDrawer/src/components/DmTile.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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" },
|
||||||
|
});
|
||||||
|
|
@ -82,7 +82,7 @@ function FadeIn({ children }: { children: React.ReactNode }) {
|
||||||
return <Animated.View style={{ opacity }}>{children}</Animated.View>;
|
return <Animated.View style={{ opacity }}>{children}</Animated.View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 open = useFolderExpanded(node.id);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
|
@ -99,7 +99,7 @@ export default function FolderItem({ node, onPick }: { node: GuildNode; onPick:
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{node.children.map((ch) => (
|
{node.children.map((ch) => (
|
||||||
<FadeIn key={ch.id}>
|
<FadeIn key={ch.id}>
|
||||||
<GuildItem node={ch} onPick={onPick} />
|
<GuildItem node={ch} onPick={onPick} showNames={showNames} />
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const GuildReadStateStore = findByStoreName("GuildReadStateStore");
|
||||||
const GuildStore = findByStoreName("GuildStore");
|
const GuildStore = findByStoreName("GuildStore");
|
||||||
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
||||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
||||||
|
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
||||||
|
|
||||||
function Badge({ guildId }: { guildId: string }) {
|
function Badge({ guildId }: { guildId: string }) {
|
||||||
const mentionCount = Flux?.useStateFromStores?.(
|
const mentionCount = Flux?.useStateFromStores?.(
|
||||||
|
|
@ -47,7 +48,7 @@ function Badge({ guildId }: { guildId: string }) {
|
||||||
return null;
|
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<View>(null);
|
const viewRef = React.useRef<View>(null);
|
||||||
const scale = React.useRef(new Animated.Value(1)).current;
|
const scale = React.useRef(new Animated.Value(1)).current;
|
||||||
const scaleDown = () => Animated.spring(scale, { toValue: 0.85, useNativeDriver: true }).start();
|
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 guildId = node.id as string;
|
||||||
|
|
||||||
|
const name = Flux?.useStateFromStores?.(
|
||||||
|
[GuildStore],
|
||||||
|
() => GuildStore?.getGuild?.(guildId)?.name ?? "",
|
||||||
|
[guildId],
|
||||||
|
) ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|
@ -111,11 +118,18 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
delayLongPress={500}
|
delayLongPress={500}
|
||||||
>
|
>
|
||||||
<View ref={viewRef} style={st.outer} collapsable={false}>
|
<View style={st.outer}>
|
||||||
<Animated.View style={[st.icon, { transform: [{ scale }] }]}>
|
<View ref={viewRef} style={st.iconWrap} collapsable={false}>
|
||||||
<GuildIcon id={guildId} />
|
<Animated.View style={[st.icon, { transform: [{ scale }] }]}>
|
||||||
</Animated.View>
|
<GuildIcon id={guildId} />
|
||||||
<Badge guildId={guildId} />
|
</Animated.View>
|
||||||
|
<Badge guildId={guildId} />
|
||||||
|
</View>
|
||||||
|
{showNames && (
|
||||||
|
<Text numberOfLines={2} ellipsizeMode="tail" style={st.label}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<ContextMenuModal
|
<ContextMenuModal
|
||||||
|
|
@ -131,8 +145,21 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
|
||||||
}
|
}
|
||||||
|
|
||||||
const st = StyleSheet.create({
|
const st = StyleSheet.create({
|
||||||
outer: { width: ICON, height: ICON },
|
outer: { width: ICON, alignItems: "center" },
|
||||||
|
iconWrap: { width: ICON, height: ICON },
|
||||||
icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" },
|
icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" },
|
||||||
|
label: {
|
||||||
|
marginTop: 4,
|
||||||
|
width: ICON,
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors?.HEADER_PRIMARY ?? colors?.TEXT_NORMAL ?? "#fff",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.75)",
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const bd = StyleSheet.create({
|
const bd = StyleSheet.create({
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native";
|
import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native";
|
||||||
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
import { find, findByProps, findByStoreName } from "@vendetta/metro";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { useProxy } from "@vendetta/storage";
|
||||||
import { GuildNode } from "../utils/theme";
|
import { GuildNode } from "../utils/theme";
|
||||||
import GuildItem from "./GuildItem";
|
import GuildItem from "./GuildItem";
|
||||||
import FolderItem from "./FolderItem";
|
import FolderItem from "./FolderItem";
|
||||||
|
import DmTile from "./DmTile";
|
||||||
|
|
||||||
const Flux = findByProps("useStateFromStores");
|
const Flux = findByProps("useStateFromStores");
|
||||||
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
const SortedGuildStore = findByStoreName("SortedGuildStore");
|
||||||
|
|
@ -54,6 +57,8 @@ function CreateJoinButton() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ServerDrawerSheet({ gestureContext }: { gestureContext: any }) {
|
export default function ServerDrawerSheet({ gestureContext }: { gestureContext: any }) {
|
||||||
|
useProxy(storage);
|
||||||
|
|
||||||
const pick = React.useCallback((id: string) => {
|
const pick = React.useCallback((id: string) => {
|
||||||
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
|
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
|
||||||
if (Routing?.transitionToGuild) {
|
if (Routing?.transitionToGuild) {
|
||||||
|
|
@ -110,10 +115,11 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext:
|
||||||
style={[st.grid, { paddingHorizontal: padX, gap: GAP }]}
|
style={[st.grid, { paddingHorizontal: padX, gap: GAP }]}
|
||||||
onLayout={onLayout}
|
onLayout={onLayout}
|
||||||
>
|
>
|
||||||
|
{!storage.hideDmTile && <DmTile />}
|
||||||
{nodes.map((node) =>
|
{nodes.map((node) =>
|
||||||
node.type === "folder"
|
node.type === "folder"
|
||||||
? <FolderItem key={node.id} node={node} onPick={pick} />
|
? <FolderItem key={node.id} node={node} onPick={pick} showNames={!!storage.showGuildNames} />
|
||||||
: <GuildItem key={node.id} node={node} onPick={pick} />
|
: <GuildItem key={node.id} node={node} onPick={pick} showNames={!!storage.showGuildNames} />
|
||||||
)}
|
)}
|
||||||
<CreateJoinButton />
|
<CreateJoinButton />
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,19 @@ import { patchMobileQuestDock } from "./patches/mobileQuestDock";
|
||||||
import { patchGetQuestAsset } from "./patches/getQuestAsset";
|
import { patchGetQuestAsset } from "./patches/getQuestAsset";
|
||||||
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
||||||
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
||||||
|
import { patchTransparentBackground } from "./patches/transparentBackground";
|
||||||
import { patchCreateElement } from "./patches/createElementIntercept";
|
import { patchCreateElement } from "./patches/createElementIntercept";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import Settings from "./ui/Settings";
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
const TAG = "[ServerDrawer]";
|
||||||
const cleanups: (() => void)[] = [];
|
const cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLoad() {
|
onLoad() {
|
||||||
|
storage.hideDmTile ??= false;
|
||||||
|
storage.showGuildNames ??= false;
|
||||||
|
|
||||||
console.log(TAG, "onLoad");
|
console.log(TAG, "onLoad");
|
||||||
|
|
||||||
let patched = 0;
|
let patched = 0;
|
||||||
|
|
@ -29,6 +35,7 @@ export default {
|
||||||
if (patchEmpty("QuestDockEnrolledBody", cleanups)) patched++;
|
if (patchEmpty("QuestDockEnrolledBody", cleanups)) patched++;
|
||||||
if (patchEmpty("QuestDockUnenrolledBody", cleanups)) patched++;
|
if (patchEmpty("QuestDockUnenrolledBody", cleanups)) patched++;
|
||||||
if (patchHideGuildsBar(cleanups)) patched++;
|
if (patchHideGuildsBar(cleanups)) patched++;
|
||||||
|
if (patchTransparentBackground(cleanups)) patched++;
|
||||||
|
|
||||||
console.log(TAG, `onLoad done — ${patched} patches applied, ${cleanups.length} cleanups`);
|
console.log(TAG, `onLoad done — ${patched} patches applied, ${cleanups.length} cleanups`);
|
||||||
},
|
},
|
||||||
|
|
@ -37,4 +44,5 @@ export default {
|
||||||
for (const fn of cleanups) fn();
|
for (const fn of cleanups) fn();
|
||||||
cleanups.length = 0;
|
cleanups.length = 0;
|
||||||
},
|
},
|
||||||
|
settings: Settings,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { find } from "@vendetta/metro";
|
import { find } from "@vendetta/metro";
|
||||||
import { React } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
||||||
import { registerIntercept } from "./createElementIntercept";
|
import { registerIntercept, registerTypeDetector } from "./createElementIntercept";
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
const TAG = "[ServerDrawer]";
|
||||||
|
|
||||||
|
|
@ -13,13 +13,26 @@ function getGestureContext(): any {
|
||||||
return cachedGestureContext;
|
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(
|
export function patchExpanded(
|
||||||
cleanups: (() => void)[]
|
cleanups: (() => void)[]
|
||||||
): boolean {
|
): 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");
|
const mod = find((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded");
|
||||||
if (!mod?.type) {
|
if (!mod?.type) {
|
||||||
console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)");
|
console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)");
|
||||||
return false;
|
return true; // type detector is already watching regardless
|
||||||
}
|
}
|
||||||
const orig = mod.type;
|
const orig = mod.type;
|
||||||
|
|
||||||
|
|
@ -29,7 +42,7 @@ export function patchExpanded(
|
||||||
return <ServerDrawerSheet gestureContext={getGestureContext()} />;
|
return <ServerDrawerSheet gestureContext={getGestureContext()} />;
|
||||||
};
|
};
|
||||||
cleanups.push(() => { mod.type = orig; });
|
cleanups.push(() => { mod.type = orig; });
|
||||||
console.log(TAG, "PATCH: QuestDockContentExpanded replaced");
|
console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,10 +50,15 @@ export function patchEmpty(
|
||||||
name: string,
|
name: string,
|
||||||
cleanups: (() => void)[]
|
cleanups: (() => void)[]
|
||||||
): boolean {
|
): 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);
|
const mod = find((m) => m?.type?.displayName === name || m?.type?.name === name);
|
||||||
if (!mod?.type) {
|
if (!mod?.type) {
|
||||||
console.log(TAG, `WARN: ${name} not found`);
|
console.log(TAG, `WARN: ${name} not found`);
|
||||||
return false;
|
return true; // type detector is already watching regardless
|
||||||
}
|
}
|
||||||
const orig = mod.type;
|
const orig = mod.type;
|
||||||
const originalComponent = orig;
|
const originalComponent = orig;
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,304 @@
|
||||||
import { React } from "@vendetta/metro/common";
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import { after } from "@vendetta/patcher";
|
||||||
|
|
||||||
interface Intercept {
|
interface Intercept {
|
||||||
replacement: React.ComponentType<any>;
|
replacement: React.ComponentType<any>;
|
||||||
extraProps?: Record<string, any>;
|
extraProps?: Record<string, any>;
|
||||||
|
/** How many 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<any> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsTransform {
|
||||||
|
predicate: (props: any) => boolean;
|
||||||
|
transform: (props: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeDetector {
|
||||||
|
key: string;
|
||||||
|
predicate: (type: any) => boolean;
|
||||||
|
onDetected: (type: any) => void;
|
||||||
|
persistent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||||
let origCreateElement: typeof React.createElement | null = null;
|
const propsIntercepts: PropsIntercept[] = [];
|
||||||
|
const propsTransforms: PropsTransform[] = [];
|
||||||
|
let typeDetectors: TypeDetector[] = [];
|
||||||
|
const detectorKeys = new Set<string>();
|
||||||
let isPatched = false;
|
let isPatched = false;
|
||||||
|
|
||||||
|
// Elements are created child-first, so a collapse mark on a child is already present by the time
|
||||||
|
// the parent's own call is intercepted - that's what makes walking the collapse upward possible.
|
||||||
|
const collapseMarks = new WeakMap<object, number>();
|
||||||
|
|
||||||
|
// 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(
|
export function registerIntercept(
|
||||||
original: React.ComponentType<any>,
|
original: React.ComponentType<any>,
|
||||||
replacement: React.ComponentType<any>,
|
replacement: React.ComponentType<any>,
|
||||||
extraProps?: Record<string, any>,
|
extraProps?: Record<string, any>,
|
||||||
|
options?: { collapseAncestors?: number },
|
||||||
) {
|
) {
|
||||||
intercepts.set(original, { replacement, extraProps });
|
intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches on a props predicate instead of an exact type reference, for components with no
|
||||||
|
// reliable name. Pass replacement: null to render nothing.
|
||||||
|
export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType<any> | null) {
|
||||||
|
propsIntercepts.push({ predicate, replacement });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrites 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)[]) {
|
export function patchCreateElement(cleanups: (() => void)[]) {
|
||||||
if (isPatched) return;
|
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;
|
isPatched = true;
|
||||||
|
|
||||||
cleanups.push(() => {
|
if (React.createElement) {
|
||||||
if (isPatched && origCreateElement) {
|
cleanups.push(
|
||||||
React.createElement = origCreateElement;
|
after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
|
||||||
isPatched = false;
|
);
|
||||||
|
} else {
|
||||||
|
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord compiles JSX through jsx/jsxs/jsxDEV, not React.createElement - scan for those
|
||||||
|
// runtimes by shape (names are mangled) and keep scanning since Metro registers lazily.
|
||||||
|
const patchedJsxRuntimes = new WeakSet<any>();
|
||||||
|
|
||||||
|
function isJsxRuntime(m: any): boolean {
|
||||||
|
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<typeof setInterval> | undefined = setInterval(() => {
|
||||||
|
scanAndPatchJsxRuntimes();
|
||||||
|
if (++ticks === 50 && timer) { // ~5s at 100ms
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = setInterval(() => {
|
||||||
|
scanAndPatchJsxRuntimes();
|
||||||
|
if (++ticks >= 75 && timer) { // + ~25s at 1s
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
isPatched = false;
|
||||||
intercepts.clear();
|
intercepts.clear();
|
||||||
|
propsIntercepts.length = 0;
|
||||||
|
propsTransforms.length = 0;
|
||||||
|
typeDetectors = [];
|
||||||
|
detectorKeys.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,78 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Pressable, Image, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { find, findByName, findByProps } from "@vendetta/metro";
|
import { registerPropsTransform, registerTypeDetector, registerIntercept } from "./createElementIntercept";
|
||||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
|
||||||
import { registerIntercept } from "./createElementIntercept";
|
|
||||||
|
|
||||||
const TAG = "[ServerDrawer]";
|
const TAG = "[ServerDrawer]";
|
||||||
|
const SD_NOTHING_TEST_ID = "ServerDrawerNothing";
|
||||||
|
|
||||||
const ChatIcon = getAssetIDByName("ChatIcon");
|
function Nothing() {
|
||||||
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
return React.createElement(View, {
|
||||||
const ChannelActions = findByProps("selectPrivateChannel");
|
style: {
|
||||||
const SelectedChannelStore = findByName("SelectedChannelStore");
|
display: "none",
|
||||||
const FluxStores = findByProps("useStateFromStores");
|
width: 0,
|
||||||
const useStateFromStores = FluxStores?.useStateFromStores;
|
minWidth: 0,
|
||||||
const GuildStore = findByProps("getGuildId");
|
maxWidth: 0,
|
||||||
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
|
flexGrow: 0,
|
||||||
const Routes = findByProps("ME");
|
flexShrink: 0,
|
||||||
const ME = Routes?.ME ?? "/channels/@me";
|
flexBasis: 0,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openDms() {
|
// The parent creates GuildsBar via its outer React.memo wrapper object, which has no own
|
||||||
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
|
// .name/.displayName at all - those only exist on the memo's inner function, at
|
||||||
if (ChannelActions?.selectPrivateChannel) {
|
// type.type.name/type.type.displayName.
|
||||||
const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.();
|
function isGuildsBar(type: any): boolean {
|
||||||
ChannelActions.selectPrivateChannel(lastChannelId);
|
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;
|
||||||
}
|
}
|
||||||
}
|
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 {
|
export function patchHideGuildsBar(cleanups: (() => void)[]): boolean {
|
||||||
const mod = findGuildsBar();
|
// Rendering GuildsBar as nothing alone still leaves its space reserved - the immediate parent
|
||||||
if (!mod?.default) {
|
// carries an explicit width in its own style, so display: none on the child doesn't reclaim
|
||||||
console.log(TAG, "WARN: GuildsBar not found");
|
// it. This zeroes the parent wrapper too once it spots the hidden marker as a child.
|
||||||
return false;
|
registerPropsTransform(
|
||||||
}
|
(props: any, _type: any, rest: any[]) =>
|
||||||
const orig = mod.default;
|
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);
|
registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => {
|
||||||
|
registerIntercept(realGuildsBar, Nothing, { testID: SD_NOTHING_TEST_ID }, { collapseAncestors: 6 });
|
||||||
mod.default = function DmsGuildsBar() {
|
console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing");
|
||||||
return React.createElement(DmsGuildIcon);
|
}, { persistent: true });
|
||||||
};
|
console.log(TAG, "PATCH: watching for the real GuildsBar to appear");
|
||||||
mod.default.displayName = "GuildsBar";
|
|
||||||
cleanups.push(() => { mod.default = orig; });
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
plugins/ServerDrawer/src/patches/transparentBackground.ts
Normal file
46
plugins/ServerDrawer/src/patches/transparentBackground.ts
Normal file
|
|
@ -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<string, any> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
29
plugins/ServerDrawer/src/ui/NoteBox.tsx
Normal file
29
plugins/ServerDrawer/src/ui/NoteBox.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
plugins/ServerDrawer/src/ui/Settings.tsx
Normal file
36
plugins/ServerDrawer/src/ui/Settings.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
<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, and the + button creates or joins a server through Discord's own modal.
|
||||||
|
</NoteBox>
|
||||||
|
<TableRowGroup title="Layout">
|
||||||
|
<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 server names"
|
||||||
|
subLabel="Print each server's name under its icon in the drawer"
|
||||||
|
value={!!storage.showGuildNames}
|
||||||
|
onValueChange={(v: boolean) => { storage.showGuildNames = v; }}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
<View style={{ height: 24 }} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
plugins/ServerDrawer/src/utils/table.ts
Normal file
14
plugins/ServerDrawer/src/utils/table.ts
Normal file
|
|
@ -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;
|
||||||
Loading…
Add table
Reference in a new issue