@rapidmx/web-client 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/shared/calendar/calendarReminders.ts +16 -7
- package/apps/shared/calendar/useCalendarReminders.ts +66 -61
- package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/layout/UserMenu.tsx +29 -1
- package/apps/shared/notifications/preferences.ts +27 -0
- package/apps/shared/notifications/sounds.ts +135 -0
- package/apps/shared/notifications/store.ts +9 -0
- package/apps/www/calendar/index.tsx +31 -1
- package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
- package/dist/apps/shared/calendar/calendarReminders.js +14 -6
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
- package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -3
- package/dist/apps/shared/components/layout/UserMenu.js +9 -3
- package/dist/apps/shared/notifications/preferences.d.ts +8 -0
- package/dist/apps/shared/notifications/preferences.js +27 -0
- package/dist/apps/shared/notifications/sounds.d.ts +15 -0
- package/dist/apps/shared/notifications/sounds.js +101 -0
- package/dist/apps/shared/notifications/store.js +11 -0
- package/dist/apps/www/calendar/index.js +30 -1
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
9
9
|
|
|
10
|
-
/** How long "Snooze" waits before the
|
|
10
|
+
/** How long "Snooze" waits before the alarm comes back. */
|
|
11
11
|
export const SNOOZE_MS = 5 * 60_000;
|
|
12
12
|
|
|
13
13
|
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
@@ -49,17 +49,26 @@ export function joinMeetingUrl(location: string | null | undefined): string | un
|
|
|
49
49
|
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** What the alarm's button that opens an event's location URL is called: "Join" for a video meeting (a conferencing service's link, or any link that says
|
|
53
|
+
* it is one to join), "Open" for any other web address. */
|
|
54
|
+
export function locationActionLabel(url: string): "Join" | "Open" {
|
|
55
|
+
const { hostname, pathname } = new URL(url);
|
|
56
|
+
const host = hostname.toLowerCase();
|
|
57
|
+
const conferencing = /(^|\.)(zoom\.us|zoom\.com|zoomgov\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|meet\.jit\.si)$/;
|
|
58
|
+
const known = conferencing.test(host) || host === "meet.google.com" || host === "teams.microsoft.com" || host === "teams.live.com";
|
|
59
|
+
return known || /\/(join|meet|meeting|room|call)(\/|$)/i.test(pathname) || /^(meet|video|call|conference)\./.test(host) ? "Join" : "Open";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
|
|
63
|
+
export function calendarEventHref(eventUid: string, startDate: string): string {
|
|
64
|
+
return `/calendar?event=${encodeURIComponent(eventUid)}&start=${encodeURIComponent(startDate)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
53
68
|
export function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined {
|
|
54
69
|
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
58
|
-
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
59
|
-
export function snoozeDelayMs(startDate: string, now: number = Date.now()): number {
|
|
60
|
-
return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
72
|
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
64
73
|
export function reminderMessage(startDate: string, now: number = Date.now()): string {
|
|
65
74
|
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { useEffect } from "react";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { getPushClient } from "../../../lib/mail/pushClient.js";
|
|
7
|
+
import { getNotificationsEnabled } from "../notifications/preferences.js";
|
|
8
|
+
import { playNotificationSound } from "../notifications/sounds.js";
|
|
7
9
|
import { notify } from "../notifications/store.js";
|
|
8
|
-
import { CalendarReminderNotice,
|
|
9
|
-
|
|
10
|
-
/** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
|
|
11
|
-
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
12
|
-
export const CALENDAR_HREF = "/calendar";
|
|
10
|
+
import { CalendarReminderNotice, SNOOZE_MS, calendarReminderOf, reminderMessage, reminderNotificationId } from "./calendarReminders.js";
|
|
13
11
|
|
|
14
12
|
export interface UseCalendarRemindersOptions {
|
|
15
13
|
/** Nothing is shown without a signed-in user. */
|
|
@@ -18,76 +16,83 @@ export interface UseCalendarRemindersOptions {
|
|
|
18
16
|
enabled: boolean;
|
|
19
17
|
}
|
|
20
18
|
|
|
19
|
+
/** The calendar alarms waiting on the person, as `CalendarAlarmDialog` draws them. */
|
|
20
|
+
export interface CalendarAlarms {
|
|
21
|
+
/** The alarm on screen: the oldest one not yet answered. `undefined` when there is none. */
|
|
22
|
+
current?: CalendarReminderNotice;
|
|
23
|
+
/** How many more are waiting behind `current`. */
|
|
24
|
+
waiting: number;
|
|
25
|
+
/** Closes `current` and brings it back after `SNOOZE_MS` (five minutes). */
|
|
26
|
+
snooze: () => void;
|
|
27
|
+
/** Closes `current` for good. */
|
|
28
|
+
dismiss: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
/**
|
|
22
|
-
*
|
|
23
|
-
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
|
|
33
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
|
|
34
|
+
* every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
|
|
35
|
+
* mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its own. Lives in the persistent
|
|
36
|
+
* app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
|
|
37
|
+
*
|
|
38
|
+
* An alarm is a modal (`CalendarAlarmDialog`), with a bell: not a pop-up that goes by itself, since a meeting missed to a toast that timed out
|
|
39
|
+
* is the failure an alarm exists to prevent. Alarms that arrive together queue, one dialog at a time. **Snooze** closes it and brings it back -
|
|
40
|
+
* with the bell again - after exactly `SNOOZE_MS`. A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the alarm itself (the
|
|
41
|
+
* push event is never replayed), it does not survive the tab closing.
|
|
28
42
|
*
|
|
29
|
-
*
|
|
30
|
-
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
31
|
-
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
32
|
-
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
43
|
+
* With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
|
|
33
44
|
*/
|
|
34
|
-
export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions):
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): CalendarAlarms {
|
|
46
|
+
const [queue, setQueue] = useState<CalendarReminderNotice[]>([]);
|
|
47
|
+
// The queue as of the last render, for the callbacks below to read (a state updater must not start a timer: React may run it twice).
|
|
48
|
+
const queueRef = useRef(queue);
|
|
49
|
+
queueRef.current = queue;
|
|
50
|
+
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
51
|
+
|
|
52
|
+
const present = useCallback((notice: CalendarReminderNotice) => {
|
|
53
|
+
if (!getNotificationsEnabled()) {
|
|
54
|
+
// Off: only the history keeps it.
|
|
55
|
+
notify({ id: reminderNotificationId(notice.eventUid, notice.startDate), kind: "calendar", title: notice.title, message: reminderMessage(notice.startDate) });
|
|
37
56
|
return;
|
|
38
57
|
}
|
|
39
|
-
const
|
|
58
|
+
const id = reminderNotificationId(notice.eventUid, notice.startDate);
|
|
59
|
+
setQueue((current) => (current.some((queued) => reminderNotificationId(queued.eventUid, queued.startDate) === id) ? current : [...current, notice]));
|
|
60
|
+
playNotificationSound("calendar");
|
|
61
|
+
}, []);
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
id: reminderNotificationId(notice.eventUid, notice.startDate),
|
|
45
|
-
kind: "calendar",
|
|
46
|
-
title: notice.title,
|
|
47
|
-
message: reminderMessage(notice.startDate),
|
|
48
|
-
// Anyone who can invite you can put any address in an event's location: the button says where it goes.
|
|
49
|
-
hint: joinUrl ? `Join Meeting opens ${new URL(joinUrl).host}` : undefined,
|
|
50
|
-
href: CALENDAR_HREF,
|
|
51
|
-
sticky: true,
|
|
52
|
-
actions: [
|
|
53
|
-
...(joinUrl
|
|
54
|
-
? [
|
|
55
|
-
{
|
|
56
|
-
label: "Join Meeting",
|
|
57
|
-
onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
|
|
58
|
-
// Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
|
|
59
|
-
keepOpen: true,
|
|
60
|
-
},
|
|
61
|
-
]
|
|
62
|
-
: []),
|
|
63
|
-
{ label: "Dismiss" },
|
|
64
|
-
{
|
|
65
|
-
label: "Snooze",
|
|
66
|
-
onClick: () => {
|
|
67
|
-
const timer = setTimeout(() => {
|
|
68
|
-
timers.delete(timer);
|
|
69
|
-
show(notice);
|
|
70
|
-
}, snoozeDelayMs(notice.startDate));
|
|
71
|
-
timers.add(timer);
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
});
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled || !userUid) {
|
|
65
|
+
return;
|
|
76
66
|
}
|
|
77
|
-
|
|
78
67
|
const offEvent = getPushClient().onEvent((event) => {
|
|
79
68
|
const notice = calendarReminderOf(event);
|
|
80
69
|
if (notice) {
|
|
81
|
-
|
|
70
|
+
present(notice);
|
|
82
71
|
}
|
|
83
72
|
});
|
|
84
|
-
|
|
73
|
+
const pending = timers.current;
|
|
85
74
|
return () => {
|
|
86
75
|
offEvent();
|
|
87
|
-
for (const timer of
|
|
76
|
+
for (const timer of pending) {
|
|
88
77
|
clearTimeout(timer);
|
|
89
78
|
}
|
|
90
|
-
|
|
79
|
+
pending.clear();
|
|
80
|
+
setQueue([]);
|
|
91
81
|
};
|
|
92
|
-
}, [enabled, userUid]);
|
|
82
|
+
}, [enabled, userUid, present]);
|
|
83
|
+
|
|
84
|
+
const dismiss = useCallback(() => setQueue((current) => current.slice(1)), []);
|
|
85
|
+
const snooze = useCallback(() => {
|
|
86
|
+
const first = queueRef.current[0];
|
|
87
|
+
if (first) {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
timers.current.delete(timer);
|
|
90
|
+
present(first);
|
|
91
|
+
}, SNOOZE_MS);
|
|
92
|
+
timers.current.add(timer);
|
|
93
|
+
}
|
|
94
|
+
setQueue((current) => current.slice(1));
|
|
95
|
+
}, [present]);
|
|
96
|
+
|
|
97
|
+
return { current: queue[0], waiting: Math.max(0, queue.length - 1), snooze, dismiss };
|
|
93
98
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
7
|
+
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
8
|
+
import { calendarEventHref, joinMeetingUrl, locationActionLabel, reminderMessage } from "../../calendar/calendarReminders.js";
|
|
9
|
+
import type { CalendarAlarms } from "../../calendar/useCalendarReminders.js";
|
|
10
|
+
import { useNavigate } from "../../navigation/index.js";
|
|
11
|
+
|
|
12
|
+
export interface CalendarAlarmDialogProps {
|
|
13
|
+
alarms: CalendarAlarms;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
|
|
18
|
+
* in the Calendar. **Join** or **Open** - named for what the event's location is, a video meeting or any other web address - opens that address in a new
|
|
19
|
+
* tab; it is only there when the location is a web address, and says which site it goes to, since anyone who can invite you can put any address in an
|
|
20
|
+
* event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
|
|
21
|
+
* View and Join/Open also dismiss it: the person has answered it.
|
|
22
|
+
*/
|
|
23
|
+
export default function CalendarAlarmDialog({ alarms }: CalendarAlarmDialogProps) {
|
|
24
|
+
const navigate = useNavigate();
|
|
25
|
+
const { current: alarm, waiting, snooze, dismiss } = alarms;
|
|
26
|
+
if (!alarm) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const url = joinMeetingUrl(alarm.location);
|
|
30
|
+
const location = alarm.location?.trim();
|
|
31
|
+
return (
|
|
32
|
+
<Modal open onClose={dismiss} title={alarm.title}>
|
|
33
|
+
<div className="flex flex-col gap-4">
|
|
34
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
35
|
+
<p className="font-semibold">{reminderMessage(alarm.startDate)}</p>
|
|
36
|
+
{location && <p className="text-text-muted break-words">{location}</p>}
|
|
37
|
+
{waiting > 0 && (
|
|
38
|
+
<p className="text-xs text-text-muted">
|
|
39
|
+
{waiting} more alarm{waiting === 1 ? "" : "s"} after this one
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
44
|
+
<Button
|
|
45
|
+
type="button"
|
|
46
|
+
variant="secondary"
|
|
47
|
+
className="!w-auto"
|
|
48
|
+
onClick={() => {
|
|
49
|
+
navigate(calendarEventHref(alarm.eventUid, alarm.startDate));
|
|
50
|
+
dismiss();
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
View
|
|
54
|
+
</Button>
|
|
55
|
+
{url && (
|
|
56
|
+
<Button
|
|
57
|
+
type="button"
|
|
58
|
+
className="!w-auto"
|
|
59
|
+
onClick={() => {
|
|
60
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
61
|
+
dismiss();
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{locationActionLabel(url)}
|
|
65
|
+
</Button>
|
|
66
|
+
)}
|
|
67
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={snooze}>
|
|
68
|
+
Snooze
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
{url && <p className="text-xs text-text-muted">{`${locationActionLabel(url)} opens ${new URL(url).host}`}</p>}
|
|
72
|
+
</div>
|
|
73
|
+
</Modal>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -50,6 +50,7 @@ import { MailConnectionContext, useMailConnection } from "../../mail/useMailConn
|
|
|
50
50
|
import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
|
|
51
51
|
import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
|
|
52
52
|
import NotificationCenter from "../../notifications/NotificationCenter.js";
|
|
53
|
+
import CalendarAlarmDialog from "../calendar/CalendarAlarmDialog.js";
|
|
53
54
|
import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
|
|
54
55
|
import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
|
|
55
56
|
import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
|
|
@@ -210,8 +211,8 @@ export function AppChrome({
|
|
|
210
211
|
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
211
212
|
// A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
|
|
212
213
|
useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
|
|
213
|
-
//
|
|
214
|
-
useCalendarReminders({ userUid, enabled: inFrame });
|
|
214
|
+
// An event's alarm raises a modal (drawn by `CalendarAlarmDialog`, below) on whichever page they are - see the hook.
|
|
215
|
+
const calendarAlarms = useCalendarReminders({ userUid, enabled: inFrame });
|
|
215
216
|
|
|
216
217
|
// A refused refresh means the sign-in has ended: open compose windows save what they hold (the access token still works for a while) and the
|
|
217
218
|
// browser goes to sign-in. Not while viewing as another user - the refresh cookie is the admin's own, and refreshing would end the impersonation.
|
|
@@ -459,6 +460,7 @@ export function AppChrome({
|
|
|
459
460
|
)}
|
|
460
461
|
{/* The one pop-up stack for the whole app: right under the header row, so it never covers the account menu. */}
|
|
461
462
|
<NotificationCenter />
|
|
463
|
+
<CalendarAlarmDialog alarms={calendarAlarms} />
|
|
462
464
|
<div
|
|
463
465
|
id="app-content"
|
|
464
466
|
tabIndex={-1}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
HiOutlineArrowUturnLeft,
|
|
11
11
|
HiOutlineBell,
|
|
12
12
|
HiOutlineBellAlert,
|
|
13
|
+
HiOutlineSpeakerWave,
|
|
13
14
|
HiOutlineClock,
|
|
14
15
|
HiOutlineCog6Tooth,
|
|
15
16
|
HiOutlineCommandLine,
|
|
@@ -34,7 +35,12 @@ import {
|
|
|
34
35
|
requestDesktopPermission,
|
|
35
36
|
setDesktopOfferDismissed,
|
|
36
37
|
} from "../../mail/newMailNotifications.js";
|
|
37
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
getNotificationSoundsEnabled,
|
|
40
|
+
getNotificationsEnabled,
|
|
41
|
+
setNotificationSoundsEnabled,
|
|
42
|
+
setNotificationsEnabled,
|
|
43
|
+
} from "../../notifications/preferences.js";
|
|
38
44
|
import { dismissAll } from "../../notifications/store.js";
|
|
39
45
|
|
|
40
46
|
export interface UserMenuProps {
|
|
@@ -134,6 +140,7 @@ export default function UserMenu({
|
|
|
134
140
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
135
141
|
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
136
142
|
const [popups, setPopups] = useState(true);
|
|
143
|
+
const [sounds, setSounds] = useState(true);
|
|
137
144
|
const [permission, setPermission] = useState<DesktopPermission>("unsupported");
|
|
138
145
|
const [profile, setProfile] = useState<Profile | undefined>(undefined);
|
|
139
146
|
// Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
|
|
@@ -230,10 +237,16 @@ export default function UserMenu({
|
|
|
230
237
|
useEffect(() => {
|
|
231
238
|
if (open && showNotificationSettings) {
|
|
232
239
|
setPopups(getNotificationsEnabled());
|
|
240
|
+
setSounds(getNotificationSoundsEnabled());
|
|
233
241
|
setPermission(desktopPermission());
|
|
234
242
|
}
|
|
235
243
|
}, [open, showNotificationSettings]);
|
|
236
244
|
|
|
245
|
+
function toggleSounds() {
|
|
246
|
+
setNotificationSoundsEnabled(!sounds);
|
|
247
|
+
setSounds(!sounds);
|
|
248
|
+
}
|
|
249
|
+
|
|
237
250
|
function togglePopups() {
|
|
238
251
|
setNotificationsEnabled(!popups);
|
|
239
252
|
setPopups(!popups);
|
|
@@ -386,6 +399,21 @@ export default function UserMenu({
|
|
|
386
399
|
<span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
|
|
387
400
|
</button>
|
|
388
401
|
)}
|
|
402
|
+
{showNotificationSettings && (
|
|
403
|
+
<button
|
|
404
|
+
role="menuitemcheckbox"
|
|
405
|
+
aria-checked={sounds}
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={toggleSounds}
|
|
408
|
+
className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
409
|
+
>
|
|
410
|
+
<span className="flex items-center gap-2">
|
|
411
|
+
<HiOutlineSpeakerWave size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
412
|
+
Notification sounds
|
|
413
|
+
</span>
|
|
414
|
+
<span className="text-xs font-semibold text-text-muted">{sounds ? "On" : "Off"}</span>
|
|
415
|
+
</button>
|
|
416
|
+
)}
|
|
389
417
|
{showNotificationSettings && permission === "default" && (
|
|
390
418
|
<button
|
|
391
419
|
role="menuitem"
|
|
@@ -34,3 +34,30 @@ export function setNotificationsEnabled(enabled: boolean): void {
|
|
|
34
34
|
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `localStorage` key of the switch for the sounds that go with notifications (a chime for new mail, a bell for a calendar alarm, a buzz for an
|
|
40
|
+
* error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
|
|
41
|
+
*/
|
|
42
|
+
export const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
|
|
43
|
+
|
|
44
|
+
/** Whether notification sounds are on (the default). */
|
|
45
|
+
export function getNotificationSoundsEnabled(): boolean {
|
|
46
|
+
try {
|
|
47
|
+
return localStorage.getItem(NOTIFICATION_SOUNDS_KEY) !== "off";
|
|
48
|
+
} catch {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setNotificationSoundsEnabled(enabled: boolean): void {
|
|
54
|
+
try {
|
|
55
|
+
if (enabled) {
|
|
56
|
+
localStorage.removeItem(NOTIFICATION_SOUNDS_KEY);
|
|
57
|
+
} else {
|
|
58
|
+
localStorage.setItem(NOTIFICATION_SOUNDS_KEY, "off");
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { getNotificationSoundsEnabled } from "./preferences.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The sounds that go with the app's notifications: a chime for new mail, a bell for a calendar alarm and a low double buzz for an error. They are
|
|
9
|
+
* made with the Web Audio API as they are played - no audio files to download, cache or ship - so they work offline and cost nothing until used.
|
|
10
|
+
*
|
|
11
|
+
* Browsers only let a page make sound after the person has interacted with it; until then the audio context stays suspended and a sound is silently
|
|
12
|
+
* skipped (it is resumed, and plays from then on, as soon as the page has had a click or a key press). A sound is never an error: whatever
|
|
13
|
+
* goes wrong with the audio is swallowed. The same sound is not played twice within `MIN_GAP_MS`, so a burst of notifications is one sound, not a clatter.
|
|
14
|
+
*/
|
|
15
|
+
export type NotificationSound = "mail" | "calendar" | "error";
|
|
16
|
+
|
|
17
|
+
/** The shortest time between two plays of the same sound. */
|
|
18
|
+
export const MIN_GAP_MS = 750;
|
|
19
|
+
|
|
20
|
+
interface Tone {
|
|
21
|
+
/** Pitch in Hz at the start. */
|
|
22
|
+
frequency: number;
|
|
23
|
+
/** Pitch in Hz at the end, when it slides. */
|
|
24
|
+
endFrequency?: number;
|
|
25
|
+
/** Seconds after the sound starts. */
|
|
26
|
+
start: number;
|
|
27
|
+
/** Seconds it takes to die away. */
|
|
28
|
+
duration: number;
|
|
29
|
+
type: OscillatorType;
|
|
30
|
+
/** Peak loudness, 0 to 1. */
|
|
31
|
+
gain: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A soft two-note rising chime. */
|
|
35
|
+
const CHIME: Tone[] = [
|
|
36
|
+
{ frequency: 1046.5, start: 0, duration: 0.5, type: "sine", gain: 0.16 },
|
|
37
|
+
{ frequency: 1568, start: 0.14, duration: 0.8, type: "sine", gain: 0.14 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** A struck bell, twice: a fundamental with the inharmonic partials of a bell, each dying away slowly. */
|
|
41
|
+
const BELL: Tone[] = [0, 0.75].flatMap((start) =>
|
|
42
|
+
[
|
|
43
|
+
{ ratio: 1, gain: 0.16, duration: 1.4 },
|
|
44
|
+
{ ratio: 2.76, gain: 0.08, duration: 1.0 },
|
|
45
|
+
{ ratio: 5.4, gain: 0.04, duration: 0.7 },
|
|
46
|
+
{ ratio: 8.93, gain: 0.02, duration: 0.4 },
|
|
47
|
+
].map<Tone>((partial) => ({ frequency: 880 * partial.ratio, start, duration: partial.duration, type: "sine", gain: partial.gain })),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Two short falling buzzes - nothing like the other two. */
|
|
51
|
+
const ERROR: Tone[] = [
|
|
52
|
+
{ frequency: 300, endFrequency: 190, start: 0, duration: 0.2, type: "sawtooth", gain: 0.09 },
|
|
53
|
+
{ frequency: 300, endFrequency: 190, start: 0.26, duration: 0.2, type: "sawtooth", gain: 0.09 },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const SOUNDS: Record<NotificationSound, Tone[]> = { mail: CHIME, calendar: BELL, error: ERROR };
|
|
57
|
+
|
|
58
|
+
let context: AudioContext | undefined;
|
|
59
|
+
const lastPlayed: Partial<Record<NotificationSound, number>> = {};
|
|
60
|
+
|
|
61
|
+
function audioContext(): AudioContext | undefined {
|
|
62
|
+
if (context) {
|
|
63
|
+
return context;
|
|
64
|
+
}
|
|
65
|
+
const Ctor: typeof AudioContext | undefined =
|
|
66
|
+
typeof window === "undefined" ? undefined : (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
|
|
67
|
+
if (!Ctor) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
context = new Ctor();
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return context;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function playTone(audio: AudioContext, tone: Tone): void {
|
|
79
|
+
const begin = audio.currentTime + tone.start;
|
|
80
|
+
const end = begin + tone.duration;
|
|
81
|
+
const oscillator = audio.createOscillator();
|
|
82
|
+
const volume = audio.createGain();
|
|
83
|
+
oscillator.type = tone.type;
|
|
84
|
+
oscillator.frequency.setValueAtTime(tone.frequency, begin);
|
|
85
|
+
if (tone.endFrequency !== undefined) {
|
|
86
|
+
oscillator.frequency.exponentialRampToValueAtTime(tone.endFrequency, end);
|
|
87
|
+
}
|
|
88
|
+
// A short attack so it doesn't click, then an exponential die-away.
|
|
89
|
+
volume.gain.setValueAtTime(0.0001, begin);
|
|
90
|
+
volume.gain.exponentialRampToValueAtTime(tone.gain, begin + 0.01);
|
|
91
|
+
volume.gain.exponentialRampToValueAtTime(0.0001, end);
|
|
92
|
+
oscillator.connect(volume);
|
|
93
|
+
volume.connect(audio.destination);
|
|
94
|
+
oscillator.start(begin);
|
|
95
|
+
oscillator.stop(end + 0.05);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
|
|
99
|
+
export function playNotificationSound(sound: NotificationSound, now: number = Date.now()): void {
|
|
100
|
+
if (!getNotificationSoundsEnabled()) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const last = lastPlayed[sound];
|
|
104
|
+
if (last !== undefined && now - last < MIN_GAP_MS) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const audio = audioContext();
|
|
109
|
+
if (!audio) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
lastPlayed[sound] = now;
|
|
113
|
+
const play = (): void => {
|
|
114
|
+
for (const tone of SOUNDS[sound]) {
|
|
115
|
+
playTone(audio, tone);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if (audio.state === "suspended") {
|
|
119
|
+
// Allowed once the page has been interacted with; until then this is refused and the sound is skipped.
|
|
120
|
+
void audio.resume().then(play, () => undefined);
|
|
121
|
+
} else {
|
|
122
|
+
play();
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Audio is a nicety: a failure to make a sound never reaches the notification.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Forgets the audio context and what was played when - for tests. */
|
|
130
|
+
export function resetNotificationSounds(): void {
|
|
131
|
+
context = undefined;
|
|
132
|
+
for (const key of Object.keys(lastPlayed) as NotificationSound[]) {
|
|
133
|
+
delete lastPlayed[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { getNotificationsEnabled } from "./preferences.js";
|
|
6
|
+
import { playNotificationSound } from "./sounds.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
|
|
@@ -390,6 +391,14 @@ export function notify(input: NotifyInput): string {
|
|
|
390
391
|
emit();
|
|
391
392
|
return id;
|
|
392
393
|
}
|
|
394
|
+
// What is shown is heard: a chime for new mail, a bell for a calendar notice, a buzz for an error.
|
|
395
|
+
if (entry.kind === "mail") {
|
|
396
|
+
playNotificationSound("mail");
|
|
397
|
+
} else if (entry.kind === "calendar") {
|
|
398
|
+
playNotificationSound("calendar");
|
|
399
|
+
} else if (entry.kind === "error") {
|
|
400
|
+
playNotificationSound("error");
|
|
401
|
+
}
|
|
393
402
|
const room = visibleCount() < MAX_VISIBLE;
|
|
394
403
|
entries.push(entry);
|
|
395
404
|
if (room) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
6
|
+
import { useLocation, useNavigate } from "../../shared/navigation/index.js";
|
|
6
7
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
8
9
|
import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
|
|
@@ -28,7 +29,7 @@ import { getPushClient } from "../../../lib/mail/pushClient.js";
|
|
|
28
29
|
import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
29
30
|
import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
|
|
30
31
|
import { createFolder } from "../../../lib/mail/mailApi.js";
|
|
31
|
-
import { CalendarOccurrence, expandAllOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
32
|
+
import { CalendarOccurrence, expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
32
33
|
import { useDayKey } from "../../../lib/calendar/useDayKey.js";
|
|
33
34
|
import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
|
|
34
35
|
import Drawer from "../../../lib/components/overlays/Drawer.js";
|
|
@@ -538,6 +539,35 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
538
539
|
setModal(null);
|
|
539
540
|
}
|
|
540
541
|
|
|
542
|
+
// `?event=<uid>&start=<iso>` opens that event's card on the day it is on - where a calendar alarm's View button leads. `start` names which occurrence
|
|
543
|
+
// of a recurring event. Read from the page's own location, so it also works from the Calendar itself (a shallow navigation), and then taken off the
|
|
544
|
+
// address so a reload doesn't open it again.
|
|
545
|
+
const { search: locationSearch } = useLocation();
|
|
546
|
+
const navigateTo = useNavigate();
|
|
547
|
+
const openedFromAddressRef = useRef("");
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
const params = new URLSearchParams(locationSearch);
|
|
550
|
+
const eventUid = params.get("event");
|
|
551
|
+
const wanted = `${eventUid}|${params.get("start")}`;
|
|
552
|
+
if (!eventUid || openedFromAddressRef.current === wanted) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
openedFromAddressRef.current = wanted;
|
|
556
|
+
const requestedStart = new Date(params.get("start") ?? "");
|
|
557
|
+
navigateTo("/calendar", { replace: true });
|
|
558
|
+
getCalendarEvent(eventUid, client)
|
|
559
|
+
.then((event) => {
|
|
560
|
+
const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
|
|
561
|
+
const candidates = expandOccurrences(event, new Date(from.getTime() - 86_400_000), new Date(from.getTime() + 86_400_000));
|
|
562
|
+
const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
|
|
563
|
+
if (match) {
|
|
564
|
+
setViewDate(occurrenceDay(match));
|
|
565
|
+
openEvent(match);
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
.catch((err) => notifyApiError(err, "Couldn't open the event"));
|
|
569
|
+
}, [locationSearch]);
|
|
570
|
+
|
|
541
571
|
// Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
|
|
542
572
|
useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
|
|
543
573
|
useShortcut(SHORTCUTS.calendar.today, goToday);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
2
2
|
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
3
3
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
4
|
-
/** How long "Snooze" waits before the
|
|
4
|
+
/** How long "Snooze" waits before the alarm comes back. */
|
|
5
5
|
export declare const SNOOZE_MS: number;
|
|
6
6
|
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
7
7
|
export interface CalendarReminderNotice {
|
|
@@ -17,11 +17,13 @@ export interface CalendarReminderNotice {
|
|
|
17
17
|
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
18
18
|
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
19
19
|
export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
|
|
20
|
+
/** What the alarm's button that opens an event's location URL is called: "Join" for a video meeting (a conferencing service's link, or any link that says
|
|
21
|
+
* it is one to join), "Open" for any other web address. */
|
|
22
|
+
export declare function locationActionLabel(url: string): "Join" | "Open";
|
|
23
|
+
/** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
|
|
24
|
+
export declare function calendarEventHref(eventUid: string, startDate: string): string;
|
|
20
25
|
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
21
26
|
export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
|
|
22
|
-
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
23
|
-
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
24
|
-
export declare function snoozeDelayMs(startDate: string, now?: number): number;
|
|
25
27
|
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
26
28
|
export declare function reminderMessage(startDate: string, now?: number): string;
|
|
27
29
|
/** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
|