Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Meetings

- **Meeting prompts swipe away like desktop notifications.** A horizontal pointer swipe of 80px in either direction dismisses a meeting card — detection, calendar reminder, or the auto-end restart offer — taking the same path as its close button. Swipes that start on an action button are ignored.

## [1.9.1] - 2026-08-27

Meeting recordings now stop the moment the call does, with a short window to pick the same note back up. Gemini joins the bring-your-own-key transcription providers, Granola users can import their back catalogue, and the dictation pill gets a brand-blue processing glow plus a companion surface for Agent mode. Account deletion no longer reaches across accounts, and 1.9.0's recording-time CPU regression — along with the GPU packs 1.8.3 quietly deleted — is repaired.
Expand Down
104 changes: 92 additions & 12 deletions src/components/MeetingNotificationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { useState, useEffect, useCallback } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactElement,
} from "react";
import { useTranslation } from "react-i18next";
import type { MeetingAutoEndAction, MeetingNotificationData } from "../types/electron";
import { MeetingNotificationCard } from "./MeetingNotificationCard";
import {
getMeetingNotificationPresentation,
initializeMeetingNotificationOverlay,
shouldDismissMeetingNotificationSwipe,
shouldRestoreAutoEndCard,
subscribeMeetingAutoEndCountdown,
} from "./meetingNotificationModel";

export default function MeetingNotificationOverlay() {
interface PointerSwipe {
pointerId: number;
startX: number;
}

export default function MeetingNotificationOverlay(): ReactElement {
const { t } = useTranslation();
const [data, setData] = useState<MeetingNotificationData | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [secondsRemaining, setSecondsRemaining] = useState(0);
const pointerSwipeRef = useRef<PointerSwipe | null>(null);

useEffect(() => {
return initializeMeetingNotificationOverlay({
Expand All @@ -34,18 +48,20 @@ export default function MeetingNotificationOverlay() {
return subscribeMeetingAutoEndCountdown(data.expiresAt, setSecondsRemaining);
}, [data]);

const presentation = getMeetingNotificationPresentation(data, secondsRemaining);

const respond = useCallback(
async (action: string) => {
async (action: string): Promise<void> => {
if (data?.kind !== "detection") return;
setIsVisible(false);
await new Promise((r) => setTimeout(r, 200));
await new Promise<void>((resolve) => setTimeout(resolve, 200));
window.electronAPI?.meetingNotificationRespond?.(data.detectionId, action);
},
[data]
);

const respondToAutoEnd = useCallback(
async (action: MeetingAutoEndAction) => {
async (action: MeetingAutoEndAction): Promise<void> => {
if (data?.kind !== "auto-end") return;
setIsVisible(false);
// Main closes this window when it accepts the response. Anything else —
Expand All @@ -62,17 +78,77 @@ export default function MeetingNotificationOverlay() {
[data]
);

const handleMouseEnter = useCallback(() => {
const dismiss = useCallback((): void => {
if (data?.kind === "auto-end") void respondToAutoEnd("dismiss");
else void respond("dismiss");
}, [data, respond, respondToAutoEnd]);

const handleMouseEnter = useCallback((): void => {
setIsHovered(true);
window.electronAPI?.setNotificationInteractivity?.(true);
}, []);

const handleMouseLeave = useCallback(() => {
const handleMouseLeave = useCallback((): void => {
setIsHovered(false);
// A captured pointer keeps dragging outside the card, so the window has to
// stay interactive until the swipe finishes.
if (pointerSwipeRef.current) return;
window.electronAPI?.setNotificationInteractivity?.(false);
}, []);

const presentation = getMeetingNotificationPresentation(data, secondsRemaining);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>): void => {
if (
!presentation.dismissible ||
!isVisible ||
!event.isPrimary ||
event.button !== 0 ||
(event.target instanceof Element && event.target.closest("button"))
) {
return;
}

pointerSwipeRef.current = { pointerId: event.pointerId, startX: event.clientX };
event.currentTarget.setPointerCapture(event.pointerId);
},
[isVisible, presentation.dismissible]
);

const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>): void => {
const swipe = pointerSwipeRef.current;
if (!swipe || swipe.pointerId !== event.pointerId) return;

pointerSwipeRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}

// The card may have been replaced mid-drag, so the dismissibility of the
// card being released is what decides, not the one the swipe started on.
if (
shouldDismissMeetingNotificationSwipe(
presentation.dismissible,
event.clientX - swipe.startX
)
) {
dismiss();
} else if (!isHovered) {
window.electronAPI?.setNotificationInteractivity?.(false);
}
},
[dismiss, isHovered, presentation.dismissible]
);

const handlePointerCancel = useCallback(
(event: ReactPointerEvent<HTMLDivElement>): void => {
if (pointerSwipeRef.current?.pointerId !== event.pointerId) return;
pointerSwipeRef.current = null;
if (!isHovered) window.electronAPI?.setNotificationInteractivity?.(false);
},
[isHovered]
);

const title = "title" in presentation ? presentation.title : t(presentation.titleKey);
const body =
"bodyValues" in presentation
Expand All @@ -82,17 +158,21 @@ export default function MeetingNotificationOverlay() {
presentation.action === "restart"
? () => void respondToAutoEnd("restart")
: () => respond(presentation.action);
const handleDismiss =
data?.kind === "auto-end" ? () => void respondToAutoEnd("dismiss") : () => respond("dismiss");

return (
<div className="meeting-notification-window w-full h-full bg-transparent p-3">
<div
className="meeting-notification-window w-full h-full bg-transparent p-3"
style={{ touchAction: presentation.dismissible ? "pan-y" : "auto" }}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
<MeetingNotificationCard
title={title}
body={body}
startLabel={t(presentation.actionKey)}
onStart={handleAction}
onDismiss={presentation.dismissible ? handleDismiss : undefined}
onDismiss={presentation.dismissible ? dismiss : undefined}
closeVisible={isHovered}
allowTitleWrap={presentation.allowTitleWrap}
onMouseEnter={handleMouseEnter}
Expand Down
9 changes: 9 additions & 0 deletions src/components/meetingNotificationModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ interface DetectionPresentation {

export type MeetingNotificationPresentation = AutoEndPresentation | DetectionPresentation;

const MEETING_NOTIFICATION_SWIPE_DISTANCE_PX = 80;

export function shouldDismissMeetingNotificationSwipe(
dismissible: boolean,
horizontalDistance: number
): boolean {
return dismissible && Math.abs(horizontalDistance) >= MEETING_NOTIFICATION_SWIPE_DISTANCE_PX;
}

export function getMeetingNotificationPresentation(
data: MeetingNotificationData | null,
secondsRemaining: number
Expand Down
16 changes: 16 additions & 0 deletions test/helpers/meetingNotificationModel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ test("detection presentation preserves event title, join action, and dismissal",
);
});

test("a dismissible meeting notification closes after an 80px horizontal swipe", async () => {
const { shouldDismissMeetingNotificationSwipe } = await load();

assert.equal(shouldDismissMeetingNotificationSwipe(true, 80), true);
assert.equal(shouldDismissMeetingNotificationSwipe(true, -80), true);
assert.equal(shouldDismissMeetingNotificationSwipe(true, 79), false);
});

// The card on screen when the pointer is released decides, not the one the
// swipe started on: a newer prompt can replace it mid-drag.
test("non-dismissible meeting notifications ignore horizontal swipes", async () => {
const { shouldDismissMeetingNotificationSwipe } = await load();

assert.equal(shouldDismissMeetingNotificationSwipe(false, 200), false);
});

test("countdown rounds up and emits updated seconds until expiration", async () => {
const { subscribeMeetingAutoEndCountdown } = await load();
let now = 10_000;
Expand Down
Loading