mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
feat: polish meeting toast design and add dev preview hotkey
This commit is contained in:
parent
e7ea03c8d1
commit
1fca31f1c7
6 changed files with 177 additions and 77 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<script>` can't break the toast", () => {
|
||||
it("escapes HTML in title/subtitle so a Meet titled `<script>` can't break the toast", () => {
|
||||
const html = buildToastHtml({
|
||||
title: "<script>alert(1)</script>",
|
||||
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)`);
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<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; }
|
||||
body { font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; color: #0A0A0A; }
|
||||
.card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(28, 28, 32, 0.96);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
background: #FFFFFF;
|
||||
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);
|
||||
padding: 16px 44px 16px 20px; /* extra right padding to clear the X */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04);
|
||||
-webkit-app-region: drag;
|
||||
animation: slidein 240ms ease-out;
|
||||
animation: slidein 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@keyframes slidein {
|
||||
from { transform: translateY(-12px); opacity: 0; }
|
||||
from { transform: translateY(-20px); 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;
|
||||
.wordmark {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: #0A2540;
|
||||
letter-spacing: -0.01em;
|
||||
-webkit-app-region: drag;
|
||||
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); }
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: #E5E7EB;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #0A0A0A;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subtitle {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: #6B7280;
|
||||
line-height: 1.3;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
a.cta {
|
||||
-webkit-app-region: no-drag;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
background: #0A2540;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 9px 18px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
a.cta:hover { background: #081C33; }
|
||||
a.cta:focus-visible { outline: 2px solid #0A2540; outline-offset: 2px; }
|
||||
a.close {
|
||||
-webkit-app-region: no-drag;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: #4B5563;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
a.close:hover { background: #F3F4F6; }
|
||||
a.close:focus-visible { outline: 2px solid #0A2540; outline-offset: 2px; }
|
||||
a.close svg { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="body">
|
||||
<div class="card" role="alert" aria-live="polite">
|
||||
<div class="wordmark">rowboat</div>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
<div class="text">
|
||||
<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 class="subtitle">${escapeHtml(payload.subtitle)}</div>
|
||||
</div>
|
||||
<a class="cta" href="${escapeAttr(payload.actionLink)}">${escapeHtml(payload.actionLabel)}</a>
|
||||
<a class="close" href="rowboat-toast://dismiss" aria-label="Dismiss meeting notification">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
@ -89,7 +149,6 @@ 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.
|
||||
|
|
@ -122,12 +181,9 @@ export class MeetingToastWindow {
|
|||
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://")) {
|
||||
|
|
@ -139,26 +195,19 @@ export class MeetingToastWindow {
|
|||
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; }
|
||||
});
|
||||
win.on("closed", () => { if (this.win === win) this.win = 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);
|
||||
// No auto-dismiss — persistent until X or CTA click (per spec).
|
||||
}
|
||||
|
||||
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