@rapidmx/web-client 0.33.0 → 0.35.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.
Files changed (60) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/calendar/calendarReminders.ts +16 -7
  3. package/apps/shared/calendar/useCalendarReminders.ts +66 -61
  4. package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  7. package/apps/shared/components/layout/AppShell.tsx +4 -2
  8. package/apps/shared/components/layout/UserMenu.tsx +29 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  10. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  11. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  12. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  13. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  14. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  15. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  16. package/apps/shared/notifications/preferences.ts +27 -0
  17. package/apps/shared/notifications/sounds.ts +135 -0
  18. package/apps/shared/notifications/store.ts +9 -0
  19. package/apps/www/calendar/index.tsx +64 -32
  20. package/apps/www/contacts/index.tsx +58 -2
  21. package/apps/www/tasks/index.tsx +42 -0
  22. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  23. package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
  24. package/dist/apps/shared/calendar/calendarReminders.js +14 -6
  25. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
  26. package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
  27. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
  28. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
  29. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  30. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  31. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  32. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  33. package/dist/apps/shared/components/layout/AppShell.js +4 -3
  34. package/dist/apps/shared/components/layout/UserMenu.js +9 -3
  35. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  36. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  37. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  38. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  39. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  40. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  41. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  42. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  43. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  44. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  45. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  46. package/dist/apps/shared/notifications/preferences.d.ts +8 -0
  47. package/dist/apps/shared/notifications/preferences.js +27 -0
  48. package/dist/apps/shared/notifications/sounds.d.ts +15 -0
  49. package/dist/apps/shared/notifications/sounds.js +101 -0
  50. package/dist/apps/shared/notifications/store.js +11 -0
  51. package/dist/apps/www/calendar/index.js +37 -8
  52. package/dist/apps/www/contacts/index.js +49 -3
  53. package/dist/apps/www/tasks/index.js +29 -1
  54. package/dist/lib/mail/pushClient.d.ts +10 -3
  55. package/dist/lib/mail/pushClient.js +18 -4
  56. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  57. package/dist/lib/tasks/tasksApi.js +3 -0
  58. package/lib/mail/pushClient.ts +18 -4
  59. package/lib/tasks/tasksApi.ts +4 -0
  60. package/package.json +1 -1
@@ -10,6 +10,7 @@ import {
10
10
  Contact,
11
11
  createContact,
12
12
  deleteContact,
13
+ getContact,
13
14
  listContacts,
14
15
  ensureSuggestedContacts,
15
16
  listContactsInFolders,
@@ -42,6 +43,18 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
42
43
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
43
44
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
44
45
  import { notify } from "../../shared/notifications/store.js";
46
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
47
+
48
+ /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
49
+ const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
50
+
51
+ /** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
52
+ const CONTACTS_CHANNEL_GROUP = "contacts";
53
+
54
+ /** `list` with `contact` in place of the one with its uid, or added when it has none. */
55
+ function upsertContact(list: Contact[], contact: Contact): Contact[] {
56
+ return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
57
+ }
45
58
 
46
59
  const INPUT_CLASS =
47
60
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -166,6 +179,45 @@ function ContactsContent({ userUid }: { userUid?: string }) {
166
179
  };
167
180
  }, [mailboxUid]);
168
181
 
182
+ // A contact created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by
183
+ // its uid and filed in the list of the folder it is in now (the mailbox's contacts, or its suggested contacts), and taken out of the other; one that is
184
+ // gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
185
+ useFolderLiveUpdates(
186
+ CONTACTS_CHANNEL_GROUP,
187
+ [folderUid, suggestedFolderUid].filter((uid): uid is string => !!uid),
188
+ (event) => changedItemOf(event, CONTACT_MODEL),
189
+ (change) => {
190
+ const remove = (uid: string) => {
191
+ setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
192
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
193
+ };
194
+ if (change.deleted) {
195
+ remove(change.uid);
196
+ return;
197
+ }
198
+ void getContact(change.uid, client).then(
199
+ (fresh) => {
200
+ if (fresh.deleted === true) {
201
+ remove(fresh.uid);
202
+ } else if (fresh.folderUid === folderUid) {
203
+ setContacts((previous) => upsertContact(previous, fresh));
204
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
205
+ } else if (fresh.folderUid === suggestedFolderUid) {
206
+ setSuggestedContacts((previous) => upsertContact(previous, fresh));
207
+ setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
208
+ } else {
209
+ remove(fresh.uid);
210
+ }
211
+ },
212
+ (err: unknown) => {
213
+ if (err instanceof ApiRequestError && err.status === 404) {
214
+ remove(change.uid);
215
+ }
216
+ },
217
+ );
218
+ },
219
+ );
220
+
169
221
  // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
170
222
  // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
171
223
  // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
@@ -228,6 +280,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
228
280
  return contacts;
229
281
  case "favorites":
230
282
  return contacts.filter((c) => c.favorite);
283
+ case "everyone":
284
+ // The mailbox's own contacts and its suggested ones in one list (a person is in only one of them: a suggestion that is accepted is moved).
285
+ return [...contacts, ...suggestedContacts];
231
286
  case "suggested":
232
287
  return suggestedContacts;
233
288
  case "list":
@@ -428,7 +483,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
428
483
 
429
484
  // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
430
485
  const handleAddToContacts = singleFlight(async () => {
431
- for (const contact of checkedContacts) {
486
+ // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
487
+ for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
432
488
  try {
433
489
  await createContact(
434
490
  {
@@ -540,7 +596,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
540
596
  onToggleFavorite={handleToggleFavorite}
541
597
  onAddCategory={handleAddCategory}
542
598
  onExportVCard={handleExportVCard}
543
- onAddToContacts={view.type === "suggested" ? handleAddToContacts : undefined}
599
+ onAddToContacts={view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined}
544
600
  onImportFile={handleImportFile}
545
601
  shortcuts
546
602
  hideNew={isMobile}
@@ -11,6 +11,7 @@ import {
11
11
  TaskPriority,
12
12
  createTask,
13
13
  deleteTask,
14
+ getTask,
14
15
  listTasks,
15
16
  setTaskCompleted,
16
17
  setTaskMyDay,
@@ -34,6 +35,13 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
34
35
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
35
36
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
36
37
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
38
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
39
+
40
+ /** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
41
+ const TASK_MODEL = /^Task(Mongo|SQL)$/;
42
+
43
+ /** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
44
+ const TASKS_CHANNEL_GROUP = "tasks";
37
45
 
38
46
  const INPUT_CLASS =
39
47
  "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -146,6 +154,40 @@ function TasksContent() {
146
154
  void reload();
147
155
  }, [folderUid]);
148
156
 
157
+ // A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
158
+ // uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
159
+ // failed fetch changes nothing: the next load shows it as it is.
160
+ useFolderLiveUpdates(
161
+ TASKS_CHANNEL_GROUP,
162
+ folderUid ? [folderUid] : [],
163
+ (event) => changedItemOf(event, TASK_MODEL),
164
+ (change) => {
165
+ const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
166
+ if (change.deleted) {
167
+ remove();
168
+ return;
169
+ }
170
+ void getTask(change.uid, client).then(
171
+ (fresh) => {
172
+ if (fresh.folderUid !== folderUid) {
173
+ remove();
174
+ return;
175
+ }
176
+ setTasks((previous) =>
177
+ previous.some((existing) => existing.uid === fresh.uid)
178
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
179
+ : [...previous, fresh],
180
+ );
181
+ },
182
+ (err: unknown) => {
183
+ if (err instanceof ApiRequestError && err.status === 404) {
184
+ remove();
185
+ }
186
+ },
187
+ );
188
+ },
189
+ );
190
+
149
191
  useEffect(() => {
150
192
  setTargetMailboxUid(undefined);
151
193
  setAddedElsewhere(null);
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
6
+ import { changedItemOf } from "../live/useFolderLiveUpdates.js";
6
7
  /** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
7
8
  * which has its own action. */
8
9
  const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
@@ -16,9 +17,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
16
17
  * changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
17
18
  */
18
19
  export function changedEventOf(event) {
19
- if (!EVENT_MODEL.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
20
- return undefined;
21
- }
22
- const data = event.data;
23
- return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
20
+ return changedItemOf(event, EVENT_MODEL);
24
21
  }
@@ -1,7 +1,7 @@
1
1
  /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
2
2
  * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
3
3
  import type { PushEvent } from "../../../lib/mail/pushClient.js";
4
- /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
4
+ /** How long "Snooze" waits before the alarm comes back. */
5
5
  export declare const SNOOZE_MS: number;
6
6
  /** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
7
7
  export interface CalendarReminderNotice {
@@ -17,11 +17,13 @@ export interface CalendarReminderNotice {
17
17
  * else (a room name, an address, free-text instructions are all real, common `location` values this must not
18
18
  * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
19
19
  export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
20
+ /** What the alarm's button that opens an event's location URL is called: "Join" for a video meeting (a conferencing service's link, or any link that says
21
+ * it is one to join), "Open" for any other web address. */
22
+ export declare function locationActionLabel(url: string): "Join" | "Open";
23
+ /** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
24
+ export declare function calendarEventHref(eventUid: string, startDate: string): string;
20
25
  /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
21
26
  export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
22
- /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
23
- * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
24
- export declare function snoozeDelayMs(startDate: string, now?: number): number;
25
27
  /** The pop-up's message line: when the meeting starts, relative to `now`. */
26
28
  export declare function reminderMessage(startDate: string, now?: number): string;
27
29
  /** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
@@ -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
+ }
@@ -2,6 +2,8 @@ import React from "react";
2
2
  import { Contact } from "../../../../lib/contacts/contactsApi.js";
3
3
  export type ContactsView = {
4
4
  type: "all";
5
+ } | {
6
+ type: "everyone";
5
7
  } | {
6
8
  type: "favorites";
7
9
  } | {
@@ -35,7 +37,7 @@ export interface ContactsSidebarProps {
35
37
  refreshToken?: number;
36
38
  }
37
39
  /**
38
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
40
+ * Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
39
41
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
40
42
  * distinct `categories` values across the loaded contacts — no separate category registry exists
41
43
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
@@ -39,7 +39,7 @@ function NavItem({ label, count, active, onClick, swatch, }) {
39
39
  ].join(" "), children: [_jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [swatch && _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: swatch } }), _jsx("span", { className: "truncate", children: label })] }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
40
40
  }
41
41
  /**
42
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
42
+ * Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
43
43
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
44
44
  * distinct `categories` values across the loaded contacts — no separate category registry exists
45
45
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
@@ -104,6 +104,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
104
104
  // A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
105
105
  // `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
106
106
  // behind the `idPrefix`).
107
- const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
107
+ const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [suggestedCount !== undefined && (_jsx(NavItem, { label: "All contacts", count: contacts.length + suggestedCount, active: active.type === "everyone", onClick: () => handleSelect({ type: "everyone" }) })), _jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
108
108
  return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
109
109
  }
@@ -26,8 +26,8 @@ export declare function findContact(folders: readonly Folder[], address: string,
26
26
  export declare function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]>;
27
27
  /** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
28
28
  export declare function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]>;
29
- /** A vCard attachment's text. */
30
- export declare function fetchVCardText(attachmentUid: string): Promise<string>;
29
+ /** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
30
+ export declare function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string>;
31
31
  /**
32
32
  * The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
33
33
  * message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
@@ -63,8 +63,11 @@ export async function loadUpcomingEvents(folders, address, now, client) {
63
63
  .sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
64
64
  .slice(0, CARD_LIST_SIZE);
65
65
  }
66
- /** A vCard attachment's text. */
67
- export async function fetchVCardText(attachmentUid) {
66
+ /** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
67
+ export async function fetchVCardText(attachmentUid, client) {
68
+ if (client) {
69
+ return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
70
+ }
68
71
  const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
69
72
  if (!res.ok) {
70
73
  throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
@@ -87,7 +90,7 @@ export async function loadParticipantCard(participant, context, client) {
87
90
  const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
88
91
  let fallback;
89
92
  for (const attachment of await vCardAttachments(context, client)) {
90
- const cards = parseVCards(await fetchVCardText(attachment.uid));
93
+ const cards = parseVCards(await fetchVCardText(attachment.uid, client));
91
94
  const match = cardForAddress(cards, participant.address, false);
92
95
  if (match) {
93
96
  return match;
@@ -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