From 652fa30b2b495ce2e30e5d514cec904defc98fc5 Mon Sep 17 00:00:00 2001 From: ruzin Date: Thu, 23 Jul 2026 19:33:12 +0100 Subject: [PATCH 1/3] feat(notifications): render all notifications via the custom toast Replace Electron's native Notification with an API-compatible custom `Notification` class that drives our own frameless BrowserWindow toast (the existing /notification renderer route), so every notification is visually consistent and theme-aware on macOS and Windows instead of an OS banner. All 8 existing call sites keep their title/body/click/action behavior unchanged (the class emits the same click/action/close events, so trackNotificationLifecycle and each site's handlers still work); each gets an appropriate iconType (alert/success/recording). The pre-meeting reminder migrates off the standalone createNotificationWindow onto the same class (marked premeeting:true to keep its Join/focus + own analytics), and the generic action/body-click bridge is wired via two new send channels (notification-action-clicked / notification-body-clicked). Includes the closure-bug fix: the ready-to-show/closed closures capture the window in a local `win` instead of reading the module-level notificationWindow, so a fast-following toast can't make an earlier one act on the wrong window. Keeps the 15s auto-close and passive-dismiss analytics. Single-toast (superseding) semantics preserved. Cross-platform: reuses the window config the pre-meeting toast already shipped on both platforms; the macOS-only visibleOnFullScreen option and 'screen-saver' level are harmlessly ignored on Windows. Co-authored-by: Vassista --- app/main.js | 284 ++++++++++++++++++++++++++---------- app/preload.js | 2 + app/renderer/src/lib/ipc.ts | 10 +- 3 files changed, 217 insertions(+), 79 deletions(-) diff --git a/app/main.js b/app/main.js index b35b1a7f..4f4ea3a4 100644 --- a/app/main.js +++ b/app/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, safeStorage, Tray, Menu, nativeImage, Notification, powerMonitor, net, session, desktopCapturer } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, safeStorage, Tray, Menu, nativeImage, powerMonitor, net, session, desktopCapturer } = require('electron'); // Prevent EPIPE crashes when stdout/stderr pipe is broken (e.g. launching terminal closed) process.stdout?.on('error', () => {}); @@ -185,6 +185,149 @@ function getOutputDir() { let mainWindow; let notificationWindow; + +// ── Custom notification toast ──────────────────────────────────────────────── +// Drop-in replacement for Electron's `Notification` that renders our OWN +// frameless BrowserWindow toast (renderer route `/notification` → +// NotificationToast.tsx) instead of an OS banner, so every notification is +// visually consistent and theme-aware on macOS AND Windows. It mirrors the +// slice of Electron's Notification API the app relies on: +// new Notification({ title, body, actions, iconType }) +// .show() / .close() / .on('click'|'action'|'close') / static isSupported() +// Being API-compatible means every existing call site — and +// trackNotificationLifecycle — keeps working unchanged; they just render a +// custom UI. See app/renderer/src/components/NotificationToast.tsx for the view. +// +// Events (kept identical to Electron's Notification so trackNotificationLifecycle +// still tells an interaction from a passive dismiss): +// 'click' — the toast body was tapped (renderer → notification-body-clicked) +// 'action' — an action button was tapped (renderer → notification-action-clicked, index arg) +// 'close' — the toast went away by ANY means (body/action click-through, the +// X button, the 15s auto-close, or being superseded) +// +// Single-toast semantics: only one toast window exists at a time; showing a new +// one supersedes (closes) the current one — matching the pre-existing +// pre-meeting toast. +// +// Cross-platform: the window options (transparent, alwaysOnTop, skipTaskbar, +// focusable:false, hasShadow:false, positioned top-right via workArea) and the +// setVisibleOnAllWorkspaces/setAlwaysOnTop calls are all valid on macOS AND +// Windows — the `visibleOnFullScreen` option and the `'screen-saver'` level are +// macOS-only but are harmlessly ignored on Windows. This is the exact window +// config the pre-meeting toast already shipped with on both platforms, so +// there's no new platform-specific surface to gate. +const { EventEmitter } = require('events'); + +class Notification extends EventEmitter { + constructor(options = {}) { + super(); + this.options = options; + this.payload = { + id: `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + title: options.title || '', + body: options.body || options.subtitle || '', + actions: (options.actions || []).map((a) => ({ + // Electron actions have no id; key by text (the app's actions are all + // single-button with a unique label, so this is stable). + id: a.text, + text: a.text, + type: a.type, + })), + }; + if (options.iconType) this.payload.iconType = options.iconType; + } + + show() { + // Supersede any current toast (single-toast semantics). + if (notificationWindow && !notificationWindow.isDestroyed()) { + notificationWindow.close(); + } + + const { screen } = require('electron'); + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + const { x, y } = primaryDisplay.workArea; + + // Capture the window in a local `win` so the ready-to-show / closed + // closures below always reference THIS toast's window — never a later one + // that superseded it via the module-level `notificationWindow`. Reading the + // module-level var inside those closures was the original closure bug: a + // fast-following toast reassigned it, and the earlier toast's timers/handlers + // then acted on the wrong window (closing the new toast, leaking the old). + const win = new BrowserWindow({ + width: 400, + height: 70, + x: x + width - 425, + y: y + 1, + frame: false, + transparent: true, + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, + focusable: false, + hasShadow: false, + backgroundColor: '#00000000', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + preload: path.join(__dirname, 'preload.js'), + }, + }); + notificationWindow = win; + win._activeCustomNotification = this; + // Set true by close-notification-window / the action+body IPC handlers. + // Scoped to this window instance (not a module-level flag) so a superseding + // toast can't leak interaction state across windows. Only the pre-meeting + // path reads it (to avoid double-counting a renderer-tracked dismiss against + // the main-side auto-close dismiss); the other notifications rely on + // trackNotificationLifecycle's own click/dismiss bookkeeping. + win._analyticsInteracted = false; + + const rendererDist = path.join(__dirname, 'renderer', 'dist', 'index.html'); + win.loadFile(rendererDist, { hash: '/notification' }); + + let autoCloseTimer; + // Registered immediately (not inside ready-to-show) so a toast superseded + // BEFORE it finishes loading still emits 'close' and clears the module-level + // ref — the auto-close timer simply hasn't been armed yet in that case. + win.on('closed', () => { + if (autoCloseTimer) clearTimeout(autoCloseTimer); + this.emit('close'); + if (notificationWindow === win) notificationWindow = null; + }); + + win.once('ready-to-show', () => { + win.showInactive(); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + win.setAlwaysOnTop(true, 'screen-saver', 1); + + // Keep the 15s auto-close (matches the pre-existing pre-meeting toast). + autoCloseTimer = setTimeout(() => { + if (!win.isDestroyed()) win.close(); + }, 15000); + + win.webContents.send('show-notification', this.payload); + }); + } + + close() { + // Only act if we're still the active toast — a superseded toast's window is + // already gone, so this is a harmless no-op in that case. + if ( + notificationWindow && + notificationWindow._activeCustomNotification === this && + !notificationWindow.isDestroyed() + ) { + notificationWindow.close(); + } + } + + static isSupported() { + return true; + } +} + let pythonProcess; let tray = null; let isQuitting = false; @@ -5084,8 +5227,9 @@ function showSleepPausedNotification() { const notif = new Notification({ title: 'Recording paused', body: 'Paused while your computer was asleep. Resume to keep capturing.', - // `actions` renders on macOS only; the click handler below covers - // Windows, where the whole notification is the affordance. + iconType: 'alert', + // The Resume action button is always rendered by the custom toast (both + // platforms); the click handler below covers a body tap as well. actions: [{ type: 'button', text: 'Resume' }], }); const resume = () => { @@ -6837,6 +6981,7 @@ ipcMain.handle('show-silence-auto-stop-notification', async (_event, payload) => const notif = new Notification({ title: 'Recording stopped', body, + iconType: 'recording', }); notif.on('click', () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -6866,6 +7011,7 @@ ipcMain.handle('show-system-audio-mic-only-notification', async () => { const notif = new Notification({ title: 'Recording mic-only', body: 'Screen Recording permission is needed to capture both sides of the call. Click to fix this in Settings.', + iconType: 'alert', }); notif.on('click', () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -6916,6 +7062,7 @@ ipcMain.handle('show-note-ready-notification', async (_event, payload) => { : failed ? 'Your recording was preserved — open the note for details.' : (title || 'Your note has finished processing'), + iconType: (hardFailure || failed) ? 'alert' : 'success', }); notif.on('click', () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -8066,6 +8213,7 @@ function showRecordingFailedNotification(body) { new Notification({ title: 'Steno', body: body || "Recording couldn't start.", + iconType: 'alert', }).show(); } catch (error) { console.error('Failed to show recording-failed notification:', error.message); @@ -10080,7 +10228,31 @@ async function firePreMeetingNotification(event) { return false; } - createNotificationWindow(event); + const notif = new Notification({ title: event.title || 'Meeting starting' }); + // The pre-meeting toast carries a richer payload (time / meeting URL / + // attendees) and keeps its legacy renderer-side handlers (Join & take notes, + // focus-on-body-tap) plus its own click/dismiss analytics. `premeeting: true` + // tells NotificationToast to use that path instead of the generic + // action/body-click bridge the other notifications use. + notif.payload.premeeting = true; + notif.payload.time = event.start + ? new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : ''; + notif.payload.meeting_url = event.meeting_url; + notif.payload.attendees = event.attendees + ? event.attendees.map((a) => a.name || a.email).join(', ') + : ''; + // Only count a PASSIVE dismiss here (15s auto-close or being superseded). An + // active click/X-dismiss is tracked by the renderer, which also flags + // _analyticsInteracted via close-notification-window — so skip it here to + // avoid double-counting. This is the same split the old createNotificationWindow + // used, preserved verbatim. + notif.on('close', () => { + if (!notificationWindow || !notificationWindow._analyticsInteracted) { + trackEvent('notification_dismissed', { type: 'premeeting' }); + } + }); + notif.show(); trackEvent('notification_shown', { type: 'premeeting' }); // Mark fired only after we've actually shown it, so an unshowable notif @@ -10089,86 +10261,42 @@ async function firePreMeetingNotification(event) { return true; } -function createNotificationWindow(event) { +// Renderer → main: the active toast was closed by an explicit user action (a +// Join/body tap, an action button, or the X). Flags _analyticsInteracted so the +// pre-meeting path doesn't ALSO count a passive dismiss for the same toast, then +// closes the window (which fires the notification's 'close' event). +ipcMain.handle('close-notification-window', () => { if (notificationWindow && !notificationWindow.isDestroyed()) { + notificationWindow._analyticsInteracted = true; notificationWindow.close(); } +}); - const { screen } = require('electron'); - const primaryDisplay = screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - const { x, y } = primaryDisplay.workArea; - - notificationWindow = new BrowserWindow({ - width: 400, - height: 70, - x: x + width - 425, - y: y + 1, - frame: false, - transparent: true, - alwaysOnTop: true, - resizable: false, - skipTaskbar: true, - focusable: false, - hasShadow: false, - backgroundColor: '#00000000', - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - preload: path.join(__dirname, 'preload.js'), - }, - }); - - const rendererDist = path.join(__dirname, 'renderer', 'dist', 'index.html'); - notificationWindow.loadFile(rendererDist, { hash: '/notification' }); - - const win = notificationWindow; - // Set true by close-notification-window (the renderer's Join/Focus/Close - // handlers all route through it, and each already tracks its own - // notification_clicked/_dismissed via the analytics bridge before calling - // it). Scoped to this window instance -- not a module-level flag -- so a - // new notification superseding an unactioned old one can't leak state - // across windows. Stays false only when the window closes via the 15s - // auto-close timer with no interaction at all, which is the passive- - // dismiss path the native Notification lifecycle already tracks but this - // custom BrowserWindow-based notification previously didn't. - win._analyticsInteracted = false; - let autoCloseTimer; - win.once('ready-to-show', () => { - win.showInactive(); - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - win.setAlwaysOnTop(true, 'screen-saver', 1); - - autoCloseTimer = setTimeout(() => { - if (!win.isDestroyed()) { - win.close(); - } - }, 15000); - - win.on('closed', () => { - if (autoCloseTimer) clearTimeout(autoCloseTimer); - if (!win._analyticsInteracted) { - trackEvent('notification_dismissed', { type: 'premeeting' }); - } - if (notificationWindow === win) { - notificationWindow = null; - } - }); - - win.webContents.send('show-notification', { - title: event.title || 'Meeting starting', - time: event.start ? new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '', - meeting_url: event.meeting_url, - attendees: event.attendees ? event.attendees.map(a => a.name || a.email).join(', ') : '', - }); - }); -} +// Renderer → main: an action button was tapped on the generic (non-pre-meeting) +// toast. Re-emit as the notification's 'action' event (with the button index, +// matching Electron's Notification 'action' signature) so the call site's +// existing `.on('action', ...)` handler + trackNotificationLifecycle both fire. +ipcMain.on('notification-action-clicked', (_event, { actionId, notifId } = {}) => { + if (notificationWindow && !notificationWindow.isDestroyed()) { + const notif = notificationWindow._activeCustomNotification; + if (notif && notif.payload.id === notifId) { + notificationWindow._analyticsInteracted = true; + const index = notif.payload.actions.findIndex((a) => a.id === actionId); + notif.emit('action', {}, index); + } + } +}); -ipcMain.handle('close-notification-window', () => { +// Renderer → main: the body of the generic toast was tapped. Re-emit as the +// notification's 'click' event so the call site's `.on('click', ...)` handler + +// trackNotificationLifecycle both fire. +ipcMain.on('notification-body-clicked', (_event, { notifId } = {}) => { if (notificationWindow && !notificationWindow.isDestroyed()) { - notificationWindow._analyticsInteracted = true; - notificationWindow.close(); + const notif = notificationWindow._activeCustomNotification; + if (notif && notif.payload.id === notifId) { + notificationWindow._analyticsInteracted = true; + notif.emit('click'); + } } }); diff --git a/app/preload.js b/app/preload.js index d5a2f0c0..411c5fd9 100644 --- a/app/preload.js +++ b/app/preload.js @@ -343,6 +343,8 @@ const stenoai = { notification: { close: () => invoke('close-notification-window'), + actionClicked: (actionId, notifId) => send('notification-action-clicked', { actionId, notifId }), + bodyClicked: (notifId) => send('notification-body-clicked', { notifId }), }, // All main-driven events. Every subscribe returns an unsubscribe fn. diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index a2c20afa..f6fa432c 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -1107,10 +1107,16 @@ export interface StenoaiBridge { trayOpenSettings: Subscribe; showQuitDialog: Subscribe<{ type: 'recording' | 'processing'; jobCount?: number }>; showNotification: Subscribe<{ + id?: string; title: string; - time: string; + body?: string; + time?: string; meeting_url?: string; attendees?: string; + premeeting?: boolean; + iconType?: 'app' | 'alert' | 'success' | 'recording'; + color?: string; + actions?: { id: string; text: string; type?: 'primary' | 'secondary' }[]; }>; }; @@ -1140,6 +1146,8 @@ export interface StenoaiBridge { notification: { close: RequestFn<[], void>; + actionClicked: SendFn<[actionId: string, notifId?: string]>; + bodyClicked: SendFn<[notifId?: string]>; }; subscribeQueryStream: ( From a5f0398f162e04595544f95be22aa5261f311f5a Mon Sep 17 00:00:00 2001 From: ruzin Date: Thu, 23 Jul 2026 19:33:19 +0100 Subject: [PATCH 2/3] feat(notifications): generalize NotificationToast for all notification types Render title/body/actions/iconType in the toast. Generic notifications show action buttons (wired to main via notification.actionClicked) and a state icon (alert/success/recording via lucide, or the brand AppIcon for the default), and route body taps through notification.bodyClicked. The pre-meeting reminder keeps its bespoke Join & take notes / focus path and its renderer-side analytics, selected by the premeeting flag. Add a `color` prop to AppIcon so the toast can tint the brand mark, and a small vitest covering the iconType -> icon mapping. Co-authored-by: Vassista --- .../src/components/NotificationToast.test.tsx | 34 ++++ .../src/components/NotificationToast.tsx | 181 +++++++++++++----- app/renderer/src/components/ui/app-icon.tsx | 8 +- 3 files changed, 175 insertions(+), 48 deletions(-) create mode 100644 app/renderer/src/components/NotificationToast.test.tsx diff --git a/app/renderer/src/components/NotificationToast.test.tsx b/app/renderer/src/components/NotificationToast.test.tsx new file mode 100644 index 00000000..be28c695 --- /dev/null +++ b/app/renderer/src/components/NotificationToast.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { AlertCircle, CheckCircle2, Mic, Info } from 'lucide-react'; +import { notificationIconMeta } from './NotificationToast'; + +describe('notificationIconMeta', () => { + it('returns null for the brand app icon (unset or "app")', () => { + expect(notificationIconMeta(undefined)).toBeNull(); + expect(notificationIconMeta('app')).toBeNull(); + }); + + it('maps alert -> red AlertCircle', () => { + const meta = notificationIconMeta('alert'); + expect(meta?.Icon).toBe(AlertCircle); + expect(meta?.className).toContain('red'); + }); + + it('maps success -> green CheckCircle2', () => { + const meta = notificationIconMeta('success'); + expect(meta?.Icon).toBe(CheckCircle2); + expect(meta?.className).toContain('green'); + }); + + it('maps recording -> blue Mic', () => { + const meta = notificationIconMeta('recording'); + expect(meta?.Icon).toBe(Mic); + expect(meta?.className).toContain('blue'); + }); + + it('falls back to Info for an unknown iconType', () => { + // @ts-expect-error - intentionally exercising an out-of-contract value + const meta = notificationIconMeta('bogus'); + expect(meta?.Icon).toBe(Info); + }); +}); diff --git a/app/renderer/src/components/NotificationToast.tsx b/app/renderer/src/components/NotificationToast.tsx index f1e464af..e841ce1c 100644 --- a/app/renderer/src/components/NotificationToast.tsx +++ b/app/renderer/src/components/NotificationToast.tsx @@ -1,12 +1,56 @@ import * as React from 'react'; import { ipc } from '@/lib/ipc'; import { useTheme } from '@/hooks/useTheme'; +import { AppIcon } from '@/components/ui/app-icon'; +import { AlertCircle, CheckCircle2, Mic, Info } from 'lucide-react'; + +export type NotificationIconType = 'app' | 'alert' | 'success' | 'recording'; + +interface NotificationAction { + id: string; + text: string; + type?: 'primary' | 'secondary'; +} interface NotificationData { + id?: string; title: string; - time: string; + body?: string; + time?: string; meeting_url?: string; attendees?: string; + /** The pre-meeting reminder keeps its bespoke Join / focus behavior + its own + * analytics; every other notification is a "generic" toast routed through the + * action/body-click bridge and tracked entirely main-side. */ + premeeting?: boolean; + iconType?: NotificationIconType; + color?: string; + actions?: NotificationAction[]; +} + +type IconComponent = React.ComponentType<{ size?: number; className?: string }>; + +/** + * Map an `iconType` to the lucide icon + tint shown on a generic toast that has + * no action buttons. `'app'` (or unset) renders the brand mark instead, so this + * returns `null` for that case. Exported + pure for unit testing. + */ +export function notificationIconMeta( + iconType?: NotificationIconType, +): { Icon: IconComponent; className: string } | null { + switch (iconType) { + case 'alert': + return { Icon: AlertCircle, className: 'text-red-500' }; + case 'success': + return { Icon: CheckCircle2, className: 'text-green-500' }; + case 'recording': + return { Icon: Mic, className: 'text-blue-500' }; + case 'app': + case undefined: + return null; // brand AppIcon + default: + return { Icon: Info, className: 'text-gray-400' }; + } } export function NotificationToast() { @@ -22,14 +66,21 @@ export function NotificationToast() { if (!data) return null; + const isPremeeting = !!data.premeeting; + const hasValidUrl = !!(data.meeting_url && /^https?:\/\//i.test(data.meeting_url)); + const hasActions = !!(data.actions && data.actions.length > 0); + const handleClose = (e?: React.MouseEvent) => { e?.stopPropagation(); - ipc().analytics.track('notification_dismissed', { type: 'premeeting' }); + // Pre-meeting tracks its own dismiss (main only counts a PASSIVE dismiss for + // it). Generic notifications are tracked entirely main-side via + // trackNotificationLifecycle, so the renderer must NOT double-track them. + if (isPremeeting) { + ipc().analytics.track('notification_dismissed', { type: 'premeeting' }); + } ipc().notification.close(); }; - const hasValidUrl = !!(data.meeting_url && /^https?:\/\//i.test(data.meeting_url)); - const handleJoin = (e?: React.MouseEvent) => { e?.stopPropagation(); ipc().analytics.track('notification_clicked', { type: 'premeeting' }); @@ -40,13 +91,26 @@ export function NotificationToast() { ipc().notification.close(); }; - const handleFocusMain = () => { - ipc().analytics.track('notification_clicked', { type: 'premeeting' }); - ipc().window.focus(); + const handleGenericAction = (actionId: string, e?: React.MouseEvent) => { + e?.stopPropagation(); + // The button's side effect lives on the main-side notification's 'action' + // handler; the renderer only relays the tap, then closes. + ipc().notification.actionClicked(actionId, data.id); + ipc().notification.close(); + }; + + const handleBody = () => { + if (isPremeeting) { + ipc().analytics.track('notification_clicked', { type: 'premeeting' }); + ipc().window.focus(); + } else { + ipc().notification.bodyClicked(data.id); + } ipc().notification.close(); }; const getEventColor = (title: string) => { + if (data.color) return data.color; const colors = ['#10B981', '#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#EF4444', '#06B6D4']; let hash = 0; for (let i = 0; i < title.length; i++) { @@ -56,6 +120,7 @@ export function NotificationToast() { }; const barColor = getEventColor(data.title); + const iconMeta = notificationIconMeta(data.iconType); return ( <> @@ -65,56 +130,80 @@ export function NotificationToast() { } `}
-
- - -
-
-
- - {data.title} - - - {data.time} - -
-
- - {hasValidUrl && ( - )} + +
+
+
+ + {data.title} + + {(data.body || data.time) && ( + + {data.body || data.time} + + )} +
+
+ + {hasActions ? ( +
+ {data.actions!.map((action) => ( + + ))} +
+ ) : isPremeeting ? ( + hasValidUrl ? ( + + ) : null + ) : ( +
+ {iconMeta ? ( + + ) : ( + + )} +
+ )} +
- ); } -function ProfessionalCameraIcon({ className, backgroundColor = '#10B981' }: { className?: string, backgroundColor?: string }) { +function ProfessionalCameraIcon({ className, backgroundColor = '#10B981' }: { className?: string; backgroundColor?: string }) { return ( - - + + ); } diff --git a/app/renderer/src/components/ui/app-icon.tsx b/app/renderer/src/components/ui/app-icon.tsx index 887b82cb..5eb08c97 100644 --- a/app/renderer/src/components/ui/app-icon.tsx +++ b/app/renderer/src/components/ui/app-icon.tsx @@ -3,9 +3,13 @@ import { cn } from '@/lib/utils'; interface AppIconProps { size?: number; className?: string; + /** Stroke color for the mark. Defaults to the themed foreground token; + * pass `currentColor` to inherit the parent's text color (e.g. inside the + * notification toast, which recolors it per state). */ + color?: string; } -export function AppIcon({ size = 80, className }: AppIconProps) { +export function AppIcon({ size = 80, className, color = 'var(--fg-1)' }: AppIconProps) { return ( From 13ba916612dfbc39b370e4d47d4498314d627f52 Mon Sep 17 00:00:00 2001 From: ruzin Date: Thu, 23 Jul 2026 19:58:29 +0100 Subject: [PATCH 3/3] fix(notifications): attribute superseded pre-meeting dismiss to its own toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the pre-meeting 'close' handler's interaction flag from the notification's own window (notif._window) instead of the module-level notificationWindow, which a superseding toast reassigns (resetting _analyticsInteracted) before the earlier toast's 'close' fires — otherwise the dismissal is attributed to the successor's flag, dropping or duplicating notification_dismissed telemetry. Also assert the default-branch className in the notificationIconMeta test so the unknown-iconType fallback is covered symmetrically with the other cases. Co-authored-by: Vassista --- app/main.js | 14 +++++++++++++- .../src/components/NotificationToast.test.tsx | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/main.js b/app/main.js index 4f4ea3a4..6b2ca5a8 100644 --- a/app/main.js +++ b/app/main.js @@ -276,6 +276,11 @@ class Notification extends EventEmitter { }); notificationWindow = win; win._activeCustomNotification = this; + // Also pin the window to the notification instance so a handler bound to + // THIS notif (e.g. the pre-meeting 'close' dismissal-telemetry handler) can + // read its OWN window's flags rather than the module-level `notificationWindow`, + // which a superseding toast reassigns out from under a not-yet-fired 'close'. + this._window = win; // Set true by close-notification-window / the action+body IPC handlers. // Scoped to this window instance (not a module-level flag) so a superseding // toast can't leak interaction state across windows. Only the pre-meeting @@ -10247,8 +10252,15 @@ async function firePreMeetingNotification(event) { // _analyticsInteracted via close-notification-window — so skip it here to // avoid double-counting. This is the same split the old createNotificationWindow // used, preserved verbatim. + // + // Read THIS notif's own window (notif._window), never the module-level + // `notificationWindow`: when this toast is superseded, the successor reassigns + // `notificationWindow` (and resets its `_analyticsInteracted` to false) before + // this 'close' fires, so reading the module-level var would attribute this + // toast's dismissal to the NEXT toast's interaction flag — dropping or + // duplicating the dismiss. The per-instance window keeps the flag correct. notif.on('close', () => { - if (!notificationWindow || !notificationWindow._analyticsInteracted) { + if (!notif._window || !notif._window._analyticsInteracted) { trackEvent('notification_dismissed', { type: 'premeeting' }); } }); diff --git a/app/renderer/src/components/NotificationToast.test.tsx b/app/renderer/src/components/NotificationToast.test.tsx index be28c695..dd121454 100644 --- a/app/renderer/src/components/NotificationToast.test.tsx +++ b/app/renderer/src/components/NotificationToast.test.tsx @@ -30,5 +30,6 @@ describe('notificationIconMeta', () => { // @ts-expect-error - intentionally exercising an out-of-contract value const meta = notificationIconMeta('bogus'); expect(meta?.Icon).toBe(Info); + expect(meta?.className).toContain('gray'); }); });