YouBar+: pause - revert to upstream's exact logic, nothing else

This commit is contained in:
everestmcarthur 2026-08-01 23:03:23 -04:00
parent 9246309220
commit 0b3ddfd6a9
6 changed files with 76 additions and 228 deletions

View file

@ -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™" }

View file

@ -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
};

View file

@ -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<T = any>(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;
}

View file

@ -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<T>(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle {
let cancelled = false;
let timer: ReturnType<typeof setInterval> | 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);
}
};
}

View file

@ -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<any>(TOAST_CONTAINER_FILE_PATH, true),
(ToastContainer) => {
unpatchToastContainer = after("type", ToastContainer, (_args: any[], res: any) => (
<React.Fragment>
{res}
<FloatingButtons />
</React.Fragment>
));
// 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 (
<View pointerEvents="box-none" style={styles.container}>
{storage.showDMButton && (
<TouchableOpacity
accessibilityLabel="Direct Messages"
onPress={() => transitionModule?.transitionToGuild?.("@me")}
style={styles.button}
>
<Image source={ChatIcon} style={styles.icon} />
</TouchableOpacity>
)}
{storage.showSettingsButton && (
<TouchableOpacity
accessibilityLabel="Settings"
onPress={() => userSettingsAction?.openUserSettings?.()}
style={styles.button}
>
<Image source={SettingsIcon} style={styles.icon} />
</TouchableOpacity>
)}
</View>
);
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 (
<React.Fragment>
{storage.showDMButton && (
<IconButton
variant={originalProps?.variant || "tertiary"}
size={originalProps?.size || "sm"}
icon={ChatIcon}
onPress={() => {
transitionModule?.transitionToGuild?.("@me");
}}
/>
)}
{storage.showSettingsButton && (
<IconButton
variant={originalProps?.variant || "tertiary"}
size={originalProps?.size || "sm"}
icon={SettingsIcon}
onPress={() => {
userSettingsAction?.openUserSettings?.();
}}
/>
)}
{res}
</React.Fragment>
);
});
}
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"
}
};

View file

@ -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 (
<SettingsScaffold>
<NoteBox>
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.
</NoteBox>
<FormSection title="Buttons">
<FormSwitchRow
label="Direct Messages button"
subLabel="Show a floating DM button"
subLabel="Show the DM button in the YouBar"
value={!!storage.showDMButton}
onValueChange={(v: boolean) => {
storage.showDMButton = v;
updateYouBar();
}}
/>
<FormSwitchRow
label="Settings button"
subLabel="Show a floating Settings button"
subLabel="Show the Settings button in the YouBar"
value={!!storage.showSettingsButton}
onValueChange={(v: boolean) => {
storage.showSettingsButton = v;
updateYouBar();
}}
/>
</FormSection>