mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-11 13:01:59 +00:00
feat(toaster):add support for dismissing toasts and new indicator
This commit is contained in:
@@ -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' }}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user