diff --git a/plugins/you-bar-plus/manifest.json b/plugins/you-bar-plus/manifest.json index 1225301..9e7b8ec 100644 --- a/plugins/you-bar-plus/manifest.json +++ b/plugins/you-bar-plus/manifest.json @@ -1,6 +1,6 @@ { "name": "YouBar+", - "description": "Adds Settings and DM buttons to the YouBar. Toggling while Discord's already running may need one reload to take effect.", + "description": "Adds floating DM/Settings buttons near the bottom of the screen, rendered independently rather than inside the native YouBar row for reliability.", "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 8e15841..f1b303e 100644 --- a/plugins/you-bar-plus/src/index.ts +++ b/plugins/you-bar-plus/src/index.ts @@ -1,20 +1,30 @@ 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"; -let unpatchAll: () => void = () => {}; +// 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 = () => {}; export default { onLoad: () => { storage.showDMButton ??= false; storage.showSettingsButton ??= true; - unpatchAll = applyPatches("YouBar+", logger, { - "YouBar buttons": patchYouBarButtons - }); + logger.log(`[YouBar+] loading (${BUILD_MARKER})`); + + try { + unpatch = patchYouBarButtons(); + } catch (e) { + logger.error(`[YouBar+] Failed to apply patches, buttons will not appear: ${e}`); + unpatch = () => {}; + } }, - onUnload: () => unpatchAll(), + onUnload: () => unpatch(), settings: Settings }; diff --git a/plugins/you-bar-plus/src/lib/rawMetro.ts b/plugins/you-bar-plus/src/lib/rawMetro.ts new file mode 100644 index 0000000..74622d7 --- /dev/null +++ b/plugins/you-bar-plus/src/lib/rawMetro.ts @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 0000000..c030b7e --- /dev/null +++ b/plugins/you-bar-plus/src/lib/waitFor.ts @@ -0,0 +1,40 @@ +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 24f69f0..1b455ae 100644 --- a/plugins/you-bar-plus/src/patches/youBarButtons.tsx +++ b/plugins/you-bar-plus/src/patches/youBarButtons.tsx @@ -1,111 +1,129 @@ import { findByProps } from "@vendetta/metro"; -import { React } from "@vendetta/metro/common"; -import { instead } from "@vendetta/patcher"; +import { React, ReactNative } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; import { getAssetIDByName } from "@vendetta/ui/assets"; -import { waitFor } from "@shared/lib/waitFor"; -import { rawFindByTypeName } from "@shared/lib/rawFind"; -import { patchCreateElement, registerTypeDetector } from "@shared/lib/createElementIntercept"; +import { showToast } from "@vendetta/ui/toasts"; +import { waitFor } from "../lib/waitFor"; +import { rawFindByFilePath } from "../lib/rawMetro"; -export let updateYouBar = () => {}; +const { View, TouchableOpacity, Image } = ReactNative; -function isYouBarNotificationsButton(type: any): boolean { - return ( - type?.name === "YouBarNotificationsButton" || - type?.displayName === "YouBarNotificationsButton" || - type?.type?.name === "YouBarNotificationsButton" || - type?.type?.displayName === "YouBarNotificationsButton" - ); -} +const TOAST_CONTAINER_FILE_PATH = "modules/toast/native/ToastContainer.tsx"; /** - * Confirmed on-device that a metro-search-based lookup (whether the original one-shot - * findByTypeName, or this repo's own waitFor + passive rawFindByTypeName retry) is fundamentally - * racing against something it can't always win: YouBarNotificationsButton is a React.memo'd leaf - * that, once mounted, appears to never render again for the rest of the session (also confirmed - * on-device - patching it and watching for 20+ real seconds on the main screen never saw another - * render). If that first, only mount happens before our search finds and patches the component, - * the patch is permanently too late no matter how many times the search retries afterward. + * 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. * - * registerTypeDetector sidesteps the race by watching element *creation* instead of searching the - * module registry after the fact: the moment ANY code calls createElement/jsx with this component - * as `type` - which is necessarily the actual live reference already, there's no way to call it any - * earlier - we grab it and patch immediately, before that render even happens. This still needs - * patchCreateElement to have installed its React.createElement/jsx-runtime hooks before that first - * call, but jsx-runtime is about as foundational and early-loaded a module as React itself, since - * literally nothing can render via JSX without it - a much safer bet than waiting on a specific leaf - * UI component to register. + * 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. * - * The waitFor + rawFindByTypeName search stays too, purely as a fallback for the case where the - * component was already mounted (and thus already searchable) *before* this plugin's onLoad even - * ran - e.g. toggled on well after a cold boot completed - since in that case there's no future - * creation call left to intercept. Both paths funnel through the same `patchOnce`, deduped by - * reference, so if both somehow fire for the same live component it only gets patched once. + * 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 { - const cleanups: (() => void)[] = []; - let buttonUnpatch: () => void = () => {}; - let patchedTarget: any = null; + let unpatchToastContainer: () => void = () => {}; - function patchOnce(target: any) { - if (!target || target === patchedTarget) return; - patchedTarget = target; - buttonUnpatch = applyButtonPatch(target); - } + const handle = waitFor( + () => rawFindByFilePath(TOAST_CONTAINER_FILE_PATH, true), + (ToastContainer) => { + unpatchToastContainer = after("type", ToastContainer, (_args: any[], res: any) => ( + + {res} + + + )); - patchCreateElement(cleanups); - registerTypeDetector(isYouBarNotificationsButton, patchOnce); - - const handle = waitFor(() => rawFindByTypeName("YouBarNotificationsButton"), patchOnce); - cleanups.push(() => handle.cancel()); + // 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 () => { - buttonUnpatch(); - cleanups.forEach((fn) => fn()); + handle.cancel(); + unpatchToastContainer(); }; } -function applyButtonPatch(YouBarNotificationsButton: any): () => void { +function FloatingButtons() { + useProxy(storage); + + if (!storage.showDMButton && !storage.showSettingsButton) return null; + const userSettingsAction = findByProps("openUserSettings"); const transitionModule = findByProps("transitionToGuild"); const ChatIcon = getAssetIDByName("ChatIcon"); const SettingsIcon = getAssetIDByName("SettingsIcon"); - return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => { - try { - 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} - - ); - } catch { - // A shape mismatch here should mean "stock YouBar, no extra buttons" - not a crash. - return OriginalRender(...args); - } - }); + return ( + + {storage.showDMButton && ( + transitionModule?.transitionToGuild?.("@me")} + style={styles.button} + > + + + )} + {storage.showSettingsButton && ( + userSettingsAction?.openUserSettings?.()} + style={styles.button} + > + + + )} + + ); } + +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 b2eeafd..c0ea09a 100644 --- a/plugins/you-bar-plus/src/ui/Settings.tsx +++ b/plugins/you-bar-plus/src/ui/Settings.tsx @@ -1,45 +1,39 @@ -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+, ported to Revenge's Vendetta-compat API, with a fix - for a startup race where Metro hadn't always registered the YouBar component yet by - the time this patch ran - if you toggle this on while Discord's already running, - the buttons should still appear without a restart once they mount, but a fresh - reload is the most reliable way to see them. + 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. { storage.showDMButton = v; - updateYouBar(); }} /> { storage.showSettingsButton = v; - updateYouBar(); }} />