mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +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();
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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