feat: top-center notion-style toast for meeting-detect prompt

This commit is contained in:
Gagancreates 2026-05-15 15:47:00 +05:30 committed by Arjun
parent 2901379d23
commit e7ea03c8d1
5 changed files with 272 additions and 2 deletions

View file

@ -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);

View file

@ -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();

View file

@ -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<Promise<void>>();
@ -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<void> {
@ -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);
}
}
}

View file

@ -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&#39;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 `<script>` can't break the toast", () => {
const html = buildToastHtml({
title: "<script>alert(1)</script>",
message: "& < > \" '",
actionLabel: "ok",
actionLink: "rowboat://action",
});
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).toContain("&amp; &lt; &gt; &quot; &#39;");
});
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("&quot;onerror=alert(1)");
});
});

View file

@ -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 `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { margin: 0; padding: 0; height: 100%; background: transparent; overflow: hidden; }
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #fff; }
.card {
box-sizing: border-box;
width: 100%; height: 100%;
background: rgba(28, 28, 32, 0.96);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 14px 18px;
display: flex; align-items: center; gap: 14px;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
backdrop-filter: blur(18px);
-webkit-app-region: drag;
animation: slidein 240ms ease-out;
}
@keyframes slidein {
from { transform: translateY(-12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.body { flex: 1; min-width: 0; -webkit-app-region: no-drag; }
.title { font-size: 13px; font-weight: 600; line-height: 1.2; }
.msg { font-size: 12px; opacity: 0.72; margin-top: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; gap: 8px; -webkit-app-region: no-drag; }
a.btn {
display: inline-block; padding: 7px 14px; border-radius: 8px;
font-size: 12px; font-weight: 600; text-decoration: none;
cursor: pointer; user-select: none;
}
a.primary { background: #4f8cff; color: #fff; }
a.primary:hover { background: #6499ff; }
a.secondary { background: rgba(255,255,255,0.08); color: #e8e8e8; }
a.secondary:hover { background: rgba(255,255,255,0.12); }
</style>
</head>
<body>
<div class="card">
<div class="body">
<div class="title">${escapeHtml(payload.title)}</div>
<div class="msg">${escapeHtml(payload.message)}</div>
</div>
<div class="actions">
<a class="btn secondary" href="rowboat-toast://dismiss">Dismiss</a>
<a class="btn primary" href="${escapeAttr(payload.actionLink)}">${escapeHtml(payload.actionLabel)}</a>
</div>
</div>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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;
}
}