mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-21 20:18:11 +02:00
feat: top-center notion-style toast for meeting-detect prompt
This commit is contained in:
parent
2901379d23
commit
e7ea03c8d1
5 changed files with 272 additions and 2 deletions
|
|
@ -391,12 +391,21 @@ app.whenReady().then(async () => {
|
||||||
initCalendarNotifications();
|
initCalendarNotifications();
|
||||||
|
|
||||||
// start meeting-detect service (mic-in-use detection -> popup asking if user wants notes)
|
// 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();
|
const meetingDetector = createPlatformDetector();
|
||||||
if (meetingDetector) {
|
if (meetingDetector) {
|
||||||
const meetingDetectService = new MeetingDetectService({
|
const meetingDetectService = new MeetingDetectService({
|
||||||
detector: meetingDetector,
|
detector: meetingDetector,
|
||||||
notifier: notificationService,
|
notifier: notificationService,
|
||||||
suppression: new Suppression(),
|
suppression: new Suppression(),
|
||||||
|
toast: USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT ? null : undefined,
|
||||||
});
|
});
|
||||||
meetingDetectService.start().catch((err) => {
|
meetingDetectService.start().catch((err) => {
|
||||||
console.error("[MeetingDetect] failed to start:", err);
|
console.error("[MeetingDetect] failed to start:", err);
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ describe("MeetingDetectService end-to-end", () => {
|
||||||
suppression,
|
suppression,
|
||||||
matchBrowser: async () => null,
|
matchBrowser: async () => null,
|
||||||
correlate: async () => correlated,
|
correlate: async () => correlated,
|
||||||
|
toast: null,
|
||||||
});
|
});
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
|
|
@ -103,6 +104,7 @@ describe("MeetingDetectService end-to-end", () => {
|
||||||
suppression,
|
suppression,
|
||||||
matchBrowser: async () => null, // browser foreground = not a meeting
|
matchBrowser: async () => null, // browser foreground = not a meeting
|
||||||
correlate: async () => null,
|
correlate: async () => null,
|
||||||
|
toast: null,
|
||||||
});
|
});
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
|
|
@ -120,6 +122,7 @@ describe("MeetingDetectService end-to-end", () => {
|
||||||
suppression,
|
suppression,
|
||||||
matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }),
|
matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }),
|
||||||
correlate: async () => null,
|
correlate: async () => null,
|
||||||
|
toast: null,
|
||||||
});
|
});
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
|
|
@ -139,6 +142,7 @@ describe("MeetingDetectService end-to-end", () => {
|
||||||
suppression,
|
suppression,
|
||||||
matchBrowser: async () => null,
|
matchBrowser: async () => null,
|
||||||
correlate: async () => null,
|
correlate: async () => null,
|
||||||
|
toast: null,
|
||||||
});
|
});
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
|
|
@ -151,6 +155,33 @@ describe("MeetingDetectService end-to-end", () => {
|
||||||
expect(notifier.sent).toHaveLength(1);
|
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 () => {
|
it("respects per-app mute", async () => {
|
||||||
await suppression.init();
|
await suppression.init();
|
||||||
await suppression.muteApp("Discord");
|
await suppression.muteApp("Discord");
|
||||||
|
|
@ -161,6 +192,7 @@ describe("MeetingDetectService end-to-end", () => {
|
||||||
suppression,
|
suppression,
|
||||||
matchBrowser: async () => null,
|
matchBrowser: async () => null,
|
||||||
correlate: async () => null,
|
correlate: async () => null,
|
||||||
|
toast: null,
|
||||||
});
|
});
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js";
|
||||||
import { Suppression } from "./suppression.js";
|
import { Suppression } from "./suppression.js";
|
||||||
import type { MeetingAppKind } from "./meeting-apps.js";
|
import type { MeetingAppKind } from "./meeting-apps.js";
|
||||||
import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.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
|
// Glue layer: turns detector events into popup notifications, gated by browser
|
||||||
// tab matching, calendar correlation, and the suppression store.
|
// tab matching, calendar correlation, and the suppression store.
|
||||||
|
|
@ -22,6 +23,10 @@ export interface MeetingDetectServiceOptions {
|
||||||
// Defaults run the real OS-touching versions; tests override.
|
// Defaults run the real OS-touching versions; tests override.
|
||||||
matchBrowser?: Matcher;
|
matchBrowser?: Matcher;
|
||||||
correlate?: Correlator;
|
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 {
|
export class MeetingDetectService {
|
||||||
|
|
@ -30,6 +35,7 @@ export class MeetingDetectService {
|
||||||
private readonly suppression: Suppression;
|
private readonly suppression: Suppression;
|
||||||
private readonly matchBrowser: Matcher;
|
private readonly matchBrowser: Matcher;
|
||||||
private readonly correlate: Correlator;
|
private readonly correlate: Correlator;
|
||||||
|
private readonly toast: { show(payload: ToastPayload): void } | null;
|
||||||
// Track async work spawned from detector events so tests (and shutdown)
|
// Track async work spawned from detector events so tests (and shutdown)
|
||||||
// can wait for it to settle.
|
// can wait for it to settle.
|
||||||
private pending = new Set<Promise<void>>();
|
private pending = new Set<Promise<void>>();
|
||||||
|
|
@ -40,6 +46,9 @@ export class MeetingDetectService {
|
||||||
this.suppression = opts.suppression;
|
this.suppression = opts.suppression;
|
||||||
this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting;
|
this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting;
|
||||||
this.correlate = opts.correlate ?? ((now) => correlateNow(now));
|
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> {
|
async start(): Promise<void> {
|
||||||
|
|
@ -115,11 +124,20 @@ export class MeetingDetectService {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
try {
|
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);
|
await this.suppression.markNotified(event.sessionKey);
|
||||||
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
|
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[MeetingDetect] notify failed:", err);
|
console.error("[MeetingDetect] popup failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
apps/x/apps/main/src/meeting-detect/toast-window.test.ts
Normal file
46
apps/x/apps/main/src/meeting-detect/toast-window.test.ts
Normal 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'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("<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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
165
apps/x/apps/main/src/meeting-detect/toast-window.ts
Normal file
165
apps/x/apps/main/src/meeting-detect/toast-window.ts
Normal 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) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue