mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +02:00
option to join meeting and take notes
This commit is contained in:
parent
d363363b70
commit
0107dc5dbf
7 changed files with 84 additions and 36 deletions
|
|
@ -54,12 +54,12 @@ export function dispatchDeepLink(url: string): void {
|
||||||
pendingUrl = null;
|
pendingUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TakeMeetingNotesAction {
|
interface MeetingNotesAction {
|
||||||
type: "take-meeting-notes";
|
type: "take-meeting-notes" | "join-and-take-meeting-notes";
|
||||||
eventId: string;
|
eventId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParsedAction = TakeMeetingNotesAction;
|
type ParsedAction = MeetingNotesAction;
|
||||||
|
|
||||||
function parseAction(url: string): ParsedAction | null {
|
function parseAction(url: string): ParsedAction | null {
|
||||||
if (!url.startsWith(URL_PREFIX)) return null;
|
if (!url.startsWith(URL_PREFIX)) return null;
|
||||||
|
|
@ -69,7 +69,7 @@ function parseAction(url: string): ParsedAction | null {
|
||||||
if (host !== ACTION_HOST) return null;
|
if (host !== ACTION_HOST) return null;
|
||||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||||
const type = params.get("type");
|
const type = params.get("type");
|
||||||
if (type === "take-meeting-notes") {
|
if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") {
|
||||||
const eventId = params.get("eventId");
|
const eventId = params.get("eventId");
|
||||||
return eventId ? { type, eventId } : null;
|
return eventId ? { type, eventId } : null;
|
||||||
}
|
}
|
||||||
|
|
@ -80,12 +80,11 @@ async function dispatchAction(url: string): Promise<void> {
|
||||||
const parsed = parseAction(url);
|
const parsed = parseAction(url);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
|
|
||||||
if (parsed.type === "take-meeting-notes") {
|
const openMeeting = parsed.type === "join-and-take-meeting-notes";
|
||||||
await handleTakeMeetingNotes(parsed.eventId);
|
await handleTakeMeetingNotes(parsed.eventId, openMeeting);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTakeMeetingNotes(eventId: string): Promise<void> {
|
async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> {
|
||||||
const win = mainWindowRef;
|
const win = mainWindowRef;
|
||||||
if (!win || win.isDestroyed()) return;
|
if (!win || win.isDestroyed()) return;
|
||||||
focusWindow(win);
|
focusWindow(win);
|
||||||
|
|
@ -100,14 +99,16 @@ async function handleTakeMeetingNotes(eventId: string): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = { event, openMeeting };
|
||||||
|
|
||||||
if (win.webContents.isLoading()) {
|
if (win.webContents.isLoading()) {
|
||||||
win.webContents.once("did-finish-load", () => {
|
win.webContents.once("did-finish-load", () => {
|
||||||
win.webContents.send("app:takeMeetingNotes", { event });
|
win.webContents.send("app:takeMeetingNotes", payload);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.send("app:takeMeetingNotes", { event });
|
win.webContents.send("app:takeMeetingNotes", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusWindow(win: BrowserWindow): void {
|
function focusWindow(win: BrowserWindow): void {
|
||||||
|
|
|
||||||
|
|
@ -15,25 +15,39 @@ export class ElectronNotificationService implements INotificationService {
|
||||||
return Notification.isSupported();
|
return Notification.isSupported();
|
||||||
}
|
}
|
||||||
|
|
||||||
notify({ title = "Rowboat", message, link, actionLabel }: NotifyInput): void {
|
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
||||||
|
// Build the actions array AND a parallel index → link map.
|
||||||
|
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
||||||
|
// additional ones live behind the chevron menu.
|
||||||
|
const actionDefs: Electron.NotificationConstructorOptions["actions"] = [];
|
||||||
|
const actionLinks: string[] = [];
|
||||||
|
|
||||||
|
const primaryLabel = actionLabel?.trim();
|
||||||
|
if (link && primaryLabel) {
|
||||||
|
actionDefs!.push({ type: "button", text: primaryLabel });
|
||||||
|
actionLinks.push(link);
|
||||||
|
}
|
||||||
|
if (secondaryActions) {
|
||||||
|
for (const sa of secondaryActions) {
|
||||||
|
actionDefs!.push({ type: "button", text: sa.label });
|
||||||
|
actionLinks.push(sa.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title,
|
title,
|
||||||
body: message,
|
body: message,
|
||||||
// Action button is only meaningful when there's a link to drive it.
|
actions: actionDefs,
|
||||||
// macOS shows the first action inline (Banner) or behind chevron (Alert).
|
|
||||||
actions: link && actionLabel?.trim()
|
|
||||||
? [{ type: "button", text: actionLabel.trim() }]
|
|
||||||
: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.active.add(notification);
|
this.active.add(notification);
|
||||||
const release = () => { this.active.delete(notification); };
|
const release = () => { this.active.delete(notification); };
|
||||||
|
|
||||||
const handleClick = () => {
|
const openLink = (target: string | undefined) => {
|
||||||
if (link && ROWBOAT_URL.test(link)) {
|
if (target && ROWBOAT_URL.test(target)) {
|
||||||
dispatchUrl(link);
|
dispatchUrl(target);
|
||||||
} else if (link && HTTP_URL.test(link)) {
|
} else if (target && HTTP_URL.test(target)) {
|
||||||
shell.openExternal(link).catch((err) => {
|
shell.openExternal(target).catch((err) => {
|
||||||
console.error("[notification] failed to open link:", err);
|
console.error("[notification] failed to open link:", err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -42,11 +56,18 @@ export class ElectronNotificationService implements INotificationService {
|
||||||
release();
|
release();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both events route through the same handler. Body click on macOS is
|
// Body click: always opens the primary `link` (or focuses the app if none).
|
||||||
// unreliable when actions are defined, but we register both so either
|
notification.on("click", () => openLink(link));
|
||||||
// one (whichever fires) drives the same behavior.
|
|
||||||
notification.on("click", handleClick);
|
// Action button click: dispatch by index into the actions array.
|
||||||
notification.on("action", handleClick);
|
notification.on("action", (_event, index) => {
|
||||||
|
if (index >= 0 && index < actionLinks.length) {
|
||||||
|
openLink(actionLinks[index]);
|
||||||
|
} else {
|
||||||
|
openLink(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
notification.on("close", release);
|
notification.on("close", release);
|
||||||
notification.on("failed", release);
|
notification.on("failed", release);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3107,8 +3107,9 @@ function App() {
|
||||||
|
|
||||||
// Triggered by main when the user clicks a calendar-meeting notification.
|
// Triggered by main when the user clicks a calendar-meeting notification.
|
||||||
// Reuses the same flow as the in-app "Join meeting & take notes" button.
|
// Reuses the same flow as the in-app "Join meeting & take notes" button.
|
||||||
|
// When `openMeeting` is true, also opens the meeting URL in the system browser.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.ipc.on('app:takeMeetingNotes', ({ event }) => {
|
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => {
|
||||||
const e = event as {
|
const e = event as {
|
||||||
summary?: string
|
summary?: string
|
||||||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||||||
|
|
@ -3119,8 +3120,15 @@ function App() {
|
||||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||||
}
|
}
|
||||||
if (!e || typeof e !== 'object') return
|
if (!e || typeof e !== 'object') return
|
||||||
const conferenceLink = e.hangoutLink
|
// Order matches extractConferenceLink in calendar-block.tsx:
|
||||||
|| e.conferenceData?.entryPoints?.find(p => p.entryPointType === 'video')?.uri
|
// entryPoints first (covers Zoom/integrated providers), then hangoutLink (Google Meet shortcut).
|
||||||
|
const conferenceLink = e.conferenceData?.entryPoints?.find(p => p.entryPointType === 'video')?.uri
|
||||||
|
|| e.hangoutLink
|
||||||
|
if (openMeeting && conferenceLink) {
|
||||||
|
window.open(conferenceLink, '_blank')
|
||||||
|
} else if (openMeeting) {
|
||||||
|
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e)
|
||||||
|
}
|
||||||
window.__pendingCalendarEvent = {
|
window.__pendingCalendarEvent = {
|
||||||
summary: e.summary,
|
summary: e.summary,
|
||||||
start: e.start,
|
start: e.start,
|
||||||
|
|
|
||||||
|
|
@ -1524,7 +1524,13 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
|
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
|
||||||
message: "link must be an http(s):// or rowboat:// URL",
|
message: "link must be an http(s):// or rowboat:// URL",
|
||||||
}).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."),
|
}).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."),
|
||||||
actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body. Note: macOS may render the button inline (Banner) or behind a chevron (Alert) depending on the user's notification style for Rowboat."),
|
actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."),
|
||||||
|
secondaryActions: z.array(z.object({
|
||||||
|
label: z.string().min(1).max(30),
|
||||||
|
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
|
||||||
|
message: "secondary action link must be an http(s):// or rowboat:// URL",
|
||||||
|
}),
|
||||||
|
})).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."),
|
||||||
}),
|
}),
|
||||||
isAvailable: async () => {
|
isAvailable: async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1533,13 +1539,13 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
execute: async ({ title, message, link, actionLabel }: { title?: string; message: string; link?: string; actionLabel?: string }) => {
|
execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => {
|
||||||
try {
|
try {
|
||||||
const service = container.resolve<INotificationService>('notificationService');
|
const service = container.resolve<INotificationService>('notificationService');
|
||||||
if (!service.isSupported()) {
|
if (!service.isSupported()) {
|
||||||
return { success: false, error: 'Notifications are not supported on this system' };
|
return { success: false, error: 'Notifications are not supported on this system' };
|
||||||
}
|
}
|
||||||
service.notify({ title, message, link, actionLabel });
|
service.notify({ title, message, link, actionLabel, secondaryActions });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export interface NotifyInput {
|
||||||
message: string;
|
message: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
|
secondaryActions?: Array<{ label: string; link: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INotificationService {
|
export interface INotificationService {
|
||||||
|
|
|
||||||
|
|
@ -121,14 +121,22 @@ async function tick(state: NotificationState): Promise<{ state: NotificationStat
|
||||||
if (msUntilStart < -NOTIFY_GRACE_MS) continue;
|
if (msUntilStart < -NOTIFY_GRACE_MS) continue;
|
||||||
|
|
||||||
const summary = event.summary?.trim() || "Untitled meeting";
|
const summary = event.summary?.trim() || "Untitled meeting";
|
||||||
const link = `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(eventId)}`;
|
const eid = encodeURIComponent(eventId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
service.notify({
|
service.notify({
|
||||||
title: "Upcoming meeting",
|
title: "Upcoming meeting",
|
||||||
message: `${summary} starts in 1 minute. Click to take notes.`,
|
message: `${summary} starts in 1 minute. Click to join and take notes.`,
|
||||||
link,
|
// Primary (body click + first button): join the meeting AND take notes.
|
||||||
actionLabel: "Take Notes",
|
link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`,
|
||||||
|
actionLabel: "Join meeting and take notes",
|
||||||
|
// Behind the chevron: just take notes (no join).
|
||||||
|
secondaryActions: [
|
||||||
|
{
|
||||||
|
label: "Take notes",
|
||||||
|
link: `rowboat://action?type=take-meeting-notes&eventId=${eid}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`);
|
console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,9 @@ const ipcSchemas = {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
||||||
event: z.unknown(),
|
event: z.unknown(),
|
||||||
|
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
|
||||||
|
// in addition to triggering the take-notes flow.
|
||||||
|
openMeeting: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
res: z.null(),
|
res: z.null(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue