diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index c873a1f5..79a7aead 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -50,6 +50,7 @@ import { MeetingDetectService, Suppression, } from "./meeting-detect/index.js"; +import { MeetingToastWindow } from "./meeting-detect/toast-window.js"; import { DEEP_LINK_SCHEME, dispatchUrl, @@ -242,6 +243,29 @@ function createWindow() { setMainWindowForDeepLinks(win); win.on("closed", () => setMainWindowForDeepLinks(null)); + // Dev-only: Ctrl+Shift+T fires a fake meeting-detect toast so we can + // iterate on the toast UI without joining a real Meet. Scoped to the + // main window's input events so it can't collide with browser/OS chords. + if (!app.isPackaged) { + win.webContents.on("before-input-event", (event, input) => { + const isToggle = + input.type === "keyDown" && + input.control && input.shift && !input.alt && !input.meta && + input.key.toLowerCase() === "t"; + if (!isToggle) return; + event.preventDefault(); + new MeetingToastWindow().show({ + title: "You are in a meeting", + subtitle: "Detected on Google Meet", + actionLabel: "Start taking notes", + actionLink: + "rowboat://action?type=take-meeting-notes&title=" + + encodeURIComponent("Meeting Notes - Meet"), + }); + console.log("[MeetingDetect] dev toast triggered (Ctrl+Shift+T)"); + }); + } + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { win.maximize(); diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 6b076889..476730fa 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -34,7 +34,7 @@ describe("buildPopup", () => { it("falls back to ad-hoc copy when no calendar match", () => { const popup = buildPopup("zoom", null, null); - expect(popup?.notify.title).toBe("You're in a meeting"); + expect(popup?.notify.title).toBe("You are in a meeting"); expect(popup?.notify.link).toContain("title="); expect(popup?.notify.link).not.toContain("eventId="); // Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom". @@ -156,10 +156,10 @@ describe("MeetingDetectService end-to-end", () => { }); it("uses the toast renderer when provided instead of the native notifier", async () => { - const calls: Array<{ title: string; message: string; actionLink: string }> = []; + const calls: Array<{ title: string; subtitle: string; actionLink: string }> = []; const toast = { - show(p: { title: string; message: string; actionLabel: string; actionLink: string }) { - calls.push({ title: p.title, message: p.message, actionLink: p.actionLink }); + show(p: { title: string; subtitle: string; actionLabel: string; actionLink: string }) { + calls.push({ title: p.title, subtitle: p.subtitle, actionLink: p.actionLink }); }, }; const service = new MeetingDetectService({ @@ -178,7 +178,8 @@ describe("MeetingDetectService end-to-end", () => { expect(notifier.sent).toHaveLength(0); expect(calls).toHaveLength(1); - expect(calls[0].title).toBe("You're in a meeting"); + expect(calls[0].title).toBe("You are in a meeting"); + expect(calls[0].subtitle).toContain("Zoom"); expect(calls[0].actionLink).toContain("take-meeting-notes"); }); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 37339825..0da141f5 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -126,8 +126,8 @@ export class MeetingDetectService { try { if (this.toast) { this.toast.show({ - title: payload.notify.title, - message: payload.notify.message, + title: payload.toast.title, + subtitle: payload.toast.subtitle, actionLabel: payload.notify.actionLabel, actionLink: payload.notify.link, }); @@ -149,6 +149,13 @@ interface BuiltPopup { link: string; actionLabel: string; }; + // Toast-specific fields (subtitle is the secondary line; the native + // notification API only has one body string, so we collapse title+subtitle + // into `message` when falling back to the OS notifier). + toast: { + title: string; + subtitle: string; + }; } export function buildPopup( @@ -161,13 +168,16 @@ export function buildPopup( if (!platformLabel) return null; if (correlated) { + const toastTitle = correlated.summary; + const toastSubtitle = `On ${platformLabel}`; return { notify: { title: "Take notes for this meeting?", message: `${correlated.summary} — on ${platformLabel}. Click to capture notes with Rowboat.`, link: `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(correlated.eventId)}`, - actionLabel: "Take notes", + actionLabel: "Start taking notes", }, + toast: { title: toastTitle, subtitle: toastSubtitle }, }; } @@ -177,10 +187,14 @@ export function buildPopup( const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`; return { notify: { - title: "You're in a meeting", + title: "You are in a meeting", message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`, link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`, - actionLabel: "Take notes", + actionLabel: "Start taking notes", + }, + toast: { + title: "You are in a meeting", + subtitle: `Detected on ${platformLabel}`, }, }; } diff --git a/apps/x/apps/main/src/meeting-detect/suppression.test.ts b/apps/x/apps/main/src/meeting-detect/suppression.test.ts index ba569fdd..ab521f11 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.test.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.test.ts @@ -26,7 +26,9 @@ describe("Suppression", () => { }); it("respects the dismiss cooldown window", async () => { - const t0 = new Date("2026-05-15T10:00:00Z"); + // Anchor at "now" — gc() filters by wall-clock age, so a hard-coded + // past date would be dropped on persist and the cooldown wouldn't apply. + const t0 = new Date(); await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0); const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts index 3d4c7244..eed24d7f 100644 --- a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts +++ b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts @@ -2,31 +2,41 @@ import { describe, it, expect } from "vitest"; import { buildToastHtml } from "./toast-window.js"; describe("buildToastHtml", () => { - it("renders title, message, action label, and a primary link to the rowboat deeplink", () => { + it("renders title, subtitle, CTA and a link to the rowboat deeplink", () => { const html = buildToastHtml({ - title: "You're in a meeting", - message: "Detected on Google Meet. Click to take notes.", - actionLabel: "Take notes", + title: "You are in a meeting", + subtitle: "Detected on Google Meet", + actionLabel: "Start taking notes", actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet", }); - expect(html).toContain("You're in a meeting"); + expect(html).toContain("You are in a meeting"); expect(html).toContain("Detected on Google Meet"); - expect(html).toContain("Take notes"); + expect(html).toContain("Start taking notes"); expect(html).toContain("rowboat://action?type=take-meeting-notes"); }); + it("includes the rowboat wordmark and accessibility attributes", () => { + const html = buildToastHtml({ + title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action", + }); + expect(html).toContain(">rowboat<"); + expect(html).toContain('role="alert"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain('aria-label="Dismiss meeting notification"'); + }); + it("includes a dismiss link the window will intercept", () => { const html = buildToastHtml({ - title: "x", message: "y", actionLabel: "Go", actionLink: "rowboat://action", + title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action", }); expect(html).toContain("rowboat-toast://dismiss"); }); - it("escapes HTML in title/message so a Meet titled `", - message: "& < > \" '", + subtitle: "& < > \" '", actionLabel: "ok", actionLink: "rowboat://action", }); @@ -37,7 +47,7 @@ describe("buildToastHtml", () => { it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => { const html = buildToastHtml({ - title: "x", message: "y", actionLabel: "ok", + title: "x", subtitle: "y", actionLabel: "ok", actionLink: `rowboat://action?title=evil"onerror=alert(1)`, }); expect(html).not.toContain(`"onerror=alert(1)`); diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.ts b/apps/x/apps/main/src/meeting-detect/toast-window.ts index 2b36252c..f66ae48b 100644 --- a/apps/x/apps/main/src/meeting-detect/toast-window.ts +++ b/apps/x/apps/main/src/meeting-detect/toast-window.ts @@ -1,24 +1,18 @@ import { BrowserWindow, screen } from "electron"; import { dispatchUrl } from "../deeplink.js"; -// Custom Notion-style meeting toast: top-center frameless window with our -// own React-less HTML. We avoid the OS notification API because Windows -// and macOS both force-position native notifications (bottom-right / top- -// right respectively) and we want top-center. +// Notion-style meeting toast: top-center frameless window with our own HTML. +// Persistent — closes only when the user clicks the CTA or the X. // -// Lifecycle: -// show() → opens window, slides in, auto-closes at AUTO_DISMISS_MS -// action button click → navigation to rowboat:// → dispatchUrl + close -// dismiss button click → navigation to rowboat://dismiss → just close +// Spec: white card, top: 24px, max-width 640, slide-down entry animation. -const TOAST_WIDTH = 460; -const TOAST_HEIGHT = 110; -const TOAST_TOP_MARGIN = 56; -const AUTO_DISMISS_MS = 30_000; +const TOAST_WIDTH = 560; +const TOAST_HEIGHT = 92; +const TOAST_TOP_MARGIN = 24; export interface ToastPayload { title: string; - message: string; + subtitle: string; actionLabel: string; actionLink: string; } @@ -31,50 +25,116 @@ export function buildToastHtml(payload: ToastPayload): string {
-