diff --git a/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts b/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts index 2c00ad7..911d230 100644 --- a/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts +++ b/plugins/rose-utils/src/modules/EnforceDeveloperMode/index.ts @@ -33,6 +33,12 @@ export default new Module({ enforce(); this.patches.add(fluxSubscribe("USER_SETTINGS_PROTO_UPDATE", enforce)); + + // Belt and suspenders: if whatever resets this doesn't go through + // USER_SETTINGS_PROTO_UPDATE for some reason, this still catches it within 5s instead + // of staying off for the rest of the session. + const interval = setInterval(enforce, 5000); + this.patches.add(() => clearInterval(interval)); }, onStop() {}, }, diff --git a/plugins/rose-utils/src/modules/Minimod/index.ts b/plugins/rose-utils/src/modules/Minimod/index.ts index 72bf8ba..838257f 100644 --- a/plugins/rose-utils/src/modules/Minimod/index.ts +++ b/plugins/rose-utils/src/modules/Minimod/index.ts @@ -25,20 +25,22 @@ export default new Module({ }, handlers: { onStart() { - if (!this.storage.options.showTimeouts || !GuildMemberStore?.getCommunicationDisabledUserMap) return; + // getCommunicationDisabledUserMap doesn't exist anymore (confirmed against decompiled + // current-build Discord source) - GuildMemberStore only exposes per-member lookups now, + // via getMember(guildId, userId).communicationDisabledUntil. + if (!this.storage.options.showTimeouts || !GuildMemberStore?.getMember) return; this.patches.add( patchRows((rows) => { if (!rows.some((row) => "message" in row)) return; - const timedOut = GuildMemberStore.getCommunicationDisabledUserMap(); const now = Date.now(); for (const row of rows) { if (row.type !== 1) continue; - const date = new Date(timedOut[`${row.message.guildId}-${row.message.authorId}`]); - if (date.getTime() > now) { + const until = GuildMemberStore.getMember(row.message.guildId, row.message.authorId)?.communicationDisabledUntil; + if (until && new Date(until).getTime() > now) { row.message.communicationDisabled = true; } } diff --git a/plugins/server-drawer/src/components/GuildItem.tsx b/plugins/server-drawer/src/components/GuildItem.tsx index 479d5e5..4533c63 100644 --- a/plugins/server-drawer/src/components/GuildItem.tsx +++ b/plugins/server-drawer/src/components/GuildItem.tsx @@ -1,8 +1,11 @@ import React from "react"; import { View, Text, Pressable, Animated, StyleSheet } from "react-native"; -import { find, findByProps, findByStoreName } from "@vendetta/metro"; +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { clipboard } from "@vendetta/metro/common"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; +import { showToast } from "@vendetta/ui/toasts"; +import { lazy } from "@shared/lib/lazy"; import { GuildNode } from "../utils/theme"; import GuildIcon from "./GuildIcon"; import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal"; @@ -12,9 +15,47 @@ const ICON = 48; const Flux = findByProps("useStateFromStores"); const GuildReadStateStore = findByStoreName("GuildReadStateStore"); const GuildStore = findByStoreName("GuildStore"); -const SortedGuildStore = findByStoreName("SortedGuildStore"); +const GuildChannelStore = findByStoreName("GuildChannelStore"); +const ChannelStore = findByStoreName("ChannelStore"); const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const getBulkAck = lazy(() => findByProps("bulkAck", "ackChannel")); +const getReadStateTypes = lazy(() => findByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes); + +// Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with +// no named export - findByName/`.default.name` heuristics for it are unreliable, since a plain +// function's own .name doesn't survive production minification the way an explicit .displayName +// assignment does (confirmed by cross-checking: many memo-wrapped components' .displayName DID +// show up as literal strings in decompiled source, this function's .name never does). Rather than +// guess at a fragile lookup, this builds a small menu from actions this repo has already confirmed +// work elsewhere (the same bulkAck path MarkAllReadButton uses, scoped to one guild). +function markGuildRead(guildId: string) { + const bulkAck = getBulkAck(); + const ReadStateTypes = getReadStateTypes(); + if (!bulkAck?.bulkAck || ReadStateTypes?.CHANNEL == null || !GuildChannelStore?.getSelectableChannelIds) { + showToast("Couldn't find Discord's read-state action - this may be unavailable on your version.", undefined); + return; + } + + const channelIds = [ + ...(GuildChannelStore.getSelectableChannelIds(guildId) ?? []), + ...(GuildChannelStore.getVocalChannelIds?.(guildId) ?? []), + ]; + const channels = channelIds.map((channelId: string) => ({ + channelId, + readStateType: ReadStateTypes.CHANNEL, + messageId: ChannelStore?.getChannel?.(channelId)?.lastMessageId ?? null, + })); + + if (channels.length === 0) { + showToast("Nothing to mark as read.", undefined); + return; + } + + bulkAck.bulkAck(channels); + showToast("Marked as read", undefined); +} + function Badge({ guildId }: { guildId: string }) { useProxy(storage); @@ -70,30 +111,26 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: ( const showMenu = React.useCallback(() => { const guildId = node.id as string; 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; - - if (!guild || !menuItemsFn) { - return; - } - - const treeVersion = SortedGuildStore?.getGuildsTree?.()?.version; - const rawItems = menuItemsFn(guildId, treeVersion); - if (!rawItems?.length) return; + if (!guild) return; const ref = viewRef.current as any; if (!ref?.measure) return; + const items: ContextMenuItem[] = [ + { label: "Mark as Read", action: () => markGuildRead(guildId) }, + { + label: "Copy Server ID", + action: () => { + clipboard.setString(guildId); + showToast("Copied server ID", undefined); + }, + }, + ]; + ref.measure((_fx: number, _fy: number, _w: number, _h: number, pageX: number, pageY: number) => { setMenuState({ visible: true, - items: rawItems.map((item: any) => ({ - label: item.label ?? item.title ?? "Unknown", - action: item.action, - danger: item.variant === "destructive", - iconSource: item.iconSource, - IconComponent: item.IconComponent, - })), + items, title: guild.name, x: pageX, y: pageY, diff --git a/plugins/server-drawer/src/patches/createElementIntercept.ts b/plugins/server-drawer/src/patches/createElementIntercept.ts index c803c2d..04b1491 100644 --- a/plugins/server-drawer/src/patches/createElementIntercept.ts +++ b/plugins/server-drawer/src/patches/createElementIntercept.ts @@ -5,7 +5,13 @@ interface Intercept { extraProps?: Record; } +interface PropsIntercept { + predicate: (props: any) => boolean; + replacement: React.ComponentType | null; +} + const intercepts = new Map, Intercept>(); +const propsIntercepts: PropsIntercept[] = []; let origCreateElement: typeof React.createElement | null = null; let isPatched = false; @@ -17,6 +23,17 @@ export function registerIntercept( intercepts.set(original, { replacement, extraProps }); } +/** + * Same idea as registerIntercept, but matches by a predicate over the element's props instead of + * an exact type reference - useful when the component itself has no reliable name to find it by + * (no named export, and a stripped/mangled runtime function name), but it always passes some + * distinguishing literal prop (e.g. a nativeID) that survives into the compiled bundle unchanged. + * Pass `replacement: null` to render nothing at all instead of swapping in a component. + */ +export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType | null) { + propsIntercepts.push({ predicate, replacement }); +} + export function patchCreateElement(cleanups: (() => void)[]) { if (isPatched) return; @@ -32,6 +49,18 @@ export function patchCreateElement(cleanups: (() => void)[]) { // Fallback to original createElement if somehow null (should not happen) return React.createElement(type, props, ...rest); } + if (props) { + for (const { predicate, replacement } of propsIntercepts) { + try { + if (predicate(props)) { + return replacement ? origCreateElement.call(React, replacement, props, ...rest) : null; + } + } catch { + // A bad predicate shouldn't block every other element from rendering. + } + } + } + const entry = intercepts.get(type); if (entry) { const newProps = entry.extraProps @@ -52,5 +81,6 @@ export function patchCreateElement(cleanups: (() => void)[]) { isPatched = false; } intercepts.clear(); + propsIntercepts.length = 0; }); } diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index f2378b3..3cc2fd0 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -1,46 +1,24 @@ -import React from "react"; -import { find, findByName } from "@vendetta/metro"; -import { registerIntercept } from "./createElementIntercept"; +import { registerPropsIntercept } from "./createElementIntercept"; const TAG = "[ServerDrawer]"; -// Used to replace the sidebar with a single DMs icon in its place; DMs now live in the drawer's -// own grid (as the first tile) instead, so the rail just goes away entirely. -function EmptyGuildsBar() { - return 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; -} - +/** + * The left server rail's root component (modules/guilds_bar/native/GuildsBar.tsx, confirmed + * against decompiled current-build Discord source) is a bare default export with no named export + * and no discoverable component name at runtime - findByName("GuildsBar") and every displayName/ + * type.name heuristic reliably comes up empty because there's genuinely nothing to match by name. + * What IS reliable: the outermost View it renders is built with a literal, unminifiable + * `nativeID: "guilds-bar-view"` (confirmed unique - it appears exactly once in the whole decompiled + * bundle). Intercepting createElement calls carrying that prop and rendering nothing instead is a + * stable way to hide it regardless of what the component itself is named at runtime. + */ 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; - - registerIntercept(orig, EmptyGuildsBar); - - mod.default = function HiddenGuildsBar() { - return React.createElement(EmptyGuildsBar); - }; - mod.default.displayName = "GuildsBar"; - cleanups.push(() => { mod.default = orig; }); + registerPropsIntercept((props) => props?.nativeID === "guilds-bar-view", null); + cleanups.push(() => { + // registerPropsIntercept has no per-call unregister - the whole list gets cleared when + // createElementIntercept's own patch unwinds, which happens as part of the same cleanup + // pass this plugin already runs on unload/restart. + }); + console.log(TAG, "PATCH: guilds-bar-view createElement calls now render nothing"); return true; } diff --git a/site/meta.json b/site/meta.json index 37a57e4..6727af2 100644 --- a/site/meta.json +++ b/site/meta.json @@ -224,7 +224,7 @@ "status": "revived", "accent": "#F0637E", "tagline": "Spotify listen-along, a Tenor fix, colorful channels, and more - now actually working.", - "note": "Fork of nexpid's NexxUtils. Fixed the reported \"render is not a function in SpotifyPlayButton\" crash - Discord converted it from a class component to a function component, confirmed against decompiled current-build source, so patching .prototype.render no longer worked. Also fixed six other lookups across the plugin that had the same unguarded pattern that crashed FPTE's whole bundle at load time - any one of them failing would have taken the entire plugin down, not just one module. A more serious bug was found and fixed after real device testing: opening RoseUtils' own settings screen crashed outright with \"Cannot convert undefined value to object\". Root cause, confirmed by reading the compiled bundle directly: this repo's SWC build config lowers class syntax to plain functions for compatibility, but doesn't also handle the separate class-fields transform, so every field initializer on the internal Module class (errors, patches, started, listeners) was silently dropped from the compiled output - every module was missing all four. Fixed by assigning them in the constructor body instead, which every build target handles the same way. Also added a new module: Enforce Developer Mode, which keeps Discord's Developer Mode switched on since it tends to reset itself on Revenge.", + "note": "Fork of nexpid's NexxUtils. Fixed the reported \"render is not a function in SpotifyPlayButton\" crash - Discord converted it from a class component to a function component, confirmed against decompiled current-build source, so patching .prototype.render no longer worked. Also fixed six other lookups across the plugin that had the same unguarded pattern that crashed FPTE's whole bundle at load time - any one of them failing would have taken the entire plugin down, not just one module. A more serious bug was found and fixed after real device testing: opening RoseUtils' own settings screen crashed outright with \"Cannot convert undefined value to object\". Root cause, confirmed by reading the compiled bundle directly: this repo's SWC build config lowers class syntax to plain functions for compatibility, but doesn't also handle the separate class-fields transform, so every field initializer on the internal Module class (errors, patches, started, listeners) was silently dropped from the compiled output - every module was missing all four. Fixed by assigning them in the constructor body instead, which every build target handles the same way. Also added a new module: Enforce Developer Mode, which keeps Discord's Developer Mode switched on since it tends to reset itself on Revenge. Minimod's timeout indicator was fixed too: GuildMemberStore.getCommunicationDisabledUserMap doesn't exist anymore (confirmed against decompiled source) - it now reads getMember(guildId, userId).communicationDisabledUntil per member instead, which does. Enforce Developer Mode also gained a 5-second poll as a backup to the settings-change listener, so it can't stay off for long no matter what actually reset it.", "howItWorks": "Each utility is its own independently toggleable module: Spotify listen-along buttons on activities, a fix for Tenor GIFs downloading as videos, an invite-to-listen-along button in the chat attachment menu, a fun \"minimod\" that shows member timeouts in chat, and colorful/NSFW-tagged channel icons. Every module tracks its own errors and shows a one-tap \"copy report\" row if something goes wrong, so a problem in one module is easy to report without digging through logs. The original's Material-Design component reskin (Switch/Toast/Alert) wasn't ported - it depends on react-native-reanimated, which isn't part of this build, and it was already disabled by default upstream due to known issues.", "features": [ "Spotify listen-along buttons on activities (fixed)", @@ -243,7 +243,7 @@ "status": "revived", "accent": "#F97316", "tagline": "An expandable server grid with DMs, smarter folders, and mark-all-read built in.", - "note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface. Folder options (auto-collapse, hide preview icons) are adapted from fres621's BetterFolders - its icon-hiding half patched a \"GuildContainer\" component that no longer exists anywhere in current Discord builds (confirmed against decompiled source), and only applied to the native server rail anyway, which this plugin already replaces - so it's been rebuilt as a feature of the drawer's own folders instead. Two more bugs fixed after real device testing: guild icons that never loaded (GuildStore lookups weren't reactive to guild data streaming in after connect, so most servers just showed a bare unread dot with no icon) and taps in the drawer not navigating anywhere (several lookups - transitionToGuild, the create/join action, the root nav ref - were resolved once at plugin-load time, before Discord had necessarily required those modules yet, same class of bug as YouBar+'s startup race). Both fixed; DMs also moved from a leftover sidebar icon into the drawer itself, and a mark-all-servers-as-read button was added. Two more rounds after that: the Quest Dock hijack (10 separate internal lookups just to force it to render) used to be attempted once at onLoad, so if any single one of them wasn't registered in Metro yet the drawer could just silently not show up for the whole session - it now retries whatever hasn't landed on a fast 200ms interval until everything's applied. And mark-all-read was rebuilt to match Discord's own markGuildsAsRead action exactly (getSelectableChannelIds + getVocalChannelIds per guild, confirmed against decompiled source) instead of a less certain guild-enumeration method.", + "note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface. Folder options (auto-collapse, hide preview icons) are adapted from fres621's BetterFolders - its icon-hiding half patched a \"GuildContainer\" component that no longer exists anywhere in current Discord builds (confirmed against decompiled source), and only applied to the native server rail anyway, which this plugin already replaces - so it's been rebuilt as a feature of the drawer's own folders instead. Two more bugs fixed after real device testing: guild icons that never loaded (GuildStore lookups weren't reactive to guild data streaming in after connect, so most servers just showed a bare unread dot with no icon) and taps in the drawer not navigating anywhere (several lookups - transitionToGuild, the create/join action, the root nav ref - were resolved once at plugin-load time, before Discord had necessarily required those modules yet, same class of bug as YouBar+'s startup race). Both fixed; DMs also moved from a leftover sidebar icon into the drawer itself, and a mark-all-servers-as-read button was added. Two more rounds after that: the Quest Dock hijack (10 separate internal lookups just to force it to render) used to be attempted once at onLoad, so if any single one of them wasn't registered in Metro yet the drawer could just silently not show up for the whole session - it now retries whatever hasn't landed on a fast 200ms interval until everything's applied. And mark-all-read was rebuilt to match Discord's own markGuildsAsRead action exactly (getSelectableChannelIds + getVocalChannelIds per guild, confirmed against decompiled source) instead of a less certain guild-enumeration method. The left server rail hiding was also fixed properly this round: the rail's root component has no discoverable name at runtime at all (confirmed against decompiled source - it's a bare default export, and unlike memo-wrapped components with an explicit displayName, a plain function's own .name doesn't survive production minification), so every name-based lookup for it was guaranteed to fail. Now intercepted by its outermost View's literal nativeID (\"guilds-bar-view\", confirmed unique in the bundle) instead. The long-press context menu on a server icon had the exact same problem (getGuildsBarGuildMenuItems is also a bare default export) - replaced with a small menu of actions built from APIs this repo has already confirmed work (mark this server as read, copy server ID) instead of a lookup that could never succeed.", "howItWorks": "Hijacks the Quest Dock - the strip Discord normally uses for daily quests, right above the tab bar - and puts a grid of your servers there instead. Tap it to expand, tap a server to jump to it, long-press for the usual context menu (mark as read, notification settings, leave, etc). A + button opens Discord's own create/join-server modal. Optionally hides the left server rail down to a single DMs icon, since your servers now live in the drawer instead.", "features": [ "Tap-to-expand server grid with folders, unread badges, and mention counts",