diff options
| author | AAGaming <aagaming@riseup.net> | 2024-07-26 14:16:05 -0400 |
|---|---|---|
| committer | AAGaming <aagaming@riseup.net> | 2024-08-03 14:04:19 -0400 |
| commit | b93fc8b557baed40a312b51196c600be3daaa6fd (patch) | |
| tree | 9d5f45493d1262639ea7d53a3ce5fab472f5b3a7 | |
| parent | 88e7919a12fd56b297e73afb3fb05483f5893f4d (diff) | |
| download | decky-loader-b93fc8b557baed40a312b51196c600be3daaa6fd.tar.gz decky-loader-b93fc8b557baed40a312b51196c600be3daaa6fd.zip | |
feat(toaster): render notifications in the quick access menu
| -rw-r--r-- | frontend/src/components/DeckyIcon.tsx | 74 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToaster.tsx | 57 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToasterState.tsx | 69 | ||||
| -rw-r--r-- | frontend/src/components/Toast.tsx | 103 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/testing/index.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 3 | ||||
| -rw-r--r-- | frontend/src/router-hook.tsx | 3 | ||||
| -rw-r--r-- | frontend/src/toaster.tsx | 13 |
8 files changed, 133 insertions, 193 deletions
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 ( - <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; 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<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; 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<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> - ); -}; 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<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> ); -}; +}); + +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 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: <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 />, }); } } 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: <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'), }); } 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) => <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, |
