From a618bf7c850fd6cd3590cc04d578b1950695fec7 Mon Sep 17 00:00:00 2001 From: vMohammad24 <62218284+vMohammad24@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:07:36 +0300 Subject: [PATCH 1/5] feat: add component injection functionality (with docs haha) --- plugins/lib/src/helpers/index.ts | 1 + plugins/lib/src/helpers/injectComponent.tsx | 170 ++++++++++++++++++++ plugins/lib/src/index.ts | 4 + 3 files changed, 175 insertions(+) create mode 100644 plugins/lib/src/helpers/injectComponent.tsx diff --git a/plugins/lib/src/helpers/index.ts b/plugins/lib/src/helpers/index.ts index b38e3d3..519fa65 100644 --- a/plugins/lib/src/helpers/index.ts +++ b/plugins/lib/src/helpers/index.ts @@ -1,6 +1,7 @@ export * from "./getCredentials"; export * from "./getPlaybackInfo"; export * from "./getPlaybackInfo.dasha.native"; +export * from "./injectComponent"; export * from "./observable"; export * from "./parseDate"; export * from "./safeTimeout"; diff --git a/plugins/lib/src/helpers/injectComponent.tsx b/plugins/lib/src/helpers/injectComponent.tsx new file mode 100644 index 0000000..fa136e6 --- /dev/null +++ b/plugins/lib/src/helpers/injectComponent.tsx @@ -0,0 +1,170 @@ +import { modules, type LunaUnloads } from "@luna/core"; +import React from "react"; + +import { libTrace } from "../index.safe"; + +export type ComponentMatcher = (props: any) => boolean; +export type InjectionPosition = "start" | "end" | number; + +export interface InjectionConfig { + /** Function to match the target element's props */ + matcher: ComponentMatcher; + /** The React component to inject */ + component: React.ReactNode; + /** Where to inject: 'start', 'end', or a specific index */ + position?: InjectionPosition; +} + +const injections = new Set(); + +/** + * Patches the React JSX runtime to inject custom components into existing Tidal components. + * Call this once during initialization to enable component injection. + */ +export const enableComponentInjection = (unloads: LunaUnloads): boolean => { + const jsxRuntime = modules["react/jsx-runtime"]; + + if (!jsxRuntime?.jsx || !jsxRuntime?.jsxs) { + libTrace.warn("injectComponent.enableComponentInjection", "jsx runtime not found - component injection unavailable"); + return false; + } + + const originalJsx = jsxRuntime.jsx; + const originalJsxs = jsxRuntime.jsxs; + + jsxRuntime.jsxs = function (type: any, props: any, ...rest: any[]) { + if (props && injections.size > 0) { + for (const injection of injections) { + if (injection.matcher(props)) { + props = { ...props }; + + const children = Array.isArray(props.children) ? [...props.children] : props.children ? [props.children] : []; + + const position = injection.position ?? "end"; + if (position === "start") { + children.unshift(injection.component); + } else if (position === "end") { + children.push(injection.component); + } else if (typeof position === "number") { + children.splice(position, 0, injection.component); + } + + props.children = children; + } + } + } + + return originalJsxs(type, props, ...rest); + }; + + jsxRuntime.jsx = function (type: any, props: any, ...rest: any[]) { + if (props && injections.size > 0) { + for (const injection of injections) { + if (injection.matcher(props)) { + props = { ...props }; + const existingChild = props.children; + const children = existingChild ? [existingChild] : []; + + const position = injection.position ?? "end"; + if (position === "start") { + children.unshift(injection.component); + } else { + children.push(injection.component); + } + + props.children = children; + } + } + } + + return originalJsx(type, props, ...rest); + }; + + unloads.add(() => { + jsxRuntime.jsx = originalJsx; + jsxRuntime.jsxs = originalJsxs; + }); + + return true; +}; + +/** + * Inject a component into a target element matching the provided matcher function. + * + * @example + * ```tsx + * // Inject into moreContainer (the buttons to the right of the player) + * injectComponent(unloads, { + * matcher: (props) => props.className?.includes?.('moreContainer'), + * component: , + * position: 'end' + * }); + * ``` + * + * @example + * ```tsx + * // Inject by data-test attribute + * injectComponent(unloads, { + * matcher: (props) => props['data-test'] === 'footer-player', + * component: , + * position: 'start' + * }); + * ``` + */ +export const injectComponent = (unloads: LunaUnloads, config: InjectionConfig): void => { + injections.add(config); + unloads.add(() => { + injections.delete(config); + }); +}; + +/** + * Helper matchers for common scenarios + */ +export const matchers = { + /** Match by className (partial match) */ + byClassName: (className: string): ComponentMatcher => { + return (props) => props?.className?.includes?.(className); + }, + + /** Match by data-test attribute */ + byDataTest: (dataTest: string): ComponentMatcher => { + return (props) => props?.["data-test"] === dataTest; + }, + + /** Match by id */ + byId: (id: string): ComponentMatcher => { + return (props) => props?.id === id; + }, + + /** Match by multiple className parts (all must match) */ + byClassNames: (...classNames: string[]): ComponentMatcher => { + return (props) => classNames.every((cn) => props?.className?.includes?.(cn)); + }, + + /** Match by custom property */ + byProp: (key: string, value: any): ComponentMatcher => { + return (props) => props?.[key] === value; + }, +}; + +/** + * Convenience function to inject into a container by className + * + * @example + * ```tsx + * injectIntoContainer(unloads, 'moreContainer', ); + * ``` + */ +export const injectIntoContainer = ( + unloads: LunaUnloads, + className: string, + component: React.ReactNode, + position?: InjectionPosition, +): void => { + injectComponent(unloads, { + matcher: matchers.byClassName(className), + component, + position, + }); +}; diff --git a/plugins/lib/src/index.ts b/plugins/lib/src/index.ts index 6707b53..473dd34 100644 --- a/plugins/lib/src/index.ts +++ b/plugins/lib/src/index.ts @@ -7,10 +7,14 @@ export * as ipcRenderer from "./ipc"; export * as redux from "./redux"; import { StyleTag } from "./classes"; +import { enableComponentInjection } from "./helpers/injectComponent"; import { observePromise } from "./helpers/observable"; import { unloads } from "./index.safe"; + +enableComponentInjection(unloads); + observePromise(unloads, "div[class^='_mainContainer'] > div[class^='_bar'] > div[class^='_title']", 30000).then((title) => { if (title !== null) title.innerHTML = 'TIDALuna BETA'; }); From 5fec85876b189923ed15bd63b3dc2c4f8c43fe56 Mon Sep 17 00:00:00 2001 From: vMohammad24 <62218284+vMohammad24@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:04:17 +0300 Subject: [PATCH 2/5] feat: add replace/replaceChildren and fix some bugs --- plugins/lib/src/helpers/injectComponent.tsx | 122 ++++++++++++++++---- 1 file changed, 99 insertions(+), 23 deletions(-) diff --git a/plugins/lib/src/helpers/injectComponent.tsx b/plugins/lib/src/helpers/injectComponent.tsx index fa136e6..b502c56 100644 --- a/plugins/lib/src/helpers/injectComponent.tsx +++ b/plugins/lib/src/helpers/injectComponent.tsx @@ -4,15 +4,17 @@ import React from "react"; import { libTrace } from "../index.safe"; export type ComponentMatcher = (props: any) => boolean; -export type InjectionPosition = "start" | "end" | number; +export type InjectionPosition = "start" | "end" | number | "replace" | "replaceChildren"; export interface InjectionConfig { /** Function to match the target element's props */ matcher: ComponentMatcher; - /** The React component to inject */ - component: React.ReactNode; - /** Where to inject: 'start', 'end', or a specific index */ + /** The React component to inject, or a function that receives props and returns a component */ + component: React.ReactNode | ((props: any) => React.ReactNode); + /** Where to inject: 'start', 'end', 'replace' (entire element), 'replaceChildren' (just children), or a specific index */ position?: InjectionPosition; + /** Optional condition that must return true for the injection to apply. Receives the matched element's props. */ + condition?: (props: any) => boolean; } const injections = new Set(); @@ -34,22 +36,42 @@ export const enableComponentInjection = (unloads: LunaUnloads): boolean => { jsxRuntime.jsxs = function (type: any, props: any, ...rest: any[]) { if (props && injections.size > 0) { + let propsCloned = false; + for (const injection of injections) { if (injection.matcher(props)) { - props = { ...props }; + if (injection.condition && !injection.condition(props)) { + continue; + } - const children = Array.isArray(props.children) ? [...props.children] : props.children ? [props.children] : []; + const component = typeof injection.component === "function" ? injection.component(props) : injection.component; const position = injection.position ?? "end"; - if (position === "start") { - children.unshift(injection.component); - } else if (position === "end") { - children.push(injection.component); - } else if (typeof position === "number") { - children.splice(position, 0, injection.component); + + if (position === "replace") { + return component; + } + + if (!propsCloned) { + props = { ...props }; + propsCloned = true; } - props.children = children; + if (position === "replaceChildren") { + props.children = component; + } else { + const children = Array.isArray(props.children) ? [...props.children] : props.children ? [props.children] : []; + + if (position === "start") { + children.unshift(component); + } else if (position === "end") { + children.push(component); + } else if (typeof position === "number") { + children.splice(position, 0, component); + } + + props.children = children; + } } } } @@ -59,20 +81,41 @@ export const enableComponentInjection = (unloads: LunaUnloads): boolean => { jsxRuntime.jsx = function (type: any, props: any, ...rest: any[]) { if (props && injections.size > 0) { + let propsCloned = false; + for (const injection of injections) { if (injection.matcher(props)) { - props = { ...props }; - const existingChild = props.children; - const children = existingChild ? [existingChild] : []; + if (injection.condition && !injection.condition(props)) { + continue; + } + + const component = typeof injection.component === "function" ? injection.component(props) : injection.component; const position = injection.position ?? "end"; - if (position === "start") { - children.unshift(injection.component); - } else { - children.push(injection.component); + + if (position === "replace") { + return component; + } + + if (!propsCloned) { + props = { ...props }; + propsCloned = true; } - props.children = children; + if (position === "replaceChildren") { + props.children = component; + } else { + const existingChild = props.children; + const children = existingChild ? [existingChild] : []; + + if (position === "start") { + children.unshift(component); + } else { + children.push(component); + } + + props.children = children; + } } } } @@ -103,13 +146,46 @@ export const enableComponentInjection = (unloads: LunaUnloads): boolean => { * * @example * ```tsx - * // Inject by data-test attribute + * // Replace the entire component + * injectComponent(unloads, { + * matcher: (props) => props['data-test'] === 'footer-player', + * component: , + * position: 'replace' + * }); + * ``` + * + * @example + * ```tsx + * // Replace just the children (keeps parent element and props) + * injectComponent(unloads, { + * matcher: (props) => props.className?.includes('moreContainer'), + * component: , + * position: 'replaceChildren' + * }); + * ``` + * + * @example + * ```tsx + * // Inject at start * injectComponent(unloads, { * matcher: (props) => props['data-test'] === 'footer-player', * component: , * position: 'start' * }); * ``` + * + * @example + * ```tsx + * // Dynamic component based on props - re-evaluates on every render + * injectComponent(unloads, { + * matcher: matchers.byClassName('moreContainer'), + * component: (props) => { + * // Access props.children, props.className, etc. + * if (props.children?.length < 5) return null; + * return ; + * } + * }); + * ``` */ export const injectComponent = (unloads: LunaUnloads, config: InjectionConfig): void => { injections.add(config); @@ -159,7 +235,7 @@ export const matchers = { export const injectIntoContainer = ( unloads: LunaUnloads, className: string, - component: React.ReactNode, + component: React.ReactNode | ((props: any) => React.ReactNode), position?: InjectionPosition, ): void => { injectComponent(unloads, { From 75e8af214577326f03323c288138de5faf9dbc67 Mon Sep 17 00:00:00 2001 From: vMohammad24 <62218284+vMohammad24@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:36:25 +0300 Subject: [PATCH 3/5] chore: bump to 1.7.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e731c0..efe5b37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "luna", - "version": "1.7.2-beta", + "version": "1.7.3-beta", "description": "A client mod for the Tidal music app for plugins", "author": { "name": "Inrixia", From 8298c994843fc0fcb5d0b0590dcd1c1dec69f182 Mon Sep 17 00:00:00 2001 From: Inrixia Date: Sun, 7 Dec 2025 04:40:51 +1300 Subject: [PATCH 4/5] WIP refactor of react injection --- plugins/lib/src/helpers/index.ts | 1 - plugins/lib/src/helpers/injectComponent.tsx | 246 -------------------- plugins/lib/src/index.ts | 4 - plugins/ui/src/index.tsx | 2 + plugins/ui/src/injectReact.tsx | 44 ++++ 5 files changed, 46 insertions(+), 251 deletions(-) delete mode 100644 plugins/lib/src/helpers/injectComponent.tsx create mode 100644 plugins/ui/src/injectReact.tsx diff --git a/plugins/lib/src/helpers/index.ts b/plugins/lib/src/helpers/index.ts index 519fa65..b38e3d3 100644 --- a/plugins/lib/src/helpers/index.ts +++ b/plugins/lib/src/helpers/index.ts @@ -1,7 +1,6 @@ export * from "./getCredentials"; export * from "./getPlaybackInfo"; export * from "./getPlaybackInfo.dasha.native"; -export * from "./injectComponent"; export * from "./observable"; export * from "./parseDate"; export * from "./safeTimeout"; diff --git a/plugins/lib/src/helpers/injectComponent.tsx b/plugins/lib/src/helpers/injectComponent.tsx deleted file mode 100644 index b502c56..0000000 --- a/plugins/lib/src/helpers/injectComponent.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { modules, type LunaUnloads } from "@luna/core"; -import React from "react"; - -import { libTrace } from "../index.safe"; - -export type ComponentMatcher = (props: any) => boolean; -export type InjectionPosition = "start" | "end" | number | "replace" | "replaceChildren"; - -export interface InjectionConfig { - /** Function to match the target element's props */ - matcher: ComponentMatcher; - /** The React component to inject, or a function that receives props and returns a component */ - component: React.ReactNode | ((props: any) => React.ReactNode); - /** Where to inject: 'start', 'end', 'replace' (entire element), 'replaceChildren' (just children), or a specific index */ - position?: InjectionPosition; - /** Optional condition that must return true for the injection to apply. Receives the matched element's props. */ - condition?: (props: any) => boolean; -} - -const injections = new Set(); - -/** - * Patches the React JSX runtime to inject custom components into existing Tidal components. - * Call this once during initialization to enable component injection. - */ -export const enableComponentInjection = (unloads: LunaUnloads): boolean => { - const jsxRuntime = modules["react/jsx-runtime"]; - - if (!jsxRuntime?.jsx || !jsxRuntime?.jsxs) { - libTrace.warn("injectComponent.enableComponentInjection", "jsx runtime not found - component injection unavailable"); - return false; - } - - const originalJsx = jsxRuntime.jsx; - const originalJsxs = jsxRuntime.jsxs; - - jsxRuntime.jsxs = function (type: any, props: any, ...rest: any[]) { - if (props && injections.size > 0) { - let propsCloned = false; - - for (const injection of injections) { - if (injection.matcher(props)) { - if (injection.condition && !injection.condition(props)) { - continue; - } - - const component = typeof injection.component === "function" ? injection.component(props) : injection.component; - - const position = injection.position ?? "end"; - - if (position === "replace") { - return component; - } - - if (!propsCloned) { - props = { ...props }; - propsCloned = true; - } - - if (position === "replaceChildren") { - props.children = component; - } else { - const children = Array.isArray(props.children) ? [...props.children] : props.children ? [props.children] : []; - - if (position === "start") { - children.unshift(component); - } else if (position === "end") { - children.push(component); - } else if (typeof position === "number") { - children.splice(position, 0, component); - } - - props.children = children; - } - } - } - } - - return originalJsxs(type, props, ...rest); - }; - - jsxRuntime.jsx = function (type: any, props: any, ...rest: any[]) { - if (props && injections.size > 0) { - let propsCloned = false; - - for (const injection of injections) { - if (injection.matcher(props)) { - if (injection.condition && !injection.condition(props)) { - continue; - } - - const component = typeof injection.component === "function" ? injection.component(props) : injection.component; - - const position = injection.position ?? "end"; - - if (position === "replace") { - return component; - } - - if (!propsCloned) { - props = { ...props }; - propsCloned = true; - } - - if (position === "replaceChildren") { - props.children = component; - } else { - const existingChild = props.children; - const children = existingChild ? [existingChild] : []; - - if (position === "start") { - children.unshift(component); - } else { - children.push(component); - } - - props.children = children; - } - } - } - } - - return originalJsx(type, props, ...rest); - }; - - unloads.add(() => { - jsxRuntime.jsx = originalJsx; - jsxRuntime.jsxs = originalJsxs; - }); - - return true; -}; - -/** - * Inject a component into a target element matching the provided matcher function. - * - * @example - * ```tsx - * // Inject into moreContainer (the buttons to the right of the player) - * injectComponent(unloads, { - * matcher: (props) => props.className?.includes?.('moreContainer'), - * component: , - * position: 'end' - * }); - * ``` - * - * @example - * ```tsx - * // Replace the entire component - * injectComponent(unloads, { - * matcher: (props) => props['data-test'] === 'footer-player', - * component: , - * position: 'replace' - * }); - * ``` - * - * @example - * ```tsx - * // Replace just the children (keeps parent element and props) - * injectComponent(unloads, { - * matcher: (props) => props.className?.includes('moreContainer'), - * component: , - * position: 'replaceChildren' - * }); - * ``` - * - * @example - * ```tsx - * // Inject at start - * injectComponent(unloads, { - * matcher: (props) => props['data-test'] === 'footer-player', - * component: , - * position: 'start' - * }); - * ``` - * - * @example - * ```tsx - * // Dynamic component based on props - re-evaluates on every render - * injectComponent(unloads, { - * matcher: matchers.byClassName('moreContainer'), - * component: (props) => { - * // Access props.children, props.className, etc. - * if (props.children?.length < 5) return null; - * return ; - * } - * }); - * ``` - */ -export const injectComponent = (unloads: LunaUnloads, config: InjectionConfig): void => { - injections.add(config); - unloads.add(() => { - injections.delete(config); - }); -}; - -/** - * Helper matchers for common scenarios - */ -export const matchers = { - /** Match by className (partial match) */ - byClassName: (className: string): ComponentMatcher => { - return (props) => props?.className?.includes?.(className); - }, - - /** Match by data-test attribute */ - byDataTest: (dataTest: string): ComponentMatcher => { - return (props) => props?.["data-test"] === dataTest; - }, - - /** Match by id */ - byId: (id: string): ComponentMatcher => { - return (props) => props?.id === id; - }, - - /** Match by multiple className parts (all must match) */ - byClassNames: (...classNames: string[]): ComponentMatcher => { - return (props) => classNames.every((cn) => props?.className?.includes?.(cn)); - }, - - /** Match by custom property */ - byProp: (key: string, value: any): ComponentMatcher => { - return (props) => props?.[key] === value; - }, -}; - -/** - * Convenience function to inject into a container by className - * - * @example - * ```tsx - * injectIntoContainer(unloads, 'moreContainer', ); - * ``` - */ -export const injectIntoContainer = ( - unloads: LunaUnloads, - className: string, - component: React.ReactNode | ((props: any) => React.ReactNode), - position?: InjectionPosition, -): void => { - injectComponent(unloads, { - matcher: matchers.byClassName(className), - component, - position, - }); -}; diff --git a/plugins/lib/src/index.ts b/plugins/lib/src/index.ts index 473dd34..6707b53 100644 --- a/plugins/lib/src/index.ts +++ b/plugins/lib/src/index.ts @@ -7,14 +7,10 @@ export * as ipcRenderer from "./ipc"; export * as redux from "./redux"; import { StyleTag } from "./classes"; -import { enableComponentInjection } from "./helpers/injectComponent"; import { observePromise } from "./helpers/observable"; import { unloads } from "./index.safe"; - -enableComponentInjection(unloads); - observePromise(unloads, "div[class^='_mainContainer'] > div[class^='_bar'] > div[class^='_title']", 30000).then((title) => { if (title !== null) title.innerHTML = 'TIDALuna BETA'; }); diff --git a/plugins/ui/src/index.tsx b/plugins/ui/src/index.tsx index 28d0bab..789c5d5 100644 --- a/plugins/ui/src/index.tsx +++ b/plugins/ui/src/index.tsx @@ -87,3 +87,5 @@ setTimeout(async () => { settingsPage.open(); } }); + +import "./injectReact"; diff --git a/plugins/ui/src/injectReact.tsx b/plugins/ui/src/injectReact.tsx new file mode 100644 index 0000000..1c24785 --- /dev/null +++ b/plugins/ui/src/injectReact.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import jsxRuntime from "react/jsx-runtime"; +import { unloads } from "./index.safe"; + +const _jsx = jsxRuntime.jsx; +const _jsxs = jsxRuntime.jsxs; +unloads.add(() => { + jsxRuntime.jsx = _jsx; + jsxRuntime.jsxs = _jsxs; +}); + +// type ReactMatch = Partial>>; +const matchers: Record = { + div: [ + (type: React.ElementType, props: any, key?: React.Key) => { + if (props?.["data-test"] === "footer-player") { + props.children = Array.isArray(props.children) ? props.children : [props.children]; + props.children.push(<>Hello Inside); + return ( +
+ Hello Above +
{_jsxs(type, props, key)}
+
+ ); + } + }, + ], +}; + +jsxRuntime.jsx = function (type, props, key) { + return interceptJSX(type, props, key) ?? _jsx(type, props, key); +}; +jsxRuntime.jsxs = function (type, props, key) { + return interceptJSX(type, props, key) ?? _jsxs(type, props, key); +}; +const interceptJSX = (type: any, props: any, key?: React.Key) => { + if (type in matchers) { + for (const matcher of matchers[type]) { + return matcher(type, props, key); + } + } + return undefined; +}; From 30846ccd4cab8d3cf323ae40334f4aaa734e1385 Mon Sep 17 00:00:00 2001 From: Inrixia Date: Sun, 7 Dec 2025 14:08:55 +1300 Subject: [PATCH 5/5] Working interceptRender --- .vscode/settings.json | 3 +- plugins/lib/src/redux/intercept.ts | 19 +++--- plugins/ui/src/index.tsx | 3 +- plugins/ui/src/injectReact.tsx | 44 ------------- plugins/ui/src/react/index.ts | 1 + plugins/ui/src/react/intercept.tsx | 99 ++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 57 deletions(-) delete mode 100644 plugins/ui/src/injectReact.tsx create mode 100644 plugins/ui/src/react/index.ts create mode 100644 plugins/ui/src/react/intercept.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a6b152..e2116ce 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,8 @@ "/plugins/ui/src/helpers", "/plugins/lib/src/helpers", "/plugins/lib/src/classes", - "/plugins/lib/src/redux" + "/plugins/lib/src/redux", + "/plugins/ui/src/react" ], "discord.enabled": false } diff --git a/plugins/lib/src/redux/intercept.ts b/plugins/lib/src/redux/intercept.ts index 7649ee4..80be398 100644 --- a/plugins/lib/src/redux/intercept.ts +++ b/plugins/lib/src/redux/intercept.ts @@ -19,7 +19,7 @@ import { interceptors, type LunaUnload, type LunaUnloads } from "@luna/core"; * Intercept a Redux action based on its `type` * @param actionType The ActionKey to intercept * @param cb Called when action is intercepted with action args, if returning true action is not dispatched (cancelled) - * @param unloads Set of unload functions to add this to, can be null but only pass if you know what your doing + * @param unloads Set of unload functions to add this to, can be nullish but only if you know what your doing * @param once If set true only intercepts once * @returns Function to call to unload/cancel the intercept */ @@ -34,26 +34,25 @@ export function intercept( const actionTypeArray: ActionType[] = Array.isArray(actionTypes) ? actionTypes : [actionTypes]; // If once is true then call unIntercept immediately to only run once - const intercept = once - ? (payload: InterceptPayload, type: ActionType) => { - unIntercept(); - return cb(payload, type); - } - : cb; + if (once) + cb = (payload: InterceptPayload, type: ActionType) => { + unIntercept(); + return cb(payload, type); + }; // Wrap removing the callback from the interceptors in a unload function and return it const unIntercept = () => { for (const actionType of actionTypeArray) { // ?. so that it doesn't throw if the interceptor was already removed - interceptors[actionType]?.delete(intercept); + interceptors[actionType]?.delete(cb); if (interceptors[actionType]?.size === 0) delete interceptors[actionType]; } }; - unIntercept.source = `intercept${JSON.stringify(actionTypeArray)}`; + unIntercept.source = `intercept::${JSON.stringify(actionTypeArray)}`; for (const actionType of actionTypeArray) { interceptors[actionType] ??= new Set>(); - interceptors[actionType].add(intercept); + interceptors[actionType].add(cb); } unloads?.add(unIntercept); diff --git a/plugins/ui/src/index.tsx b/plugins/ui/src/index.tsx index 789c5d5..1f411c1 100644 --- a/plugins/ui/src/index.tsx +++ b/plugins/ui/src/index.tsx @@ -19,6 +19,7 @@ import { unloads } from "./index.safe"; export * from "./classes"; export * from "./components"; export * from "./helpers"; +export * from "./react"; export { lunaMuiTheme, unloads }; @@ -87,5 +88,3 @@ setTimeout(async () => { settingsPage.open(); } }); - -import "./injectReact"; diff --git a/plugins/ui/src/injectReact.tsx b/plugins/ui/src/injectReact.tsx deleted file mode 100644 index 1c24785..0000000 --- a/plugins/ui/src/injectReact.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; - -import jsxRuntime from "react/jsx-runtime"; -import { unloads } from "./index.safe"; - -const _jsx = jsxRuntime.jsx; -const _jsxs = jsxRuntime.jsxs; -unloads.add(() => { - jsxRuntime.jsx = _jsx; - jsxRuntime.jsxs = _jsxs; -}); - -// type ReactMatch = Partial>>; -const matchers: Record = { - div: [ - (type: React.ElementType, props: any, key?: React.Key) => { - if (props?.["data-test"] === "footer-player") { - props.children = Array.isArray(props.children) ? props.children : [props.children]; - props.children.push(<>Hello Inside); - return ( -
- Hello Above -
{_jsxs(type, props, key)}
-
- ); - } - }, - ], -}; - -jsxRuntime.jsx = function (type, props, key) { - return interceptJSX(type, props, key) ?? _jsx(type, props, key); -}; -jsxRuntime.jsxs = function (type, props, key) { - return interceptJSX(type, props, key) ?? _jsxs(type, props, key); -}; -const interceptJSX = (type: any, props: any, key?: React.Key) => { - if (type in matchers) { - for (const matcher of matchers[type]) { - return matcher(type, props, key); - } - } - return undefined; -}; diff --git a/plugins/ui/src/react/index.ts b/plugins/ui/src/react/index.ts new file mode 100644 index 0000000..d65d607 --- /dev/null +++ b/plugins/ui/src/react/index.ts @@ -0,0 +1 @@ +export * from "./intercept"; diff --git a/plugins/ui/src/react/intercept.tsx b/plugins/ui/src/react/intercept.tsx new file mode 100644 index 0000000..c44b950 --- /dev/null +++ b/plugins/ui/src/react/intercept.tsx @@ -0,0 +1,99 @@ +import React from "react"; + +import type { UnknownRecord } from "@inrixia/helpers"; +import type { LunaUnload, LunaUnloads } from "@luna/core"; +import jsxRuntime, { type JSX } from "react/jsx-runtime"; +import { unloads } from "../index.safe"; + +export const renderJSX = jsxRuntime.jsx; +export const renderJSXS = jsxRuntime.jsxs; +unloads.add(() => { + jsxRuntime.jsx = renderJSX; + jsxRuntime.jsxs = renderJSXS; +}); + +export type RenderJSX = typeof renderJSX; +export type RenderJSXS = typeof renderJSXS; +export type JSXProps

= P & { children?: React.ReactNode }; +export type JSXSProps

= P & { children?: React.ReactNode[] }; +export type JSXElementType = keyof JSX.IntrinsicElements; + +type JSXRenderArgs = + | [isJSXS: true, elementType: JSXElementType, props: JSXSProps, key?: React.Key] + | [isJSXS: false, elementType: JSXElementType, props: JSXProps, key?: React.Key]; +/** + * @param elementType The React HTMLElementType + * @param props The React element props + * @param key The React element key + * @param isJSXS Indicates if calling render was from JSXS or JSX. JSXS children is an array, JSXS is not + * @returns `undefined` to continue, `ReactElement` to render returned element immediately or `null` to cancel. + */ +export type JSXRender = (...args: JSXRenderArgs) => undefined | React.ReactElement | null; +export const renderInterceptors: Partial>> = {}; + +jsxRuntime.jsx = function (type, props, key) { + if (typeof type === "string") return interceptJSX(false, type, props, key)!; + return renderJSX(type, props, key); +}; +jsxRuntime.jsxs = function (type, props, key) { + if (typeof type === "string") return interceptJSX(true, type, props, key)!; + return renderJSXS(type, props, key); +}; +const interceptJSX = (isJSXS: boolean, type: JSXElementType, props: any, key?: React.Key) => { + if (type in renderInterceptors) { + // Run interceptors for JSXElementType + for (const interceptor of renderInterceptors[type]!) { + const res = interceptor(isJSXS, type, props, key); + // If res is null or ReactElement immediately return it. + if (res !== undefined) return res; + } + } +}; + +interceptRender("div", unloads, (isJSXS, elementType, props, key?) => { + if (props["data-test"] !== "footer-player") return; + + const children = isJSXS ? (props.children ?? []) : [props.children]; + children.push(<>Hello Inside); + props.children = children; + + return ( + <> + Hello Above + {renderJSXS(elementType, props, key)} + + ); +}); + +/** + * Intercept a React Componoent Render based on its `ElementType` + * + * **WARNING!** `cb` is called on every render for `ElementType`, only use this if you know what you are doing. This is performance critical code. + * @param elementType The React HTMLElementType to intercept + * @param cb Called when render is intercepted with props, if returning false element is not rendered + * @param unloads Set of unload functions to add this to, can be nullish but only if you know what your doing + * @param once If set true only intercepts once + * @returns Function to call to unload/cancel the intercept + */ +export function interceptRender(elementType: React.HTMLElementType, unloads: LunaUnloads, cb: JSXRender, once?: boolean): LunaUnload { + // If once is true then call unIntercept immediately to only run once + if (once) + cb = (isJSXS, type, props: any, key?) => { + unIntercept(); + return cb(isJSXS, type, props, key); + }; + + // Wrap removing the callback from the interceptors in a unload function and return it + const unIntercept = () => { + // ?. so that it doesn't throw if the interceptor was already removed + renderInterceptors[elementType]?.delete(cb); + if (renderInterceptors[elementType]?.size === 0) delete renderInterceptors[elementType]; + }; + unIntercept.source = `intercept::${elementType}`; + + renderInterceptors[elementType] ??= new Set(); + renderInterceptors[elementType].add(cb); + + unloads?.add(unIntercept); + return unIntercept; +}