feat(toaster): render notifications in the quick access menu

This commit is contained in:
AAGaming
2024-07-26 14:16:05 -04:00
parent 88e7919a12
commit b93fc8b557
8 changed files with 133 additions and 193 deletions
+6 -4
View File
@@ -1,6 +1,7 @@
export default function DeckyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456">
import { FC, SVGAttributes } from 'react';
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' }}
@@ -34,4 +35,5 @@ export default function DeckyIcon() {
</g>
</svg>
);
}
export default DeckyIcon;
-57
View File
@@ -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>
);
};
+79 -22
View File
@@ -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;
if (mod.ToastPlaceholder) {
return true;
interface ToastRendererProps extends ToastProps {
location: ToastLocation;
}
return false;
});
const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
const templateClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
// These are memoized as they like to randomly rerender
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 />,
});
}
}
+3
View File
@@ -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'),
});
}
+1 -2
View File
@@ -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;
+8 -5
View File
@@ -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,