From b93fc8b557baed40a312b51196c600be3daaa6fd Mon Sep 17 00:00:00 2001 From: AAGaming Date: Fri, 26 Jul 2024 14:16:05 -0400 Subject: feat(toaster): render notifications in the quick access menu --- frontend/src/components/DeckyIcon.tsx | 74 ++++++++------- frontend/src/components/DeckyToaster.tsx | 57 ------------ frontend/src/components/DeckyToasterState.tsx | 69 -------------- frontend/src/components/Toast.tsx | 103 ++++++++++++++++----- .../components/settings/pages/testing/index.tsx | 4 +- frontend/src/plugin-loader.tsx | 3 + frontend/src/router-hook.tsx | 3 +- frontend/src/toaster.tsx | 13 ++- 8 files changed, 133 insertions(+), 193 deletions(-) delete mode 100644 frontend/src/components/DeckyToaster.tsx delete mode 100644 frontend/src/components/DeckyToasterState.tsx diff --git a/frontend/src/components/DeckyIcon.tsx b/frontend/src/components/DeckyIcon.tsx index 515bd847..fce249e3 100644 --- a/frontend/src/components/DeckyIcon.tsx +++ b/frontend/src/components/DeckyIcon.tsx @@ -1,37 +1,39 @@ -export default function DeckyIcon() { - return ( - - - +import { FC, SVGAttributes } from 'react'; - - - - - - ); -} +const DeckyIcon: FC> = (props) => ( + + + + + + + + + +); + +export default DeckyIcon; diff --git a/frontend/src/components/DeckyToaster.tsx b/frontend/src/components/DeckyToaster.tsx deleted file mode 100644 index 056f1dd7..00000000 --- a/frontend/src/components/DeckyToaster.tsx +++ /dev/null @@ -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 = () => { - const { toasts, removeToast } = useDeckyToasterState(); - const [renderedToast, setRenderedToast] = useState(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: , 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 ( -
- {renderedToast && renderedToast.component} -
- ); -}; - -export default DeckyToaster; diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx deleted file mode 100644 index ebe90b23..00000000 --- a/frontend/src/components/DeckyToasterState.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ToastData } from '@decky/api'; -import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react'; - -interface PublicDeckyToasterState { - toasts: Set; -} - -export class DeckyToasterState { - private _toasts: Set = 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(null as any); - -export const useDeckyToasterState = () => useContext(DeckyToasterContext); - -interface Props { - deckyToasterState: DeckyToasterState; - children: ReactNode; -} - -export const DeckyToasterStateContextProvider: FC = ({ children, deckyToasterState }) => { - const [publicDeckyToasterState, setPublicDeckyToasterState] = useState({ - ...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 ( - - {children} - - ); -}; diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index 79e3d864..13d12f58 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -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'; -interface ToastProps { - toast: ToastData; -} +import Logger from '../logger'; +import TranslationHelper, { TranslationClass } from '../utils/TranslationHelper'; -export const toastClasses = findModule((mod) => { - if (typeof mod !== 'object') return false; +const logger = new Logger('ToastRenderer'); - if (mod.ToastPlaceholder) { - return true; - } +// TODO there are more of these +export enum ToastLocation { + /** Big Picture popup toasts */ + GAMEPADUI_POPUP = 1, + /** QAM Notifications tab */ + GAMEPADUI_QAM = 3, +} - return false; -}); +interface ToastProps { + toast: ToastData; +} -const templateClasses = findModule((mod) => { - if (typeof mod !== 'object') return false; +interface ToastRendererProps extends ToastProps { + location: ToastLocation; +} - if (mod.ShortTemplate) { - return true; - } +const templateClasses = findClassModule((m) => m.ShortTemplate) || {}; - return false; -}); +// These are memoized as they like to randomly rerender -const Toast: FunctionComponent = ({ toast }) => { +const GamepadUIPopupToast: FC = memo(({ toast }) => { return (
{toast.logo &&
{toast.logo}
}
@@ -43,6 +44,62 @@ const Toast: FunctionComponent = ({ toast }) => {
); -}; +}); + +const GamepadUIQAMToast: FC = memo(({ toast }) => { + // The fields aren't mismatched, the logic for these is just a bit weird. + return ( + { + Navigation.CloseSideMenus(); + toast.onClick?.(); + }} + className={joinClassNames( + templateClasses.StandardTemplateContainer, + toast.className || '', + 'DeckyGamepadUIQAMToast', + )} + > +
+ {toast.logo &&
{toast.logo}
} +
+
+ {toast.icon &&
{toast.icon}
} +
+ {toast.header || ( + + )} +
+ {/* timestamp should always be defined by toaster */} + {/* TODO check how valve does this */} + {toast.timestamp && ( +
+ {toast.timestamp.toLocaleTimeString(undefined, { timeStyle: 'short' })} +
+ )} +
+
{toast.title}
+
{toast.body}
+
+ {/* TODO support NewIndicator */} + {/*
+ +
*/} +
+
+ ); +}); + +export const ToastRenderer: FC = memo(({ toast, location }) => { + switch (location) { + default: + logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIPopupToast.`); + case ToastLocation.GAMEPADUI_POPUP: + return ; + case ToastLocation.GAMEPADUI_QAM: + return ; + } +}); -export default Toast; +export default ToastRenderer; diff --git a/frontend/src/components/settings/pages/testing/index.tsx b/frontend/src/components/settings/pages/testing/index.tsx index eb572614..6f52afe3 100644 --- a/frontend/src/components/settings/pages/testing/index.tsx +++ b/frontend/src/components/settings/pages/testing/index.tsx @@ -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: , }); 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: , }); } } diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 8187116e..f9032909 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -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: , onClick: () => Router.Navigate('/decky/settings'), }); } @@ -246,6 +248,7 @@ class PluginLoader extends Logger { i18nArgs={{ count: updates.size }} /> ), + icon: , onClick: () => Router.Navigate('/decky/settings/plugins'), }); } diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx index 4255f257..20d0a4a5 100644 --- a/frontend/src/router-hook.tsx +++ b/frontend/src/router-hook.tsx @@ -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; diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx index 611806d2..b6b29157 100644 --- a/frontend/src/toaster.tsx +++ b/frontend/src/toaster.tsx @@ -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) => ); + return args[0].group.notifications.map((notification: any) => ( + + )); } 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, -- cgit v1.2.3