YouBar+: ground-up rewrite - independent floating overlay instead of patching YouBar itself
This commit is contained in:
parent
446be85995
commit
19e2316724
6 changed files with 214 additions and 108 deletions
|
|
@ -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™" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
44
plugins/you-bar-plus/src/lib/rawMetro.ts
Normal file
44
plugins/you-bar-plus/src/lib/rawMetro.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
40
plugins/you-bar-plus/src/lib/waitFor.ts
Normal file
40
plugins/you-bar-plus/src/lib/waitFor.ts
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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<any>(TOAST_CONTAINER_FILE_PATH, true),
|
||||
(ToastContainer) => {
|
||||
unpatchToastContainer = after("type", ToastContainer, (_args: any[], res: any) => (
|
||||
<React.Fragment>
|
||||
{res}
|
||||
<FloatingButtons />
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
} catch {
|
||||
// A shape mismatch here should mean "stock YouBar, no extra buttons" - not a crash.
|
||||
return OriginalRender(...args);
|
||||
}
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
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.
|
||||
</NoteBox>
|
||||
<FormSection title="Buttons">
|
||||
<FormSwitchRow
|
||||
label="Direct Messages button"
|
||||
subLabel="Show the DM button in the YouBar"
|
||||
subLabel="Show a floating DM button"
|
||||
value={!!storage.showDMButton}
|
||||
onValueChange={(v: boolean) => {
|
||||
storage.showDMButton = v;
|
||||
updateYouBar();
|
||||
}}
|
||||
/>
|
||||
<FormSwitchRow
|
||||
label="Settings button"
|
||||
subLabel="Show the Settings button in the YouBar"
|
||||
subLabel="Show a floating Settings button"
|
||||
value={!!storage.showSettingsButton}
|
||||
onValueChange={(v: boolean) => {
|
||||
storage.showSettingsButton = v;
|
||||
updateYouBar();
|
||||
}}
|
||||
/>
|
||||
</FormSection>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue