feat(toaster):add support for dismissing toasts and new indicator

This commit is contained in:
AAGaming
2024-07-28 18:19:55 -04:00
parent 4c23549748
commit 4cf80595ad
6 changed files with 88 additions and 58 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { FC, SVGAttributes } from 'react'; import { FC, SVGAttributes } from 'react';
const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => ( const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456" {...props}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" {...props}>
<g> <g>
<path <path
style={{ fill: 'none' }} style={{ fill: 'none' }}
+18 -21
View File
@@ -3,7 +3,6 @@ import { Focusable, Navigation, findClassModule, joinClassNames } from '@decky/u
import { FC, memo } from 'react'; import { FC, memo } from 'react';
import Logger from '../logger'; import Logger from '../logger';
import TranslationHelper, { TranslationClass } from '../utils/TranslationHelper';
const logger = new Logger('ToastRenderer'); const logger = new Logger('ToastRenderer');
@@ -17,6 +16,7 @@ export enum ToastLocation {
interface ToastProps { interface ToastProps {
toast: ToastData; toast: ToastData;
newIndicator?: boolean;
} }
interface ToastRendererProps extends ToastProps { interface ToastRendererProps extends ToastProps {
@@ -27,7 +27,7 @@ const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
// These are memoized as they like to randomly rerender // These are memoized as they like to randomly rerender
const GamepadUIPopupToast: FC<ToastProps> = memo(({ toast }) => { const GamepadUIPopupToast: FC<Omit<ToastProps, 'newIndicator'>> = memo(({ toast }) => {
return ( return (
<div <div
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties} style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
@@ -46,13 +46,13 @@ const GamepadUIPopupToast: FC<ToastProps> = memo(({ toast }) => {
); );
}); });
const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast }) => { const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast, newIndicator }) => {
// The fields aren't mismatched, the logic for these is just a bit weird. // The fields aren't mismatched, the logic for these is just a bit weird.
return ( return (
<Focusable <Focusable
onActivate={() => { onActivate={() => {
Navigation.CloseSideMenus();
toast.onClick?.(); toast.onClick?.();
Navigation.CloseSideMenus();
}} }}
className={joinClassNames( className={joinClassNames(
templateClasses.StandardTemplateContainer, templateClasses.StandardTemplateContainer,
@@ -65,11 +65,7 @@ const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast }) => {
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}> <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
<div className={templateClasses.Header}> <div className={templateClasses.Header}>
{toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>} {toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
<div className={templateClasses.Title}> {toast.title && <div className={templateClasses.Title}>{toast.title}</div>}
{toast.header || (
<TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />
)}
</div>
{/* timestamp should always be defined by toaster */} {/* timestamp should always be defined by toaster */}
{/* TODO check how valve does this */} {/* TODO check how valve does this */}
{toast.timestamp && ( {toast.timestamp && (
@@ -78,29 +74,30 @@ const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast }) => {
</div> </div>
)} )}
</div> </div>
<div className={templateClasses.StandardNotificationDescription}> {toast.body && <div className={templateClasses.StandardNotificationDescription}>{toast.body}</div>}
{toast.fullTemplateTitle || toast.title} {toast.subtext && <div className={templateClasses.StandardNotificationSubText}>{toast.subtext}</div>}
</div>
<div className={templateClasses.StandardNotificationSubText}>{toast.body}</div>
</div> </div>
{/* TODO support NewIndicator */} {newIndicator && (
{/* <div className={templateClasses.NewIndicator}><svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" <div className={templateClasses.NewIndicator}>
viewBox="0 0 50 50" fill="none"> <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> <circle fill="currentColor" cx="25" cy="25" r="25"></circle>
</svg></div> */} </svg>
</div>
)}
</div> </div>
</Focusable> </Focusable>
); );
}); });
export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location }) => { export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location, newIndicator }) => {
switch (location) { switch (location) {
default: default:
logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIPopupToast.`); logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIQAMToast.`);
return <GamepadUIQAMToast toast={toast} newIndicator={false} />;
case ToastLocation.GAMEPADUI_POPUP: case ToastLocation.GAMEPADUI_POPUP:
return <GamepadUIPopupToast toast={toast} />; return <GamepadUIPopupToast toast={toast} />;
case ToastLocation.GAMEPADUI_QAM: case ToastLocation.GAMEPADUI_QAM:
return <GamepadUIQAMToast toast={toast} />; return <GamepadUIQAMToast toast={toast} newIndicator={newIndicator} />;
} }
}); });
@@ -91,13 +91,14 @@ export default function TestingVersionList() {
<DialogButton <DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }} style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={async () => { onClick={async () => {
DeckyPluginLoader.toaster.toast({ const downloadToast = DeckyPluginLoader.toaster.toast({
title: t('Testing.start_download_toast', { id: version.id }), title: t('Testing.start_download_toast', { id: version.id }),
body: null, body: null,
icon: <FaFlask />, icon: <FaFlask />,
}); });
try { try {
await downloadTestingVersion(version.id, version.head_sha); await downloadTestingVersion(version.id, version.head_sha);
downloadToast.dismiss();
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
DeckyPluginLoader.toaster.toast({ DeckyPluginLoader.toaster.toast({
+2 -1
View File
@@ -6,12 +6,13 @@ interface Window {
(async () => { (async () => {
// Wait for react to definitely be loaded // Wait for react to definitely be loaded
console.debug('[Decky:Boot] Waiting for React chunk...');
while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) { while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) {
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here. await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
} }
if (!window.SP_REACT) { if (!window.SP_REACT) {
console.debug('[Decky:Boot] Setting up React globals...'); console.debug('[Decky:Boot] Setting up Webpack & React globals...');
// deliberate partial import // deliberate partial import
const DFLWebpack = await import('@decky/ui/dist/webpack'); const DFLWebpack = await import('@decky/ui/dist/webpack');
window.SP_REACT = DFLWebpack.findModule((m) => m.Component && m.PureComponent && m.useLayoutEffect); window.SP_REACT = DFLWebpack.findModule((m) => m.Component && m.PureComponent && m.useLayoutEffect);
+16 -12
View File
@@ -1,16 +1,17 @@
import { ToastNotification } from '@decky/api';
import { import {
ModalRoot, ModalRoot,
Navigation,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
QuickAccessTab, QuickAccessTab,
Router,
findSP, findSP,
quickAccessMenuClasses, quickAccessMenuClasses,
showModal, showModal,
sleep, sleep,
} from '@decky/ui'; } from '@decky/ui';
import { FC, lazy } from 'react'; import { FC, lazy } from 'react';
import { FaExclamationCircle, FaPlug } from 'react-icons/fa'; import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa';
import DeckyIcon from './components/DeckyIcon'; import DeckyIcon from './components/DeckyIcon';
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState'; import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
@@ -80,6 +81,9 @@ class PluginLoader extends Logger {
// stores a list of plugin names which requested to be reloaded // stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: { name: string; version?: string }[] = []; private pluginReloadQueue: { name: string; version?: string }[] = [];
private loaderUpdateToast?: ToastNotification;
private pluginUpdateToast?: ToastNotification;
constructor() { constructor() {
super(PluginLoader.name); super(PluginLoader.name);
@@ -174,10 +178,6 @@ class PluginLoader extends Logger {
>('loader/get_plugins'); >('loader/get_plugins');
private async loadPlugins() { private async loadPlugins() {
// wait for SP window to exist before loading plugins
while (!findSP()) {
await sleep(100);
}
const plugins = await this.getPluginsFromBackend(); const plugins = await this.getPluginsFromBackend();
const pluginLoadPromises = []; const pluginLoadPromises = [];
const loadStart = performance.now(); const loadStart = performance.now();
@@ -210,7 +210,8 @@ class PluginLoader extends Logger {
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) { if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.deckyState.setHasLoaderUpdate(true); this.deckyState.setHasLoaderUpdate(true);
if (this.notificationService.shouldNotify('deckyUpdates')) { if (this.notificationService.shouldNotify('deckyUpdates')) {
this.toaster.toast({ this.loaderUpdateToast && this.loaderUpdateToast.dismiss();
this.loaderUpdateToast = this.toaster.toast({
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />, title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
body: ( body: (
<TranslationHelper <TranslationHelper
@@ -219,8 +220,9 @@ class PluginLoader extends Logger {
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }} i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }}
/> />
), ),
icon: <DeckyIcon />, logo: <DeckyIcon />,
onClick: () => Router.Navigate('/decky/settings'), icon: <FaDownload />,
onClick: () => Navigation.Navigate('/decky/settings'),
}); });
} }
} }
@@ -239,7 +241,8 @@ class PluginLoader extends Logger {
public async notifyPluginUpdates() { public async notifyPluginUpdates() {
const updates = await this.checkPluginUpdates(); const updates = await this.checkPluginUpdates();
if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) { if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) {
this.toaster.toast({ this.pluginUpdateToast && this.pluginUpdateToast.dismiss();
this.pluginUpdateToast = this.toaster.toast({
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />, title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
body: ( body: (
<TranslationHelper <TranslationHelper
@@ -248,8 +251,9 @@ class PluginLoader extends Logger {
i18nArgs={{ count: updates.size }} i18nArgs={{ count: updates.size }}
/> />
), ),
icon: <DeckyIcon />, logo: <DeckyIcon />,
onClick: () => Router.Navigate('/decky/settings/plugins'), icon: <FaDownload />,
onClick: () => Navigation.Navigate('/decky/settings/plugins'),
}); });
} }
} }
+49 -22
View File
@@ -1,4 +1,4 @@
import type { ToastData } from '@decky/api'; import type { ToastData, ToastNotification } from '@decky/api';
import { Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui'; import { Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui';
import Toast from './components/Toast'; import Toast from './components/Toast';
@@ -20,8 +20,6 @@ declare global {
} }
class Toaster extends Logger { class Toaster extends Logger {
private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toastPatch?: Patch; private toastPatch?: Patch;
constructor() { constructor() {
@@ -29,11 +27,7 @@ class Toaster extends Logger {
window.__TOASTER_INSTANCE?.deinit?.(); window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this; window.__TOASTER_INSTANCE = this;
this.init();
}
// TODO maybe move to constructor lol
async init() {
const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`)); const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
// TODO find a way to undo this if possible? // TODO find a way to undo this if possible?
const patchedRenderer = injectFCTrampoline(ValveToastRenderer); const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
@@ -41,34 +35,43 @@ class Toaster extends Logger {
this.debug('render toast', args); this.debug('render toast', args);
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) { if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
return args[0].group.notifications.map((notification: any) => ( return args[0].group.notifications.map((notification: any) => (
<Toast toast={notification.data} location={args?.[0]?.location} /> <Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
)); ));
} }
return callOriginal; return callOriginal;
}); });
this.log('Initialized'); this.log('Initialized');
this.finishStartup?.();
} }
async toast(toast: ToastData) { toast(toast: ToastData): ToastNotification {
// toast.duration = toast.duration || 5e3;
// this.toasterState.addToast(toast);
await this.ready;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
eType: toast.eType || 11,
nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast,
decky: true,
};
if (toast.sound === undefined) toast.sound = 6; if (toast.sound === undefined) toast.sound = 6;
if (toast.playSound === undefined) toast.playSound = true; if (toast.playSound === undefined) toast.playSound = true;
if (toast.showToast === undefined) toast.showToast = true; if (toast.showToast === undefined) toast.showToast = true;
if (toast.timestamp === undefined) toast.timestamp = new Date(); if (toast.timestamp === undefined) toast.timestamp = new Date();
if (toast.showNewIndicator === undefined) toast.showNewIndicator = true;
/* eType 13
13: {
proto: m.mu,
fnTray: null,
showToast: !0,
sound: f.PN.ToastMisc,
eFeature: l.uX
}
*/
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
bNewIndicator: toast.showNewIndicator,
rtCreated: Date.now(),
eType: toast.eType || 13,
eSource: 1, // Client
nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast,
decky: true,
};
let group: any;
function fnTray(toast: any, tray: any) { function fnTray(toast: any, tray: any) {
let group = { group = {
eType: toast.eType, eType: toast.eType,
notifications: [toast], notifications: [toast],
}; };
@@ -83,7 +86,31 @@ class Toaster extends Logger {
bCritical: toast.critical, bCritical: toast.critical,
fnTray, fnTray,
}; };
const self = this;
let expirationTimeout: number;
const toastResult: ToastNotification = {
data: toast,
dismiss() {
// it checks against the id of notifications[0]
try {
expirationTimeout && clearTimeout(expirationTimeout);
group && window.NotificationStore.RemoveGroupFromTray(group);
} catch (e) {
self.error('Error while dismissing toast:', e);
}
},
};
if (toast.expiration) {
expirationTimeout = setTimeout(() => {
try {
group && window.NotificationStore.RemoveGroupFromTray(group);
} catch (e) {
this.error('Error while dismissing expired toast:', e);
}
}, toast.expiration);
}
window.NotificationStore.ProcessNotification(info, toastData, ToastType.New); window.NotificationStore.ProcessNotification(info, toastData, ToastType.New);
return toastResult;
} }
deinit() { deinit() {