mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 12:15:09 +03:00
feat(toaster): render notifications in the quick access menu
This commit is contained in:
@@ -1,37 +1,39 @@
|
||||
export default function DeckyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456">
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: 'none' }}
|
||||
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||
/>
|
||||
import { FC, SVGAttributes } from 'react';
|
||||
|
||||
<ellipse
|
||||
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||
style={{ fill: 'none' }}
|
||||
cx="154.33"
|
||||
cy="211.33"
|
||||
rx="69.33"
|
||||
ry="69.33"
|
||||
/>
|
||||
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||
<path
|
||||
style={{ fill: 'currentColor' }}
|
||||
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||
c7.18,0,13,5.82,13,13V271z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456" {...props}>
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: 'none' }}
|
||||
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||
/>
|
||||
|
||||
<ellipse
|
||||
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||
style={{ fill: 'none' }}
|
||||
cx="154.33"
|
||||
cy="211.33"
|
||||
rx="69.33"
|
||||
ry="69.33"
|
||||
/>
|
||||
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||
<path
|
||||
style={{ fill: 'currentColor' }}
|
||||
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||
c7.18,0,13,5.82,13,13V271z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default DeckyIcon;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import { joinClassNames } from '@decky/ui';
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
|
||||
import { useDeckyToasterState } from './DeckyToasterState';
|
||||
import Toast, { toastClasses } from './Toast';
|
||||
|
||||
interface DeckyToasterProps {}
|
||||
|
||||
interface RenderedToast {
|
||||
component: ReactElement;
|
||||
data: ToastData;
|
||||
}
|
||||
|
||||
const DeckyToaster: FC<DeckyToasterProps> = () => {
|
||||
const { toasts, removeToast } = useDeckyToasterState();
|
||||
const [renderedToast, setRenderedToast] = useState<RenderedToast | null>(null);
|
||||
console.log(toasts);
|
||||
if (toasts.size > 0) {
|
||||
const [activeToast] = toasts;
|
||||
if (!renderedToast || activeToast != renderedToast.data) {
|
||||
// TODO play toast soundReactElement
|
||||
console.log('rendering toast', activeToast);
|
||||
setRenderedToast({ component: <Toast key={Math.random()} toast={activeToast} />, data: activeToast });
|
||||
}
|
||||
} else {
|
||||
if (renderedToast) setRenderedToast(null);
|
||||
}
|
||||
useEffect(() => {
|
||||
// not actually node but TS is shit
|
||||
let interval: number | null;
|
||||
if (renderedToast) {
|
||||
interval = setTimeout(
|
||||
() => {
|
||||
interval = null;
|
||||
console.log('clear toast', renderedToast.data);
|
||||
removeToast(renderedToast.data);
|
||||
},
|
||||
(renderedToast.data.duration || 5e3) + 1000,
|
||||
);
|
||||
console.log('set int', interval);
|
||||
}
|
||||
return () => {
|
||||
if (interval) {
|
||||
console.log('clearing int', interval);
|
||||
clearTimeout(interval);
|
||||
}
|
||||
};
|
||||
}, [renderedToast]);
|
||||
return (
|
||||
<div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}>
|
||||
{renderedToast && renderedToast.component}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeckyToaster;
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface PublicDeckyToasterState {
|
||||
toasts: Set<ToastData>;
|
||||
}
|
||||
|
||||
export class DeckyToasterState {
|
||||
private _toasts: Set<ToastData> = new Set();
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyToasterState {
|
||||
return { toasts: this._toasts };
|
||||
}
|
||||
|
||||
addToast(toast: ToastData) {
|
||||
this._toasts.add(toast);
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
removeToast(toast: ToastData) {
|
||||
this._toasts.delete(toast);
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyToasterContext extends PublicDeckyToasterState {
|
||||
addToast(toast: ToastData): void;
|
||||
removeToast(toast: ToastData): void;
|
||||
}
|
||||
|
||||
const DeckyToasterContext = createContext<DeckyToasterContext>(null as any);
|
||||
|
||||
export const useDeckyToasterState = () => useContext(DeckyToasterContext);
|
||||
|
||||
interface Props {
|
||||
deckyToasterState: DeckyToasterState;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
|
||||
const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({
|
||||
...deckyToasterState.publicState(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onUpdate() {
|
||||
setPublicDeckyToasterState({ ...deckyToasterState.publicState() });
|
||||
}
|
||||
|
||||
deckyToasterState.eventBus.addEventListener('update', onUpdate);
|
||||
|
||||
return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const addToast = deckyToasterState.addToast.bind(deckyToasterState);
|
||||
const removeToast = deckyToasterState.removeToast.bind(deckyToasterState);
|
||||
|
||||
return (
|
||||
<DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}>
|
||||
{children}
|
||||
</DeckyToasterContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +1,38 @@
|
||||
import type { ToastData } from '@decky/api';
|
||||
import { findModule, joinClassNames } from '@decky/ui';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Focusable, Navigation, findClassModule, joinClassNames } from '@decky/ui';
|
||||
import { FC, memo } from 'react';
|
||||
|
||||
import Logger from '../logger';
|
||||
import TranslationHelper, { TranslationClass } from '../utils/TranslationHelper';
|
||||
|
||||
const logger = new Logger('ToastRenderer');
|
||||
|
||||
// TODO there are more of these
|
||||
export enum ToastLocation {
|
||||
/** Big Picture popup toasts */
|
||||
GAMEPADUI_POPUP = 1,
|
||||
/** QAM Notifications tab */
|
||||
GAMEPADUI_QAM = 3,
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: ToastData;
|
||||
}
|
||||
|
||||
export const toastClasses = findModule((mod) => {
|
||||
if (typeof mod !== 'object') return false;
|
||||
interface ToastRendererProps extends ToastProps {
|
||||
location: ToastLocation;
|
||||
}
|
||||
|
||||
if (mod.ToastPlaceholder) {
|
||||
return true;
|
||||
}
|
||||
const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
|
||||
|
||||
return false;
|
||||
});
|
||||
// These are memoized as they like to randomly rerender
|
||||
|
||||
const templateClasses = findModule((mod) => {
|
||||
if (typeof mod !== 'object') return false;
|
||||
|
||||
if (mod.ShortTemplate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
||||
const GamepadUIPopupToast: FC<ToastProps> = memo(({ toast }) => {
|
||||
return (
|
||||
<div
|
||||
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
|
||||
onClick={toast.onClick}
|
||||
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
|
||||
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '', 'DeckyGamepadUIPopupToast')}
|
||||
>
|
||||
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
|
||||
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
|
||||
@@ -43,6 +44,62 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Toast;
|
||||
const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast }) => {
|
||||
// The fields aren't mismatched, the logic for these is just a bit weird.
|
||||
return (
|
||||
<Focusable
|
||||
onActivate={() => {
|
||||
Navigation.CloseSideMenus();
|
||||
toast.onClick?.();
|
||||
}}
|
||||
className={joinClassNames(
|
||||
templateClasses.StandardTemplateContainer,
|
||||
toast.className || '',
|
||||
'DeckyGamepadUIQAMToast',
|
||||
)}
|
||||
>
|
||||
<div className={templateClasses.StandardTemplate}>
|
||||
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
|
||||
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
|
||||
<div className={templateClasses.Header}>
|
||||
{toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
|
||||
<div className={templateClasses.Title}>
|
||||
{toast.header || (
|
||||
<TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />
|
||||
)}
|
||||
</div>
|
||||
{/* timestamp should always be defined by toaster */}
|
||||
{/* TODO check how valve does this */}
|
||||
{toast.timestamp && (
|
||||
<div className={templateClasses.Timestamp}>
|
||||
{toast.timestamp.toLocaleTimeString(undefined, { timeStyle: 'short' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={templateClasses.StandardNotificationDescription}>{toast.title}</div>
|
||||
<div className={templateClasses.StandardNotificationSubText}>{toast.body}</div>
|
||||
</div>
|
||||
{/* TODO support NewIndicator */}
|
||||
{/* <div className={templateClasses.NewIndicator}><svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"
|
||||
viewBox="0 0 50 50" fill="none">
|
||||
<circle fill="currentColor" cx="25" cy="25" r="25"></circle>
|
||||
</svg></div> */}
|
||||
</div>
|
||||
</Focusable>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location }) => {
|
||||
switch (location) {
|
||||
default:
|
||||
logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIPopupToast.`);
|
||||
case ToastLocation.GAMEPADUI_POPUP:
|
||||
return <GamepadUIPopupToast toast={toast} />;
|
||||
case ToastLocation.GAMEPADUI_QAM:
|
||||
return <GamepadUIQAMToast toast={toast} />;
|
||||
}
|
||||
});
|
||||
|
||||
export default ToastRenderer;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@decky/ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaDownload, FaInfo } from 'react-icons/fa';
|
||||
import { FaDownload, FaFlask, FaInfo } from 'react-icons/fa';
|
||||
|
||||
import { setSetting } from '../../../../utils/settings';
|
||||
import { UpdateBranch } from '../general/BranchSelect';
|
||||
@@ -94,6 +94,7 @@ export default function TestingVersionList() {
|
||||
DeckyPluginLoader.toaster.toast({
|
||||
title: t('Testing.start_download_toast', { id: version.id }),
|
||||
body: null,
|
||||
icon: <FaFlask />,
|
||||
});
|
||||
try {
|
||||
await downloadTestingVersion(version.id, version.head_sha);
|
||||
@@ -102,6 +103,7 @@ export default function TestingVersionList() {
|
||||
DeckyPluginLoader.toaster.toast({
|
||||
title: t('Testing.error'),
|
||||
body: `${e.name}: ${e.message}`,
|
||||
icon: <FaFlask />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { FC, lazy } from 'react';
|
||||
import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||
|
||||
import DeckyIcon from './components/DeckyIcon';
|
||||
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
|
||||
import { File, FileSelectionType } from './components/modals/filepicker';
|
||||
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
||||
@@ -218,6 +219,7 @@ class PluginLoader extends Logger {
|
||||
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }}
|
||||
/>
|
||||
),
|
||||
icon: <DeckyIcon />,
|
||||
onClick: () => Router.Navigate('/decky/settings'),
|
||||
});
|
||||
}
|
||||
@@ -246,6 +248,7 @@ class PluginLoader extends Logger {
|
||||
i18nArgs={{ count: updates.size }}
|
||||
/>
|
||||
),
|
||||
icon: <DeckyIcon />,
|
||||
onClick: () => Router.Navigate('/decky/settings/plugins'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ class RouterHook extends Logger {
|
||||
routerNode = findRouterNode();
|
||||
}
|
||||
if (routerNode) {
|
||||
this.debug('routerNode', routerNode);
|
||||
// Patch the component globally
|
||||
this.routerPatch = afterPatch(routerNode.elementType, 'type', this.handleRouterRender.bind(this));
|
||||
// Swap out the current instance
|
||||
@@ -110,7 +109,7 @@ class RouterHook extends Logger {
|
||||
const { routes, routePatches } = useDeckyRouterState();
|
||||
// TODO make more redundant
|
||||
if (!children?.props?.children?.[0]?.props?.children) {
|
||||
console.log('routerWrapper wrong component?', children);
|
||||
this.debug('routerWrapper wrong component?', children);
|
||||
return children;
|
||||
}
|
||||
const mainRouteList = children.props.children[0].props.children;
|
||||
|
||||
@@ -32,16 +32,17 @@ class Toaster extends Logger {
|
||||
this.init();
|
||||
}
|
||||
|
||||
// TODO maybe move to constructor lol
|
||||
async init() {
|
||||
const ToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
|
||||
this.debug('toastrenderer', ToastRenderer);
|
||||
const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
|
||||
// TODO find a way to undo this if possible?
|
||||
const patchedRenderer = injectFCTrampoline(ToastRenderer);
|
||||
const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
|
||||
this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
|
||||
this.debug('render toast', args);
|
||||
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
|
||||
this.debug('rendering decky toast');
|
||||
return args[0].group.notifications.map((notification: any) => <Toast toast={notification.data} />);
|
||||
return args[0].group.notifications.map((notification: any) => (
|
||||
<Toast toast={notification.data} location={args?.[0]?.location} />
|
||||
));
|
||||
}
|
||||
return callOriginal;
|
||||
});
|
||||
@@ -65,6 +66,7 @@ class Toaster extends Logger {
|
||||
if (toast.sound === undefined) toast.sound = 6;
|
||||
if (toast.playSound === undefined) toast.playSound = true;
|
||||
if (toast.showToast === undefined) toast.showToast = true;
|
||||
if (toast.timestamp === undefined) toast.timestamp = new Date();
|
||||
if (
|
||||
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
|
||||
(window.settingsStore.settings.bDisableToastsInGame &&
|
||||
@@ -79,6 +81,7 @@ class Toaster extends Logger {
|
||||
notifications: [toast],
|
||||
};
|
||||
tray.unshift(group);
|
||||
// TODO do we need to handle expiration?
|
||||
}
|
||||
const info = {
|
||||
showToast: toast.showToast,
|
||||
|
||||
Reference in New Issue
Block a user