YouBar+: reset to upstream's exact original logic

This commit is contained in:
everestmcarthur 2026-08-01 20:24:05 -04:00
parent c66ebf57b4
commit 3dc639e305
4 changed files with 54 additions and 132 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "YouBar+", "name": "YouBar+",
"description": "Adds up to 2 configurable buttons (Direct Messages, Settings, or none) to the YouBar, in whatever order you pick.", "description": "Adds Settings and DM buttons to the YouBar. (May require a restart after enabling.)",
"authors": [ "authors": [
{ "name": "everestmcarthur" }, { "name": "everestmcarthur" },
{ "name": "Purple_Ξye™" } { "name": "Purple_Ξye™" }

View file

@ -1,107 +1,67 @@
import { findByProps } from "@vendetta/metro"; import { findByProps, findByTypeName } from "@vendetta/metro";
import { React } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { instead } from "@vendetta/patcher"; import { instead } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { getAssetIDByName } from "@vendetta/ui/assets"; import { getAssetIDByName } from "@vendetta/ui/assets";
import { waitFor } from "@shared/lib/waitFor";
import { rawFindByTypeName } from "@shared/lib/rawFind";
import { forceRerender } from "@shared/lib/forceRerender";
let internalForceUpdate: (() => void) | null = null; // Reset to Purple-EyeZ's original upstream logic, verbatim aside from the import path
let patchedComponent: any = null; // (@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 = () => {};
/**
* Lets the Settings screen force the patched YouBar to re-render right after a toggle changes.
* Prefers the cheap in-tree hook update once our wrapper has actually rendered at least once under
* the patch; falls back to forceRerender's fiber-walk otherwise, since a memoized component that
* hasn't rendered under the patch yet has no hook state to update through (see forceRerender.ts for
* why that fallback is needed at all).
*/
export function updateYouBar(): void {
if (internalForceUpdate) internalForceUpdate();
else if (patchedComponent) forceRerender(patchedComponent);
}
/**
* `YouBarNotificationsButton` isn't guaranteed to be registered in Metro yet at the exact moment
* onLoad runs - on a cold app start plugins can load before every screen component has actually
* been required. The original version of this plugin did a single synchronous
* `findByTypeName` lookup here and gave up for the whole session if it came back empty, which is
* the "sometimes the buttons just don't show up, restarting fixes it" bug this port fixes.
*
* Uses rawFindByTypeName (not @vendetta/metro's findByTypeName) specifically because this call
* gets retried by waitFor - Revenge's own findByTypeName permanently caches a "not found" result
* and never rescans, so retrying the cached version would just hit that poisoned cache every time
* instead of ever actually finding the component once it registers. Confirmed by reading Revenge's
* own metro finder source.
*/
export default function patchYouBarButtons(): () => void { export default function patchYouBarButtons(): () => void {
let unpatch: () => void = () => {}; const YouBarNotificationsButton = findByTypeName("YouBarNotificationsButton");
const handle = waitFor(
() => rawFindByTypeName("YouBarNotificationsButton"),
(YouBarNotificationsButton) => {
unpatch = applyButtonPatch(YouBarNotificationsButton);
patchedComponent = YouBarNotificationsButton;
// Finding the module and patching it doesn't make anything visible on its own - this
// component is React.memo'd with a stable prop, so once mounted it can go the rest of
// the session without ever rendering again on its own (see forceRerender.ts). Force
// one pass right now instead of waiting on luck for the buttons to actually appear.
forceRerender(YouBarNotificationsButton);
}
);
return () => {
handle.cancel();
unpatch();
internalForceUpdate = null;
patchedComponent = null;
};
}
function applyButtonPatch(YouBarNotificationsButton: any): () => void {
// Same lookups the original working version of this plugin used - this was a timing bug
// (see above), not a renamed-API one, so these targets are left exactly as they were.
const userSettingsAction = findByProps("openUserSettings"); const userSettingsAction = findByProps("openUserSettings");
const transitionModule = findByProps("transitionToGuild"); const transitionModule = findByProps("transitionToGuild");
const ChatIcon = getAssetIDByName("ChatIcon");
const SettingsIcon = getAssetIDByName("SettingsIcon"); const SettingsIcon = getAssetIDByName("SettingsIcon");
const ChatIcon = getAssetIDByName("ChatIcon");
if (!YouBarNotificationsButton) return () => {};
return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => { return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => {
try { const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
internalForceUpdate = () => forceUpdate();
const res = OriginalRender(...args); updateYouBar = () => forceUpdate();
if (!res?.props?.children) return res;
const IconButton = res.props.children.type; const res = OriginalRender(...args);
const originalProps = res.props.children.props;
return ( if (!res?.props?.children) return res;
<React.Fragment>
{storage.showDMButton && ( const IconButton = res.props.children.type;
<IconButton const originalProps = res.props.children.props;
variant={originalProps?.variant || "tertiary"}
size={originalProps?.size || "sm"} return (
icon={ChatIcon} <React.Fragment>
onPress={() => transitionModule?.transitionToGuild?.("@me")} {storage.showDMButton && (
/> <IconButton
)} variant={originalProps?.variant || "tertiary"}
{storage.showSettingsButton && ( size={originalProps?.size || "sm"}
<IconButton icon={ChatIcon}
variant={originalProps?.variant || "tertiary"} onPress={() => {
size={originalProps?.size || "sm"} transitionModule?.transitionToGuild?.("@me");
icon={SettingsIcon} }}
onPress={() => userSettingsAction?.openUserSettings?.()} />
/> )}
)}
{res} {storage.showSettingsButton && (
</React.Fragment> <IconButton
); variant={originalProps?.variant || "tertiary"}
} catch { size={originalProps?.size || "sm"}
// A shape mismatch here should mean "stock YouBar, no extra buttons" - not a crash. icon={SettingsIcon}
return OriginalRender(...args); onPress={() => {
} userSettingsAction?.openUserSettings?.();
}}
/>
)}
{res}
</React.Fragment>
);
}); });
} }

View file

@ -17,8 +17,9 @@ export default function Settings() {
return ( return (
<SettingsScaffold> <SettingsScaffold>
<NoteBox> <NoteBox>
Fork of Purple-EyeZ's YouBar+, ported to Revenge's Vendetta-compat API with a fix Fork of Purple-EyeZ's YouBar+, ported to Revenge's Vendetta-compat API. Like the
for a startup race that could silently skip the button patch for an entire session. 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> </NoteBox>
<FormSection title="Buttons"> <FormSection title="Buttons">
<FormSwitchRow <FormSwitchRow

View file

@ -1,39 +0,0 @@
// The actual reason YouBar+'s buttons only ever showed up after a full reload: YouBar's own
// button is wrapped in React.memo with the default shallow-prop comparator, and it only ever
// receives one stable boolean prop. Once it mounts, React's own updateMemoComponent bailout means
// it will *never* run its render function again - regardless of how often anything around it
// re-renders - unless something upstream schedules real work that reaches this exact fiber. That
// makes patching `.type` alone invisible until pure luck (an unrelated future re-render) happens
// to touch it, which on a live device can be "eventually" or "never in this session" - matching
// exactly what looked like "needs 2 reloads."
//
// Two earlier versions of this file tried to actively force that render pass:
// - Walking React's DevTools fiber-root registry (__REACT_DEVTOOLS_GLOBAL_HOOK__). Confirmed via
// Key Inspector on a real device that hook doesn't exist on this Discord build at all - that
// whole approach was silently doing nothing.
// - Calling setParams() on the current route via getRootNavigationRef(), to force a genuine
// react-navigation state update. This ran unconditionally, synchronously, the moment the button
// module first initializes - which on a cold boot can be very early, before navigation has
// fully settled. After shipping it, buttons stopped appearing even after a full restart - a
// real regression, not just "still needs a reload." Poking live navigation state as an automatic
// side effect of applying a UI patch is a meaningfully bigger risk than anything else in this
// fix, and it's the prime suspect, so it's been pulled entirely rather than debugged further
// blind. (It also used the cached findByProps instead of this repo's raw* variants, so on a
// build where that lookup wasn't registered yet, it would have permanently poisoned
// getRootNavigationRef for the rest of the session - a second, independent reason to drop it.)
//
// What's left is the one part that's unconditionally safe: if `target` is a memo wrapper,
// permanently override its `compare` so that whenever a future render pass *does* reach this fiber
// for any reason, it can never bail out again. This doesn't make buttons appear instantly on a hot
// toggle - that still needs a real trigger, which is worth another attempt, but only ever tested
// manually first via Key Inspector's Eval tool where its effect can actually be observed before
// it's wired back into anything that runs automatically.
export function forceRerender(target: any): void {
try {
if (target && typeof target === "object" && "compare" in target) {
target.compare = () => false;
}
} catch {
// Best effort - a shape mismatch here just means no permanent bailout override.
}
}