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
1 change: 0 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ The chat agent and the voice agent become one Voice Assistant behind a redesigne

### Meetings & speakers

- **Meeting reminders swipe away like desktop notifications.** A horizontal pointer swipe now dismisses meeting-detected and calendar reminder cards, while the **Keep recording** countdown remains protected.
- **Forgotten recordings end themselves.** When the call is over — the meeting app releases your microphone, or both audio channels go quiet, or the app exits — a 60-second countdown starts, cancellable with **Keep recording**, and the card tells you which signal fired. Remote voices still playing defer the countdown, so a quiet-but-live meeting is never cut short. (#1494)
- **Meeting notes know who is who.** Every transcript line carries its resolved speaker name and the mic track carries your own name, a Meeting Context block names the note owner and invited participants, and the prompt is forbidden from inventing identities — so notes stop assuming you're whoever got named in conversation. (#1741)
- **In-person meetings get speaker labels.** Recordings with everyone in the room and no call ended with zero diarized segments, because only the (silent) system-audio channel was ever diarized. Those sessions now diarize the microphone track. (#1726)
Expand Down
94 changes: 9 additions & 85 deletions src/components/MeetingNotificationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,19 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactElement,
} from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { MeetingNotificationData } from "../types/electron";
import { MeetingNotificationCard } from "./MeetingNotificationCard";
import {
getMeetingNotificationPresentation,
initializeMeetingNotificationOverlay,
shouldDismissMeetingNotificationSwipe,
subscribeMeetingAutoEndCountdown,
} from "./meetingNotificationModel";

interface PointerSwipe {
pointerId: number;
startX: number;
}

export default function MeetingNotificationOverlay(): ReactElement {
export default function MeetingNotificationOverlay() {
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 @@ -47,88 +33,32 @@ export default function MeetingNotificationOverlay(): ReactElement {
return subscribeMeetingAutoEndCountdown(data.expiresAt, setSecondsRemaining);
}, [data]);

const presentation = getMeetingNotificationPresentation(data, secondsRemaining);

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

const keepRecording = useCallback((): void => {
const keepRecording = useCallback(() => {
if (data?.kind !== "auto-end") return;
void window.electronAPI?.meetingAutoEndKeep?.(data.sessionId);
}, [data]);

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

const handleMouseLeave = useCallback((): void => {
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
if (pointerSwipeRef.current) return;
window.electronAPI?.setNotificationInteractivity?.(false);
}, []);

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);
}

if (
shouldDismissMeetingNotificationSwipe(
presentation.dismissible,
event.clientX - swipe.startX
)
) {
void respond("dismiss");
} else if (!isHovered) {
window.electronAPI?.setNotificationInteractivity?.(false);
}
},
[isHovered, presentation.dismissible, respond]
);

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 presentation = getMeetingNotificationPresentation(data, secondsRemaining);
const title = "title" in presentation ? presentation.title : t(presentation.titleKey);
const body =
"bodyValues" in presentation
Expand All @@ -138,13 +68,7 @@ export default function MeetingNotificationOverlay(): ReactElement {
presentation.action === "keep" ? keepRecording : () => respond(presentation.action);

return (
<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}
>
<div className="meeting-notification-window w-full h-full bg-transparent p-3">
<MeetingNotificationCard
title={title}
body={body}
Expand Down
9 changes: 0 additions & 9 deletions src/components/meetingNotificationModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,6 @@ 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
14 changes: 0 additions & 14 deletions test/helpers/meetingNotificationModel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,6 @@ 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);
});

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