diff --git a/plugins/you-bar-plus/manifest.json b/plugins/you-bar-plus/manifest.json index 9e7b8ec..dd6a848 100644 --- a/plugins/you-bar-plus/manifest.json +++ b/plugins/you-bar-plus/manifest.json @@ -1,6 +1,6 @@ { "name": "YouBar+", - "description": "Adds floating DM/Settings buttons near the bottom of the screen, rendered independently rather than inside the native YouBar row for reliability.", + "description": "Adds Settings and DM buttons to the YouBar. (May require a restart after enabling.)", "authors": [ { "name": "everestmcarthur" }, { "name": "Purple_Ξye™" } diff --git a/plugins/you-bar-plus/src/index.ts b/plugins/you-bar-plus/src/index.ts index f1b303e..8e15841 100644 --- a/plugins/you-bar-plus/src/index.ts +++ b/plugins/you-bar-plus/src/index.ts @@ -1,30 +1,20 @@ import { logger } from "@vendetta"; import { storage } from "@vendetta/plugin"; +import { applyPatches } from "@shared/lib/patcher"; import patchYouBarButtons from "./patches/youBarButtons"; import Settings from "./ui/Settings"; -// Bumped by hand whenever the patch logic meaningfully changes - logged on every load so "did the -// update actually land" (a real, repeated source of confusion this whole rebuild, between GitHub -// Pages' CDN cache and Revenge's own plugin-update caching) can be answered by checking Revenge's -// debug log instead of guessing. -const BUILD_MARKER = "youbar-plus-v2-toast-anchor"; - -let unpatch: () => void = () => {}; +let unpatchAll: () => void = () => {}; export default { onLoad: () => { storage.showDMButton ??= false; storage.showSettingsButton ??= true; - logger.log(`[YouBar+] loading (${BUILD_MARKER})`); - - try { - unpatch = patchYouBarButtons(); - } catch (e) { - logger.error(`[YouBar+] Failed to apply patches, buttons will not appear: ${e}`); - unpatch = () => {}; - } + unpatchAll = applyPatches("YouBar+", logger, { + "YouBar buttons": patchYouBarButtons + }); }, - onUnload: () => unpatch(), + onUnload: () => unpatchAll(), settings: Settings }; diff --git a/plugins/you-bar-plus/src/lib/rawMetro.ts b/plugins/you-bar-plus/src/lib/rawMetro.ts deleted file mode 100644 index 74622d7..0000000 --- a/plugins/you-bar-plus/src/lib/rawMetro.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Self-contained on purpose - this plugin does not import from @shared/*. Revenge's own -// findByProps/findByName/findByTypeName/findByStoreName permanently cache a negative result the -// first time a search comes up empty and never rescan window.modules again, which makes any -// retry loop built on top of them pointless if the first attempt runs before the target registers. -// This reads window.modules directly instead, but - critically - never forces a module to -// initialize (never calls window.__r on something that hasn't run yet): Metro only ever runs a -// module's factory once and caches whatever it produced forever, so force-evaluating one before its -// own dependencies are ready can permanently wedge it into a broken state for the rest of the -// session. Only ever inspects modules Metro's own isInitialized flag says have already been -// required by something else - entirely passive, safe to poll repeatedly. -declare const window: any; - -/** - * Matches Revenge's own byFilePath filter (metro/filters.ts: metroModules[id].__filePath === path) - * - confirmed live via Key Inspector's Eval console that this reads a real property Metro attaches - * to every module's definition at registration time, well before that module has ever been - * required. Revenge's own compat layer (window.vendetta, everything this repo builds against) - * doesn't expose this filter, but since it's just a plain property read off window.modules[id], - * there's nothing to import - it works the same read directly. - * - * Unlike name-based matching (findByTypeName and friends), a file path can't collide with an - * unrelated module that happens to share a function name after minification - it's the literal - * source file Discord's own build attached, tied to exactly one module. - * - * Still requires the module to be isInitialized to return its exports (the __filePath id lookup - * itself doesn't need that, but there's nothing to hand back until the factory has actually run). - */ -export function rawFindByFilePath(path: string, exportDefault = true): T | undefined { - const modules = window?.modules; - if (!modules) return undefined; - - for (const id in modules) { - const def = modules[id]; - if (def?.__filePath !== path) continue; - if (!def.isInitialized) return undefined; - - const exports = def.publicModule?.exports; - if (!exports) return undefined; - - return exportDefault ? (exports.default ?? exports) : exports; - } - - return undefined; -} diff --git a/plugins/you-bar-plus/src/lib/waitFor.ts b/plugins/you-bar-plus/src/lib/waitFor.ts deleted file mode 100644 index c030b7e..0000000 --- a/plugins/you-bar-plus/src/lib/waitFor.ts +++ /dev/null @@ -1,40 +0,0 @@ -export interface WaitForHandle { - /** Stops polling. Safe to call even after `find` has already resolved. */ - cancel(): void; -} - -/** - * Polls `find` every `intervalMs` (default 100) until it returns a truthy value, then calls - * `onFound` with it and stops polling. - * - * Needed because a plugin's onLoad can run before every Metro module it depends on has actually - * been required - on a cold app start that's a real race, not an edge case. Checks immediately (so - * there's no added delay once the target is already registered) and keeps retrying otherwise. - */ -export function waitFor(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle { - let cancelled = false; - let timer: ReturnType | undefined; - - const immediate = find(); - if (immediate) { - onFound(immediate); - return { cancel() {} }; - } - - timer = setInterval(() => { - if (cancelled) return; - const value = find(); - if (value) { - cancelled = true; - clearInterval(timer); - onFound(value); - } - }, intervalMs); - - return { - cancel() { - cancelled = true; - if (timer) clearInterval(timer); - } - }; -} diff --git a/plugins/you-bar-plus/src/patches/youBarButtons.tsx b/plugins/you-bar-plus/src/patches/youBarButtons.tsx index 1b455ae..e0e1e7f 100644 --- a/plugins/you-bar-plus/src/patches/youBarButtons.tsx +++ b/plugins/you-bar-plus/src/patches/youBarButtons.tsx @@ -1,129 +1,67 @@ -import { findByProps } from "@vendetta/metro"; -import { React, ReactNative } from "@vendetta/metro/common"; -import { after } from "@vendetta/patcher"; +import { findByProps, findByTypeName } from "@vendetta/metro"; +import { React } from "@vendetta/metro/common"; +import { instead } from "@vendetta/patcher"; import { storage } from "@vendetta/plugin"; -import { useProxy } from "@vendetta/storage"; import { getAssetIDByName } from "@vendetta/ui/assets"; -import { showToast } from "@vendetta/ui/toasts"; -import { waitFor } from "../lib/waitFor"; -import { rawFindByFilePath } from "../lib/rawMetro"; -const { View, TouchableOpacity, Image } = ReactNative; +// Reset to Purple-EyeZ's original upstream logic, verbatim aside from the import path +// (@revenge-mod/metro -> @vendetta/metro, since this repo only has the vendetta-compat alias +// configured) - no retry loop, no raw* passive finder, no forced re-render. Every one of those +// layers added this session was meant to fix "sometimes needs a reload," but on the actual device +// buttons stopped appearing at all, even after ten restarts - worse than upstream's own documented +// "requires a restart" baseline. Resetting to the exact known-quantity upstream version first, to +// find out whether the *added* logic was the actual problem or something else in this port +// (a renamed lookup, a build difference) was always broken and just wasn't visible under the retry +// logic's own try/catch swallowing it. +export let updateYouBar = () => {}; -const TOAST_CONTAINER_FILE_PATH = "modules/toast/native/ToastContainer.tsx"; - -/** - * Ground-up rewrite. Every previous approach patched YouBarNotificationsButton directly (or tried - * to catch it via element-creation interception) and all of them failed on-device, repeatedly, - * confirmed via Key Inspector's Eval console: the component is found and patchable, but never - * renders again after its first mount, and there is real evidence that first mount can happen - * before Revenge's own plugin system has even finished loading plugins - in which case no in-JS - * patch, however early, can ever be early enough, because our code fundamentally cannot run before - * Revenge itself runs. - * - * This sidesteps that entirely instead of trying to win an unwinnable race: it doesn't touch - * YouBarNotificationsButton at all. It renders its own independent floating button row, mounted by - * patching Discord's own ToastContainer (modules/toast/native/ToastContainer.tsx - confirmed - * React.memo'd, confirmed its content comes from a genuine Flux store subscription - * (useStateFromStoresArray on ToastStore), not a reanimated-only value like YouBar's badge count - - * meaning a real toast firing causes a real React re-render here, unlike YouBar's leaf, which - * never re-renders for any reason once mounted). Firing one toast ourselves right after patching - * guarantees that re-render happens under our patch, instead of waiting on luck. - * - * Found by file path (rawFindByFilePath), not by name: ToastContainer's inner render function has - * no recoverable name in the compiled bundle (confirmed against decompiled source), so name-based - * matching wouldn't work here even as a fallback. File path is registration-time metadata Metro - * attaches to every module regardless of minification, so it doesn't have that problem. - */ export default function patchYouBarButtons(): () => void { - let unpatchToastContainer: () => void = () => {}; - - const handle = waitFor( - () => rawFindByFilePath(TOAST_CONTAINER_FILE_PATH, true), - (ToastContainer) => { - unpatchToastContainer = after("type", ToastContainer, (_args: any[], res: any) => ( - - {res} - - - )); - - // Guarantees ToastContainer actually re-renders at least once under our patch - its - // content is driven by a real Flux store subscription (ToastStore), so a genuine toast - // firing is a genuine React re-render, not a hope. Without this, our injected buttons - // would only ever appear whenever Discord's own code happens to show a toast next, - // which could be immediate or could be a long wait depending on what the user's doing. - showToast("YouBar+ ready", getAssetIDByName("CheckmarkIcon")); - } - ); - - return () => { - handle.cancel(); - unpatchToastContainer(); - }; -} - -function FloatingButtons() { - useProxy(storage); - - if (!storage.showDMButton && !storage.showSettingsButton) return null; - + const YouBarNotificationsButton = findByTypeName("YouBarNotificationsButton"); const userSettingsAction = findByProps("openUserSettings"); const transitionModule = findByProps("transitionToGuild"); - const ChatIcon = getAssetIDByName("ChatIcon"); + const SettingsIcon = getAssetIDByName("SettingsIcon"); + const ChatIcon = getAssetIDByName("ChatIcon"); - return ( - - {storage.showDMButton && ( - transitionModule?.transitionToGuild?.("@me")} - style={styles.button} - > - - - )} - {storage.showSettingsButton && ( - userSettingsAction?.openUserSettings?.()} - style={styles.button} - > - - - )} - - ); + if (!YouBarNotificationsButton) return () => {}; + + return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => { + const [, forceUpdate] = React.useReducer((x: number) => ~x, 0); + + updateYouBar = () => forceUpdate(); + + const res = OriginalRender(...args); + + if (!res?.props?.children) return res; + + const IconButton = res.props.children.type; + const originalProps = res.props.children.props; + + return ( + + {storage.showDMButton && ( + { + transitionModule?.transitionToGuild?.("@me"); + }} + /> + )} + + {storage.showSettingsButton && ( + { + userSettingsAction?.openUserSettings?.(); + }} + /> + )} + {res} + + ); + }); } - -const BUTTON_SIZE = 40; - -// Approximate positioning, anchored bottom-right - not pixel-matched to the native YouBar row, -// since this renders independently of it rather than inside it. Expect this to need visual -// tuning once the buttons are confirmed to actually appear reliably. -const styles = { - container: { - position: "absolute" as const, - right: 12, - bottom: 90, - flexDirection: "row" as const, - alignItems: "center" as const, - gap: 10, - zIndex: 999, - elevation: 999 - }, - button: { - width: BUTTON_SIZE, - height: BUTTON_SIZE, - borderRadius: BUTTON_SIZE / 2, - backgroundColor: "rgba(30,30,35,0.85)", - alignItems: "center" as const, - justifyContent: "center" as const - }, - icon: { - width: 20, - height: 20, - tintColor: "white" - } -}; diff --git a/plugins/you-bar-plus/src/ui/Settings.tsx b/plugins/you-bar-plus/src/ui/Settings.tsx index c0ea09a..3ec69d0 100644 --- a/plugins/you-bar-plus/src/ui/Settings.tsx +++ b/plugins/you-bar-plus/src/ui/Settings.tsx @@ -1,39 +1,43 @@ +import { React } from "@vendetta/metro/common"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; import { Forms } from "@vendetta/ui/components"; import SettingsScaffold from "@shared/ui/SettingsScaffold"; import NoteBox from "@shared/ui/NoteBox"; +import { updateYouBar } from "../patches/youBarButtons"; const { FormSection, FormSwitchRow } = Forms; export default function Settings() { useProxy(storage); + // Also force one last re-render on the way out, in case a tap didn't reach a mounted YouBar. + React.useEffect(() => () => updateYouBar(), []); + return ( - Fork of Purple-EyeZ's YouBar+, rebuilt from scratch after the original approach - (patching YouBar's own notification button directly) turned out to be unreliable on - current Discord builds. These buttons now render as an independent floating overlay - near the bottom-right of the screen instead of natively inside the YouBar row itself - - not pixel-identical placement, but should appear reliably and update instantly when - toggled, without needing a restart. + Fork of Purple-EyeZ's YouBar+, ported to Revenge's Vendetta-compat API. Like the + original, this patches the YouBar the moment it loads - if you enable it while + Discord's already running, you may need to restart once for the buttons to appear. { storage.showDMButton = v; + updateYouBar(); }} /> { storage.showSettingsButton = v; + updateYouBar(); }} />