mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-11 07:21:34 -07:00
fix(notification): replace repeated desktop status toasts
- Add optional replaceId to showDesktopNotification; a repeated id closes the prior toast instead of stacking a new one - Character dictionary auto-sync now shares one replaceId across checking/generating/importing/ready phases - Startup OSD sequencer passes its lane id as replaceId so each startup lane keeps a single live notification
This commit is contained in:
@@ -52,9 +52,15 @@ function resolveRuntimeDefaultNotificationIconPath(): string | null {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Live notifications keyed by `replaceId`. Electron exposes no native "replace this notification"
|
||||
* flag, so a repeated status closes its predecessor instead of stacking a fresh toast per update.
|
||||
*/
|
||||
const notificationsByReplaceId = new Map<string, Electron.Notification>();
|
||||
|
||||
export function showDesktopNotification(
|
||||
title: string,
|
||||
options: { body?: string; icon?: string },
|
||||
options: { body?: string; icon?: string; replaceId?: string },
|
||||
): void {
|
||||
const notificationOptions: {
|
||||
title: string;
|
||||
@@ -98,5 +104,15 @@ export function showDesktopNotification(
|
||||
}
|
||||
|
||||
const notification = new Notification(notificationOptions);
|
||||
const replaceId = options.replaceId?.trim();
|
||||
if (replaceId) {
|
||||
notificationsByReplaceId.get(replaceId)?.close();
|
||||
notificationsByReplaceId.set(replaceId, notification);
|
||||
notification.once('close', () => {
|
||||
if (notificationsByReplaceId.get(replaceId) === notification) {
|
||||
notificationsByReplaceId.delete(replaceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
notification.show();
|
||||
}
|
||||
|
||||
@@ -214,3 +214,69 @@ test('auto sync notifications let startup sequencer own osd-system desktop deliv
|
||||
|
||||
assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']);
|
||||
});
|
||||
|
||||
test('auto sync desktop notifications reuse one replace id across every phase', () => {
|
||||
const replaceIds: Array<string | undefined> = [];
|
||||
const deps = {
|
||||
getNotificationType: () => 'system' as const,
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: (_title: string, options: { body?: string; replaceId?: string }) => {
|
||||
replaceIds.push(options.replaceId);
|
||||
},
|
||||
};
|
||||
|
||||
for (const phase of ['checking', 'generating', 'importing', 'ready'] as const) {
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent(phase, phase), deps);
|
||||
}
|
||||
|
||||
assert.deepEqual(replaceIds, [
|
||||
'character-dictionary-auto-sync',
|
||||
'character-dictionary-auto-sync',
|
||||
'character-dictionary-auto-sync',
|
||||
'character-dictionary-auto-sync',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay-unavailable desktop fallback shares the same replace id', () => {
|
||||
const replaceIds: Array<string | undefined> = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||
getNotificationType: () => 'overlay',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: (_title, options) => {
|
||||
replaceIds.push(options.replaceId);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(replaceIds, ['character-dictionary-auto-sync']);
|
||||
});
|
||||
|
||||
test('startup lanes keep one desktop notification per lane', () => {
|
||||
const calls: Array<{ body?: string; replaceId?: string }> = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
getNotificationType: () => 'system',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: (_title, options) => {
|
||||
calls.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.markTokenizationReady();
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating one'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: () => undefined,
|
||||
startupOsdSequencer: sequencer,
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating two'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: () => undefined,
|
||||
startupOsdSequencer: sequencer,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.replaceId),
|
||||
['startup-status', 'startup-status'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; replaceId?: string }) => void;
|
||||
startupOsdSequencer?: {
|
||||
notifyCharacterDictionaryStatus: (
|
||||
event: StartupOsdSequencerCharacterDictionaryEvent,
|
||||
@@ -17,6 +17,10 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
};
|
||||
}
|
||||
|
||||
// One live desktop notification for the whole sync: progress updates replace each other and the
|
||||
// terminal ready/failed message replaces the last progress one, matching the overlay toast.
|
||||
const CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID = 'character-dictionary-auto-sync';
|
||||
|
||||
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
|
||||
return phase === 'ready' || phase === 'failed';
|
||||
}
|
||||
@@ -53,7 +57,10 @@ export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
persistent: !isTerminalPhase(event.phase),
|
||||
});
|
||||
} else if (!shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
body: event.message,
|
||||
replaceId: CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +76,9 @@ export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
}
|
||||
|
||||
if (shouldShowDesktop(type) && !startupSequencerShown) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
body: event.message,
|
||||
replaceId: CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface StartupOsdSequencerDeps {
|
||||
getNotificationType?: () => NotificationType | undefined;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification?: (title: string, options: { body?: string }) => void;
|
||||
showDesktopNotification?: (title: string, options: { body?: string; replaceId?: string }) => void;
|
||||
}
|
||||
|
||||
interface StartupStatusNotificationOptions {
|
||||
@@ -62,7 +62,11 @@ export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
|
||||
shown = deps.showOsd(options.message) !== false || shown;
|
||||
}
|
||||
if (options.desktop !== false && shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification?.('SubMiner', { body: options.message });
|
||||
// Each startup lane keeps one live desktop notification instead of one per update.
|
||||
deps.showDesktopNotification?.('SubMiner', {
|
||||
body: options.message,
|
||||
replaceId: options.id,
|
||||
});
|
||||
shown = true;
|
||||
}
|
||||
return shown;
|
||||
|
||||
Reference in New Issue
Block a user