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';
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>
<path
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 Logger from '../logger';
import TranslationHelper, { TranslationClass } from '../utils/TranslationHelper';
const logger = new Logger('ToastRenderer');
@@ -17,6 +16,7 @@ export enum ToastLocation {
interface ToastProps {
toast: ToastData;
newIndicator?: boolean;
}
interface ToastRendererProps extends ToastProps {
@@ -27,7 +27,7 @@ const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
// These are memoized as they like to randomly rerender
const GamepadUIPopupToast: FC<ToastProps> = memo(({ toast }) => {
const GamepadUIPopupToast: FC<Omit<ToastProps, 'newIndicator'>> = memo(({ toast }) => {
return (
<div
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.
return (
<Focusable
onActivate={() => {
Navigation.CloseSideMenus();
toast.onClick?.();
Navigation.CloseSideMenus();
}}
className={joinClassNames(
templateClasses.StandardTemplateContainer,
@@ -65,11 +65,7 @@ const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast }) => {
<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>
{toast.title && <div className={templateClasses.Title}>{toast.title}</div>}
{/* timestamp should always be defined by toaster */}
{/* TODO check how valve does this */}
{toast.timestamp && (
@@ -78,29 +74,30 @@ const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast }) => {
</div>
)}
</div>
<div className={templateClasses.StandardNotificationDescription}>
{toast.fullTemplateTitle || toast.title}
</div>
<div className={templateClasses.StandardNotificationSubText}>{toast.body}</div>
{toast.body && <div className={templateClasses.StandardNotificationDescription}>{toast.body}</div>}
{toast.subtext && <div className={templateClasses.StandardNotificationSubText}>{toast.subtext}</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> */}
{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 }) => {
export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location, newIndicator }) => {
switch (location) {
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:
return <GamepadUIPopupToast toast={toast} />;
case ToastLocation.GAMEPADUI_QAM:
return <GamepadUIQAMToast toast={toast} />;
return <GamepadUIQAMToast toast={toast} newIndicator={newIndicator} />;
}
});
@@ -91,13 +91,14 @@ export default function TestingVersionList() {
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={async () => {
DeckyPluginLoader.toaster.toast({
const downloadToast = DeckyPluginLoader.toaster.toast({
title: t('Testing.start_download_toast', { id: version.id }),
body: null,
icon: <FaFlask />,
});
try {
await downloadTestingVersion(version.id, version.head_sha);
downloadToast.dismiss();
} catch (e) {
if (e instanceof Error) {
DeckyPluginLoader.toaster.toast({
+2 -1
View File
@@ -6,12 +6,13 @@ interface Window {
(async () => {
// Wait for react to definitely be loaded
console.debug('[Decky:Boot] Waiting for React chunk...');
while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) {
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
}
if (!window.SP_REACT) {
console.debug('[Decky:Boot] Setting up React globals...');
console.debug('[Decky:Boot] Setting up Webpack & React globals...');
// deliberate partial import
const DFLWebpack = await import('@decky/ui/dist/webpack');
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 {
ModalRoot,
Navigation,
PanelSection,
PanelSectionRow,
QuickAccessTab,
Router,
findSP,
quickAccessMenuClasses,
showModal,
sleep,
} from '@decky/ui';
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 { 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
private pluginReloadQueue: { name: string; version?: string }[] = [];
private loaderUpdateToast?: ToastNotification;
private pluginUpdateToast?: ToastNotification;
constructor() {
super(PluginLoader.name);
@@ -174,10 +178,6 @@ class PluginLoader extends Logger {
>('loader/get_plugins');
private async loadPlugins() {
// wait for SP window to exist before loading plugins
while (!findSP()) {
await sleep(100);
}
const plugins = await this.getPluginsFromBackend();
const pluginLoadPromises = [];
const loadStart = performance.now();
@@ -210,7 +210,8 @@ class PluginLoader extends Logger {
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.deckyState.setHasLoaderUpdate(true);
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" />,
body: (
<TranslationHelper
@@ -219,8 +220,9 @@ class PluginLoader extends Logger {
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }}
/>
),
icon: <DeckyIcon />,
onClick: () => Router.Navigate('/decky/settings'),
logo: <DeckyIcon />,
icon: <FaDownload />,
onClick: () => Navigation.Navigate('/decky/settings'),
});
}
}
@@ -239,7 +241,8 @@ class PluginLoader extends Logger {
public async notifyPluginUpdates() {
const updates = await this.checkPluginUpdates();
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" />,
body: (
<TranslationHelper
@@ -248,8 +251,9 @@ class PluginLoader extends Logger {
i18nArgs={{ count: updates.size }}
/>
),
icon: <DeckyIcon />,
onClick: () => Router.Navigate('/decky/settings/plugins'),
logo: <DeckyIcon />,
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 Toast from './components/Toast';
@@ -20,8 +20,6 @@ declare global {
}
class Toaster extends Logger {
private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toastPatch?: Patch;
constructor() {
@@ -29,11 +27,7 @@ class Toaster extends Logger {
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
this.init();
}
// TODO maybe move to constructor lol
async init() {
const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
// TODO find a way to undo this if possible?
const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
@@ -41,34 +35,43 @@ class Toaster extends Logger {
this.debug('render toast', args);
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
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;
});
this.log('Initialized');
this.finishStartup?.();
}
async toast(toast: ToastData) {
// 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,
};
toast(toast: ToastData): ToastNotification {
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 (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) {
let group = {
group = {
eType: toast.eType,
notifications: [toast],
};
@@ -83,7 +86,31 @@ class Toaster extends Logger {
bCritical: toast.critical,
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);
return toastResult;
}
deinit() {