mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 12:15:09 +03: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';
|
||||
|
||||
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' }}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user