From e7ea03c8d1492e71b283f3d391da7addd5380baa Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 15:47:00 +0530 Subject: [PATCH] feat: top-center notion-style toast for meeting-detect prompt --- apps/x/apps/main/src/main.ts | 9 + .../main/src/meeting-detect/service.test.ts | 32 ++++ .../x/apps/main/src/meeting-detect/service.ts | 22 ++- .../src/meeting-detect/toast-window.test.ts | 46 +++++ .../main/src/meeting-detect/toast-window.ts | 165 ++++++++++++++++++ 5 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/toast-window.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/toast-window.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index a5e9eb5f..c873a1f5 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -391,12 +391,21 @@ app.whenReady().then(async () => { initCalendarNotifications(); // start meeting-detect service (mic-in-use detection -> popup asking if user wants notes) + // + // Popup style — flip this one constant to switch the meeting-detect prompt + // between the custom Notion-style top-center toast and the native OS + // notification. Doesn't affect the separate calendar 1-min warnings. + // false (default) → custom toast + // true → native OS notification + const USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT = false; + const meetingDetector = createPlatformDetector(); if (meetingDetector) { const meetingDetectService = new MeetingDetectService({ detector: meetingDetector, notifier: notificationService, suppression: new Suppression(), + toast: USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT ? null : undefined, }); meetingDetectService.start().catch((err) => { console.error("[MeetingDetect] failed to start:", err); 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 7586efde..6b076889 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -83,6 +83,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => correlated, + toast: null, }); await service.start(); @@ -103,6 +104,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, // browser foreground = not a meeting correlate: async () => null, + toast: null, }); await service.start(); @@ -120,6 +122,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }), correlate: async () => null, + toast: null, }); await service.start(); @@ -139,6 +142,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => null, + toast: null, }); await service.start(); @@ -151,6 +155,33 @@ describe("MeetingDetectService end-to-end", () => { expect(notifier.sent).toHaveLength(1); }); + it("uses the toast renderer when provided instead of the native notifier", async () => { + const calls: Array<{ title: string; message: 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 }); + }, + }; + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, + correlate: async () => null, + toast, + }); + await service.start(); + + probe.next = [{ executable: "zoom.us", pid: 100 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(0); + expect(calls).toHaveLength(1); + expect(calls[0].title).toBe("You're in a meeting"); + expect(calls[0].actionLink).toContain("take-meeting-notes"); + }); + it("respects per-app mute", async () => { await suppression.init(); await suppression.muteApp("Discord"); @@ -161,6 +192,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => null, + toast: null, }); await service.start(); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 5e38c521..37339825 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -5,6 +5,7 @@ import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; import { Suppression } from "./suppression.js"; import type { MeetingAppKind } from "./meeting-apps.js"; import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; +import { MeetingToastWindow, type ToastPayload } from "./toast-window.js"; // Glue layer: turns detector events into popup notifications, gated by browser // tab matching, calendar correlation, and the suppression store. @@ -22,6 +23,10 @@ export interface MeetingDetectServiceOptions { // Defaults run the real OS-touching versions; tests override. matchBrowser?: Matcher; correlate?: Correlator; + // Custom popup renderer. When provided (default in production), the toast + // is used instead of the native OS notification. Tests pass null to fall + // back to the notifier and assert on its calls. + toast?: { show(payload: ToastPayload): void } | null; } export class MeetingDetectService { @@ -30,6 +35,7 @@ export class MeetingDetectService { private readonly suppression: Suppression; private readonly matchBrowser: Matcher; private readonly correlate: Correlator; + private readonly toast: { show(payload: ToastPayload): void } | null; // Track async work spawned from detector events so tests (and shutdown) // can wait for it to settle. private pending = new Set>(); @@ -40,6 +46,9 @@ export class MeetingDetectService { this.suppression = opts.suppression; this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting; this.correlate = opts.correlate ?? ((now) => correlateNow(now)); + // `toast` is explicitly nullable so tests can opt out. Undefined → + // build the real one. Null → use the native notifier instead. + this.toast = opts.toast === undefined ? new MeetingToastWindow() : opts.toast; } async start(): Promise { @@ -115,11 +124,20 @@ export class MeetingDetectService { if (!payload) return; try { - this.notifier.notify(payload.notify); + if (this.toast) { + this.toast.show({ + title: payload.notify.title, + message: payload.notify.message, + actionLabel: payload.notify.actionLabel, + actionLink: payload.notify.link, + }); + } else { + this.notifier.notify(payload.notify); + } await this.suppression.markNotified(event.sessionKey); console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`); } catch (err) { - console.error("[MeetingDetect] notify failed:", err); + console.error("[MeetingDetect] popup failed:", err); } } } 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 new file mode 100644 index 00000000..3d4c7244 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts @@ -0,0 +1,46 @@ +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", () => { + const html = buildToastHtml({ + title: "You're in a meeting", + message: "Detected on Google Meet. Click to take notes.", + actionLabel: "Take notes", + actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet", + }); + + expect(html).toContain("You're in a meeting"); + expect(html).toContain("Detected on Google Meet"); + expect(html).toContain("Take notes"); + expect(html).toContain("rowboat://action?type=take-meeting-notes"); + }); + + it("includes a dismiss link the window will intercept", () => { + const html = buildToastHtml({ + title: "x", message: "y", actionLabel: "Go", actionLink: "rowboat://action", + }); + expect(html).toContain("rowboat-toast://dismiss"); + }); + + it("escapes HTML in title/message so a Meet titled `", + message: "& < > \" '", + actionLabel: "ok", + actionLink: "rowboat://action", + }); + expect(html).not.toContain(""); + expect(html).toContain("<script>alert(1)</script>"); + expect(html).toContain("& < > " '"); + }); + + 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", + actionLink: `rowboat://action?title=evil"onerror=alert(1)`, + }); + expect(html).not.toContain(`"onerror=alert(1)`); + expect(html).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 new file mode 100644 index 00000000..2b36252c --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/toast-window.ts @@ -0,0 +1,165 @@ +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. +// +// 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 + +const TOAST_WIDTH = 460; +const TOAST_HEIGHT = 110; +const TOAST_TOP_MARGIN = 56; +const AUTO_DISMISS_MS = 30_000; + +export interface ToastPayload { + title: string; + message: string; + actionLabel: string; + actionLink: string; +} + +/** Build the self-contained HTML the toast window renders. Pure — tested. */ +export function buildToastHtml(payload: ToastPayload): string { + return ` + + + + + + +
+
+
${escapeHtml(payload.title)}
+
${escapeHtml(payload.message)}
+
+ +
+ +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c]!)); +} +function escapeAttr(s: string): string { return escapeHtml(s); } + +export class MeetingToastWindow { + private win: BrowserWindow | null = null; + private timer: NodeJS.Timeout | null = null; + + show(payload: ToastPayload): void { + // If a previous toast is still up, replace it. + this.closeImmediate(); + + const display = screen.getPrimaryDisplay(); + const wa = display.workArea; + const x = Math.round(wa.x + (wa.width - TOAST_WIDTH) / 2); + const y = wa.y + TOAST_TOP_MARGIN; + + const win = new BrowserWindow({ + width: TOAST_WIDTH, + height: TOAST_HEIGHT, + x, y, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + focusable: false, + show: false, + hasShadow: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + // alwaysOnTop with screen-saver level so it floats above full-screen apps too. + win.setAlwaysOnTop(true, "screen-saver"); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + // Intercept the action links — both rowboat:// (real deeplinks) and + // rowboat-toast://dismiss (our internal dismiss signal). + win.webContents.on("will-navigate", (event, url) => { + event.preventDefault(); + if (url.startsWith("rowboat-toast://")) { + this.closeImmediate(); + return; + } + if (url.startsWith("rowboat://")) { + dispatchUrl(url); + this.closeImmediate(); + return; + } + // Anything else (shouldn't happen with our own HTML) — ignore. + }); + + win.once("ready-to-show", () => win.show()); + win.on("closed", () => { + if (this.win === win) this.win = null; + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + }); + + const html = buildToastHtml(payload); + // data: URL so we don't have to ship a separate file. Encoded so the + // protocol parser doesn't choke on quotes / hashes in the payload. + win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + + this.win = win; + this.timer = setTimeout(() => this.closeImmediate(), AUTO_DISMISS_MS); + } + + closeImmediate(): void { + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + if (this.win && !this.win.isDestroyed()) this.win.close(); + this.win = null; + } +}