@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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
6
6
|
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
7
|
-
/** How long "Snooze" waits before the
|
|
7
|
+
/** How long "Snooze" waits before the alarm comes back. */
|
|
8
8
|
export const SNOOZE_MS = 5 * 60000;
|
|
9
9
|
function isReminderNotice(data) {
|
|
10
10
|
const notice = data;
|
|
@@ -31,15 +31,23 @@ export function joinMeetingUrl(location) {
|
|
|
31
31
|
}
|
|
32
32
|
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
33
33
|
}
|
|
34
|
+
/** 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
|
|
35
|
+
* it is one to join), "Open" for any other web address. */
|
|
36
|
+
export function locationActionLabel(url) {
|
|
37
|
+
const { hostname, pathname } = new URL(url);
|
|
38
|
+
const host = hostname.toLowerCase();
|
|
39
|
+
const conferencing = /(^|\.)(zoom\.us|zoom\.com|zoomgov\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|meet\.jit\.si)$/;
|
|
40
|
+
const known = conferencing.test(host) || host === "meet.google.com" || host === "teams.microsoft.com" || host === "teams.live.com";
|
|
41
|
+
return known || /\/(join|meet|meeting|room|call)(\/|$)/i.test(pathname) || /^(meet|video|call|conference)\./.test(host) ? "Join" : "Open";
|
|
42
|
+
}
|
|
43
|
+
/** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
|
|
44
|
+
export function calendarEventHref(eventUid, startDate) {
|
|
45
|
+
return `/calendar?event=${encodeURIComponent(eventUid)}&start=${encodeURIComponent(startDate)}`;
|
|
46
|
+
}
|
|
34
47
|
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
35
48
|
export function calendarReminderOf(event) {
|
|
36
49
|
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
37
50
|
}
|
|
38
|
-
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
39
|
-
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
40
|
-
export function snoozeDelayMs(startDate, now = Date.now()) {
|
|
41
|
-
return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
|
|
42
|
-
}
|
|
43
51
|
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
44
52
|
export function reminderMessage(startDate, now = Date.now()) {
|
|
45
53
|
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
3
|
-
export declare const CALENDAR_HREF = "/calendar";
|
|
1
|
+
import { CalendarReminderNotice } from "./calendarReminders.js";
|
|
4
2
|
export interface UseCalendarRemindersOptions {
|
|
5
3
|
/** Nothing is shown without a signed-in user. */
|
|
6
4
|
userUid?: string;
|
|
7
5
|
/** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
|
|
8
6
|
enabled: boolean;
|
|
9
7
|
}
|
|
8
|
+
/** The calendar alarms waiting on the person, as `CalendarAlarmDialog` draws them. */
|
|
9
|
+
export interface CalendarAlarms {
|
|
10
|
+
/** The alarm on screen: the oldest one not yet answered. `undefined` when there is none. */
|
|
11
|
+
current?: CalendarReminderNotice;
|
|
12
|
+
/** How many more are waiting behind `current`. */
|
|
13
|
+
waiting: number;
|
|
14
|
+
/** Closes `current` and brings it back after `SNOOZE_MS` (five minutes). */
|
|
15
|
+
snooze: () => void;
|
|
16
|
+
/** Closes `current` for good. */
|
|
17
|
+
dismiss: () => void;
|
|
18
|
+
}
|
|
10
19
|
/**
|
|
11
|
-
*
|
|
12
|
-
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
20
|
+
* Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
|
|
21
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
|
|
22
|
+
* every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
|
|
23
|
+
* 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
|
|
24
|
+
* app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
|
|
25
|
+
*
|
|
26
|
+
* 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
|
|
27
|
+
* 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 -
|
|
28
|
+
* 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
|
|
29
|
+
* push event is never replayed), it does not survive the tab closing.
|
|
17
30
|
*
|
|
18
|
-
*
|
|
19
|
-
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
20
|
-
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
21
|
-
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
31
|
+
* With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
|
|
22
32
|
*/
|
|
23
|
-
export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions):
|
|
33
|
+
export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): CalendarAlarms;
|
|
@@ -2,80 +2,73 @@
|
|
|
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 {
|
|
9
|
-
/** 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
|
|
10
|
-
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
11
|
-
export const CALENDAR_HREF = "/calendar";
|
|
10
|
+
import { SNOOZE_MS, calendarReminderOf, reminderMessage, reminderNotificationId } from "./calendarReminders.js";
|
|
12
11
|
/**
|
|
13
|
-
*
|
|
14
|
-
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Mail, Calendar, Contacts, Settings.
|
|
12
|
+
* Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
|
|
13
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
|
|
14
|
+
* every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
|
|
15
|
+
* 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
|
|
16
|
+
* app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* 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
|
|
19
|
+
* 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 -
|
|
20
|
+
* 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
|
|
21
|
+
* push event is never replayed), it does not survive the tab closing.
|
|
22
|
+
*
|
|
23
|
+
* With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
|
|
24
24
|
*/
|
|
25
25
|
export function useCalendarReminders({ userUid, enabled }) {
|
|
26
|
+
const [queue, setQueue] = useState([]);
|
|
27
|
+
// 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).
|
|
28
|
+
const queueRef = useRef(queue);
|
|
29
|
+
queueRef.current = queue;
|
|
30
|
+
const timers = useRef(new Set());
|
|
31
|
+
const present = useCallback((notice) => {
|
|
32
|
+
if (!getNotificationsEnabled()) {
|
|
33
|
+
// Off: only the history keeps it.
|
|
34
|
+
notify({ id: reminderNotificationId(notice.eventUid, notice.startDate), kind: "calendar", title: notice.title, message: reminderMessage(notice.startDate) });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const id = reminderNotificationId(notice.eventUid, notice.startDate);
|
|
38
|
+
setQueue((current) => (current.some((queued) => reminderNotificationId(queued.eventUid, queued.startDate) === id) ? current : [...current, notice]));
|
|
39
|
+
playNotificationSound("calendar");
|
|
40
|
+
}, []);
|
|
26
41
|
useEffect(() => {
|
|
27
42
|
if (!enabled || !userUid) {
|
|
28
43
|
return;
|
|
29
44
|
}
|
|
30
|
-
const timers = new Set();
|
|
31
|
-
function show(notice) {
|
|
32
|
-
const joinUrl = joinMeetingUrl(notice.location);
|
|
33
|
-
notify({
|
|
34
|
-
id: reminderNotificationId(notice.eventUid, notice.startDate),
|
|
35
|
-
kind: "calendar",
|
|
36
|
-
title: notice.title,
|
|
37
|
-
message: reminderMessage(notice.startDate),
|
|
38
|
-
// Anyone who can invite you can put any address in an event's location: the button says where it goes.
|
|
39
|
-
hint: joinUrl ? `Join Meeting opens ${new URL(joinUrl).host}` : undefined,
|
|
40
|
-
href: CALENDAR_HREF,
|
|
41
|
-
sticky: true,
|
|
42
|
-
actions: [
|
|
43
|
-
...(joinUrl
|
|
44
|
-
? [
|
|
45
|
-
{
|
|
46
|
-
label: "Join Meeting",
|
|
47
|
-
onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
|
|
48
|
-
// Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
|
|
49
|
-
keepOpen: true,
|
|
50
|
-
},
|
|
51
|
-
]
|
|
52
|
-
: []),
|
|
53
|
-
{ label: "Dismiss" },
|
|
54
|
-
{
|
|
55
|
-
label: "Snooze",
|
|
56
|
-
onClick: () => {
|
|
57
|
-
const timer = setTimeout(() => {
|
|
58
|
-
timers.delete(timer);
|
|
59
|
-
show(notice);
|
|
60
|
-
}, snoozeDelayMs(notice.startDate));
|
|
61
|
-
timers.add(timer);
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
45
|
const offEvent = getPushClient().onEvent((event) => {
|
|
68
46
|
const notice = calendarReminderOf(event);
|
|
69
47
|
if (notice) {
|
|
70
|
-
|
|
48
|
+
present(notice);
|
|
71
49
|
}
|
|
72
50
|
});
|
|
51
|
+
const pending = timers.current;
|
|
73
52
|
return () => {
|
|
74
53
|
offEvent();
|
|
75
|
-
for (const timer of
|
|
54
|
+
for (const timer of pending) {
|
|
76
55
|
clearTimeout(timer);
|
|
77
56
|
}
|
|
78
|
-
|
|
57
|
+
pending.clear();
|
|
58
|
+
setQueue([]);
|
|
79
59
|
};
|
|
80
|
-
}, [enabled, userUid]);
|
|
60
|
+
}, [enabled, userUid, present]);
|
|
61
|
+
const dismiss = useCallback(() => setQueue((current) => current.slice(1)), []);
|
|
62
|
+
const snooze = useCallback(() => {
|
|
63
|
+
const first = queueRef.current[0];
|
|
64
|
+
if (first) {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timers.current.delete(timer);
|
|
67
|
+
present(first);
|
|
68
|
+
}, SNOOZE_MS);
|
|
69
|
+
timers.current.add(timer);
|
|
70
|
+
}
|
|
71
|
+
setQueue((current) => current.slice(1));
|
|
72
|
+
}, [present]);
|
|
73
|
+
return { current: queue[0], waiting: Math.max(0, queue.length - 1), snooze, dismiss };
|
|
81
74
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { CalendarAlarms } from "../../calendar/useCalendarReminders.js";
|
|
3
|
+
export interface CalendarAlarmDialogProps {
|
|
4
|
+
alarms: CalendarAlarms;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
|
|
8
|
+
* 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
|
|
9
|
+
* 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
|
|
10
|
+
* event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
|
|
11
|
+
* View and Join/Open also dismiss it: the person has answered it.
|
|
12
|
+
*/
|
|
13
|
+
export default function CalendarAlarmDialog({ alarms }: CalendarAlarmDialogProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
3
|
+
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
4
|
+
import { calendarEventHref, joinMeetingUrl, locationActionLabel, reminderMessage } from "../../calendar/calendarReminders.js";
|
|
5
|
+
import { useNavigate } from "../../navigation/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
|
|
8
|
+
* 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
|
|
9
|
+
* 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
|
|
10
|
+
* event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
|
|
11
|
+
* View and Join/Open also dismiss it: the person has answered it.
|
|
12
|
+
*/
|
|
13
|
+
export default function CalendarAlarmDialog({ alarms }) {
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
const { current: alarm, waiting, snooze, dismiss } = alarms;
|
|
16
|
+
if (!alarm) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const url = joinMeetingUrl(alarm.location);
|
|
20
|
+
const location = alarm.location?.trim();
|
|
21
|
+
return (_jsx(Modal, { open: true, onClose: dismiss, title: alarm.title, children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1 text-sm", children: [_jsx("p", { className: "font-semibold", children: reminderMessage(alarm.startDate) }), location && _jsx("p", { className: "text-text-muted break-words", children: location }), waiting > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: [waiting, " more alarm", waiting === 1 ? "" : "s", " after this one"] }))] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => {
|
|
22
|
+
navigate(calendarEventHref(alarm.eventUid, alarm.startDate));
|
|
23
|
+
dismiss();
|
|
24
|
+
}, children: "View" }), url && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => {
|
|
25
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
26
|
+
dismiss();
|
|
27
|
+
}, children: locationActionLabel(url) })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: snooze, children: "Snooze" })] }), url && _jsx("p", { className: "text-xs text-text-muted", children: `${locationActionLabel(url)} opens ${new URL(url).host}` })] }) }));
|
|
28
|
+
}
|
|
@@ -44,6 +44,7 @@ import { MailConnectionContext, useMailConnection } from "../../mail/useMailConn
|
|
|
44
44
|
import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
|
|
45
45
|
import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
|
|
46
46
|
import NotificationCenter from "../../notifications/NotificationCenter.js";
|
|
47
|
+
import CalendarAlarmDialog from "../calendar/CalendarAlarmDialog.js";
|
|
47
48
|
import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
|
|
48
49
|
import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
|
|
49
50
|
import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
|
|
@@ -128,8 +129,8 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
128
129
|
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
129
130
|
// A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
|
|
130
131
|
useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
|
|
131
|
-
//
|
|
132
|
-
useCalendarReminders({ userUid, enabled: inFrame });
|
|
132
|
+
// An event's alarm raises a modal (drawn by `CalendarAlarmDialog`, below) on whichever page they are - see the hook.
|
|
133
|
+
const calendarAlarms = useCalendarReminders({ userUid, enabled: inFrame });
|
|
133
134
|
// 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
|
|
134
135
|
// 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.
|
|
135
136
|
useSessionRefresh(userUid, authServerUrl, { paused: impersonating, beforeRedirect: () => flushComposeDrafts(LOGOUT_TIMEOUT_MS) });
|
|
@@ -277,7 +278,7 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
277
278
|
id === active
|
|
278
279
|
? "bg-primary/10 text-primary-dark"
|
|
279
280
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
280
|
-
].join(" "), children: _jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
281
|
+
].join(" "), children: _jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx(CalendarAlarmDialog, { alarms: calendarAlarms }), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
281
282
|
}
|
|
282
283
|
/**
|
|
283
284
|
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { createPortal } from "react-dom";
|
|
8
8
|
import useIsMobile from "../../../../lib/util/useIsMobile.js";
|
|
9
|
-
import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineSpeakerWave, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
|
|
10
10
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "../../../../lib/auth/profileApi.js";
|
|
11
11
|
import { listMailboxes } from "../../../../lib/mail/mailApi.js";
|
|
12
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -20,7 +20,7 @@ import { MailConnectionContext } from "../../mail/useMailConnection.js";
|
|
|
20
20
|
import { ownMailboxes } from "../../mail/primaryMailbox.js";
|
|
21
21
|
import ThemeSwitch from "./ThemeSwitch.js";
|
|
22
22
|
import { desktopPermission, requestDesktopPermission, setDesktopOfferDismissed, } from "../../mail/newMailNotifications.js";
|
|
23
|
-
import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
|
|
23
|
+
import { getNotificationSoundsEnabled, getNotificationsEnabled, setNotificationSoundsEnabled, setNotificationsEnabled, } from "../../notifications/preferences.js";
|
|
24
24
|
import { dismissAll } from "../../notifications/store.js";
|
|
25
25
|
function Avatar({ profile, initials, large }) {
|
|
26
26
|
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
@@ -53,6 +53,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
53
53
|
const menuRef = useRef(null);
|
|
54
54
|
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
55
55
|
const [popups, setPopups] = useState(true);
|
|
56
|
+
const [sounds, setSounds] = useState(true);
|
|
56
57
|
const [permission, setPermission] = useState("unsupported");
|
|
57
58
|
const [profile, setProfile] = useState(undefined);
|
|
58
59
|
// Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
|
|
@@ -140,9 +141,14 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
140
141
|
useEffect(() => {
|
|
141
142
|
if (open && showNotificationSettings) {
|
|
142
143
|
setPopups(getNotificationsEnabled());
|
|
144
|
+
setSounds(getNotificationSoundsEnabled());
|
|
143
145
|
setPermission(desktopPermission());
|
|
144
146
|
}
|
|
145
147
|
}, [open, showNotificationSettings]);
|
|
148
|
+
function toggleSounds() {
|
|
149
|
+
setNotificationSoundsEnabled(!sounds);
|
|
150
|
+
setSounds(!sounds);
|
|
151
|
+
}
|
|
146
152
|
function togglePopups() {
|
|
147
153
|
setNotificationsEnabled(!popups);
|
|
148
154
|
setPopups(!popups);
|
|
@@ -206,7 +212,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
206
212
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
207
213
|
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
|
|
208
214
|
anchor &&
|
|
209
|
-
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, 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", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
215
|
+
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, 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", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": sounds, type: "button", onClick: toggleSounds, 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", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineSpeakerWave, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notification sounds"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: sounds ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
210
216
|
setOpen(false);
|
|
211
217
|
onShowNotifications();
|
|
212
218
|
}, 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", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineClock, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Recent notifications"] }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsxs("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|
|
@@ -11,3 +11,11 @@ export declare const NOTIFICATIONS_ENABLED_KEY = "rapidmx-new-mail-popups";
|
|
|
11
11
|
/** Whether pop-up notifications are on (the default). */
|
|
12
12
|
export declare function getNotificationsEnabled(): boolean;
|
|
13
13
|
export declare function setNotificationsEnabled(enabled: boolean): void;
|
|
14
|
+
/**
|
|
15
|
+
* `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
|
|
16
|
+
* error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
|
|
17
|
+
*/
|
|
18
|
+
export declare const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
|
|
19
|
+
/** Whether notification sounds are on (the default). */
|
|
20
|
+
export declare function getNotificationSoundsEnabled(): boolean;
|
|
21
|
+
export declare function setNotificationSoundsEnabled(enabled: boolean): void;
|
|
@@ -34,3 +34,30 @@ export function setNotificationsEnabled(enabled) {
|
|
|
34
34
|
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* `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
|
|
39
|
+
* error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
|
|
40
|
+
*/
|
|
41
|
+
export const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
|
|
42
|
+
/** Whether notification sounds are on (the default). */
|
|
43
|
+
export function getNotificationSoundsEnabled() {
|
|
44
|
+
try {
|
|
45
|
+
return localStorage.getItem(NOTIFICATION_SOUNDS_KEY) !== "off";
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function setNotificationSoundsEnabled(enabled) {
|
|
52
|
+
try {
|
|
53
|
+
if (enabled) {
|
|
54
|
+
localStorage.removeItem(NOTIFICATION_SOUNDS_KEY);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
localStorage.setItem(NOTIFICATION_SOUNDS_KEY, "off");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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
|
|
3
|
+
* 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.
|
|
4
|
+
*
|
|
5
|
+
* 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
|
|
6
|
+
* 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
|
|
7
|
+
* 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.
|
|
8
|
+
*/
|
|
9
|
+
export type NotificationSound = "mail" | "calendar" | "error";
|
|
10
|
+
/** The shortest time between two plays of the same sound. */
|
|
11
|
+
export declare const MIN_GAP_MS = 750;
|
|
12
|
+
/** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
|
|
13
|
+
export declare function playNotificationSound(sound: NotificationSound, now?: number): void;
|
|
14
|
+
/** Forgets the audio context and what was played when - for tests. */
|
|
15
|
+
export declare function resetNotificationSounds(): void;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { getNotificationSoundsEnabled } from "./preferences.js";
|
|
6
|
+
/** The shortest time between two plays of the same sound. */
|
|
7
|
+
export const MIN_GAP_MS = 750;
|
|
8
|
+
/** A soft two-note rising chime. */
|
|
9
|
+
const CHIME = [
|
|
10
|
+
{ frequency: 1046.5, start: 0, duration: 0.5, type: "sine", gain: 0.16 },
|
|
11
|
+
{ frequency: 1568, start: 0.14, duration: 0.8, type: "sine", gain: 0.14 },
|
|
12
|
+
];
|
|
13
|
+
/** A struck bell, twice: a fundamental with the inharmonic partials of a bell, each dying away slowly. */
|
|
14
|
+
const BELL = [0, 0.75].flatMap((start) => [
|
|
15
|
+
{ ratio: 1, gain: 0.16, duration: 1.4 },
|
|
16
|
+
{ ratio: 2.76, gain: 0.08, duration: 1.0 },
|
|
17
|
+
{ ratio: 5.4, gain: 0.04, duration: 0.7 },
|
|
18
|
+
{ ratio: 8.93, gain: 0.02, duration: 0.4 },
|
|
19
|
+
].map((partial) => ({ frequency: 880 * partial.ratio, start, duration: partial.duration, type: "sine", gain: partial.gain })));
|
|
20
|
+
/** Two short falling buzzes - nothing like the other two. */
|
|
21
|
+
const ERROR = [
|
|
22
|
+
{ frequency: 300, endFrequency: 190, start: 0, duration: 0.2, type: "sawtooth", gain: 0.09 },
|
|
23
|
+
{ frequency: 300, endFrequency: 190, start: 0.26, duration: 0.2, type: "sawtooth", gain: 0.09 },
|
|
24
|
+
];
|
|
25
|
+
const SOUNDS = { mail: CHIME, calendar: BELL, error: ERROR };
|
|
26
|
+
let context;
|
|
27
|
+
const lastPlayed = {};
|
|
28
|
+
function audioContext() {
|
|
29
|
+
if (context) {
|
|
30
|
+
return context;
|
|
31
|
+
}
|
|
32
|
+
const Ctor = typeof window === "undefined" ? undefined : (window.AudioContext ?? window.webkitAudioContext);
|
|
33
|
+
if (!Ctor) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
context = new Ctor();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return context;
|
|
43
|
+
}
|
|
44
|
+
function playTone(audio, tone) {
|
|
45
|
+
const begin = audio.currentTime + tone.start;
|
|
46
|
+
const end = begin + tone.duration;
|
|
47
|
+
const oscillator = audio.createOscillator();
|
|
48
|
+
const volume = audio.createGain();
|
|
49
|
+
oscillator.type = tone.type;
|
|
50
|
+
oscillator.frequency.setValueAtTime(tone.frequency, begin);
|
|
51
|
+
if (tone.endFrequency !== undefined) {
|
|
52
|
+
oscillator.frequency.exponentialRampToValueAtTime(tone.endFrequency, end);
|
|
53
|
+
}
|
|
54
|
+
// A short attack so it doesn't click, then an exponential die-away.
|
|
55
|
+
volume.gain.setValueAtTime(0.0001, begin);
|
|
56
|
+
volume.gain.exponentialRampToValueAtTime(tone.gain, begin + 0.01);
|
|
57
|
+
volume.gain.exponentialRampToValueAtTime(0.0001, end);
|
|
58
|
+
oscillator.connect(volume);
|
|
59
|
+
volume.connect(audio.destination);
|
|
60
|
+
oscillator.start(begin);
|
|
61
|
+
oscillator.stop(end + 0.05);
|
|
62
|
+
}
|
|
63
|
+
/** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
|
|
64
|
+
export function playNotificationSound(sound, now = Date.now()) {
|
|
65
|
+
if (!getNotificationSoundsEnabled()) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const last = lastPlayed[sound];
|
|
69
|
+
if (last !== undefined && now - last < MIN_GAP_MS) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const audio = audioContext();
|
|
74
|
+
if (!audio) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
lastPlayed[sound] = now;
|
|
78
|
+
const play = () => {
|
|
79
|
+
for (const tone of SOUNDS[sound]) {
|
|
80
|
+
playTone(audio, tone);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
if (audio.state === "suspended") {
|
|
84
|
+
// Allowed once the page has been interacted with; until then this is refused and the sound is skipped.
|
|
85
|
+
void audio.resume().then(play, () => undefined);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
play();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Audio is a nicety: a failure to make a sound never reaches the notification.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Forgets the audio context and what was played when - for tests. */
|
|
96
|
+
export function resetNotificationSounds() {
|
|
97
|
+
context = undefined;
|
|
98
|
+
for (const key of Object.keys(lastPlayed)) {
|
|
99
|
+
delete lastPlayed[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -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
|
export const MAX_VISIBLE = 3;
|
|
7
8
|
export const MAX_QUEUED = 20;
|
|
8
9
|
export const QUEUE_STALE_MS = 30000;
|
|
@@ -240,6 +241,16 @@ export function notify(input) {
|
|
|
240
241
|
emit();
|
|
241
242
|
return id;
|
|
242
243
|
}
|
|
244
|
+
// What is shown is heard: a chime for new mail, a bell for a calendar notice, a buzz for an error.
|
|
245
|
+
if (entry.kind === "mail") {
|
|
246
|
+
playNotificationSound("mail");
|
|
247
|
+
}
|
|
248
|
+
else if (entry.kind === "calendar") {
|
|
249
|
+
playNotificationSound("calendar");
|
|
250
|
+
}
|
|
251
|
+
else if (entry.kind === "error") {
|
|
252
|
+
playNotificationSound("error");
|
|
253
|
+
}
|
|
243
254
|
const room = visibleCount() < MAX_VISIBLE;
|
|
244
255
|
entries.push(entry);
|
|
245
256
|
if (room) {
|
|
@@ -4,6 +4,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
7
|
+
import { useLocation, useNavigate } from "../../shared/navigation/index.js";
|
|
7
8
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
8
9
|
import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
9
10
|
import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
|
|
@@ -16,7 +17,7 @@ import { getPushClient } from "../../../lib/mail/pushClient.js";
|
|
|
16
17
|
import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
17
18
|
import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
|
|
18
19
|
import { createFolder } from "../../../lib/mail/mailApi.js";
|
|
19
|
-
import { expandAllOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
20
|
+
import { expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
20
21
|
import { useDayKey } from "../../../lib/calendar/useDayKey.js";
|
|
21
22
|
import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
|
|
22
23
|
import Drawer from "../../../lib/components/overlays/Drawer.js";
|
|
@@ -443,6 +444,34 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
443
444
|
function closeModal() {
|
|
444
445
|
setModal(null);
|
|
445
446
|
}
|
|
447
|
+
// `?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
|
|
448
|
+
// 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
|
|
449
|
+
// address so a reload doesn't open it again.
|
|
450
|
+
const { search: locationSearch } = useLocation();
|
|
451
|
+
const navigateTo = useNavigate();
|
|
452
|
+
const openedFromAddressRef = useRef("");
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
const params = new URLSearchParams(locationSearch);
|
|
455
|
+
const eventUid = params.get("event");
|
|
456
|
+
const wanted = `${eventUid}|${params.get("start")}`;
|
|
457
|
+
if (!eventUid || openedFromAddressRef.current === wanted) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
openedFromAddressRef.current = wanted;
|
|
461
|
+
const requestedStart = new Date(params.get("start") ?? "");
|
|
462
|
+
navigateTo("/calendar", { replace: true });
|
|
463
|
+
getCalendarEvent(eventUid, client)
|
|
464
|
+
.then((event) => {
|
|
465
|
+
const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
|
|
466
|
+
const candidates = expandOccurrences(event, new Date(from.getTime() - 86400000), new Date(from.getTime() + 86400000));
|
|
467
|
+
const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
|
|
468
|
+
if (match) {
|
|
469
|
+
setViewDate(occurrenceDay(match));
|
|
470
|
+
openEvent(match);
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
.catch((err) => notifyApiError(err, "Couldn't open the event"));
|
|
474
|
+
}, [locationSearch]);
|
|
446
475
|
// Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
|
|
447
476
|
useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
|
|
448
477
|
useShortcut(SHORTCUTS.calendar.today, goToday);
|
package/package.json
CHANGED