@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.
@@ -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 reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
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
- /** 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
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
- * Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
12
- * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
13
- * (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
14
- * event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
15
- * own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
16
- * Mail, Calendar, Contacts, Settings.
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
- * Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
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): void;
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 { calendarReminderOf, joinMeetingUrl, reminderMessage, reminderNotificationId, snoozeDelayMs } from "./calendarReminders.js";
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
- * Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
14
- * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
15
- * (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
16
- * event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
17
- * own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
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
- * Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
21
- * closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
22
- * sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
23
- * reminder itself (the push event is never replayed), it does not survive the tab closing.
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
- show(notice);
48
+ present(notice);
71
49
  }
72
50
  });
51
+ const pending = timers.current;
73
52
  return () => {
74
53
  offEvent();
75
- for (const timer of timers) {
54
+ for (const timer of pending) {
76
55
  clearTimeout(timer);
77
56
  }
78
- timers.clear();
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
- // A meeting reminder pops up on whichever page they are - see the hook.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",