@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
@@ -5,6 +5,7 @@
5
5
  /** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
6
6
 
7
7
  import type { PushEvent } from "../../../lib/mail/pushClient.js";
8
+ import { changedItemOf } from "../live/useFolderLiveUpdates.js";
8
9
 
9
10
  /** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
10
11
  * which has its own action. */
@@ -20,9 +21,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
20
21
  * changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
21
22
  */
22
23
  export function changedEventOf(event: PushEvent): { uid: string; deleted: boolean } | undefined {
23
- if (!EVENT_MODEL.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
24
- return undefined;
25
- }
26
- const data = event.data as { uid?: unknown } | null | undefined;
27
- return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
24
+ return changedItemOf(event, EVENT_MODEL);
28
25
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { PushEvent } from "../../../lib/mail/pushClient.js";
9
9
 
10
- /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
10
+ /** How long "Snooze" waits before the alarm comes back. */
11
11
  export const SNOOZE_MS = 5 * 60_000;
12
12
 
13
13
  /** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
@@ -49,17 +49,26 @@ export function joinMeetingUrl(location: string | null | undefined): string | un
49
49
  return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
50
50
  }
51
51
 
52
+ /** What the alarm's button that opens an event's location URL is called: "Join" for a video meeting (a conferencing service's link, or any link that says
53
+ * it is one to join), "Open" for any other web address. */
54
+ export function locationActionLabel(url: string): "Join" | "Open" {
55
+ const { hostname, pathname } = new URL(url);
56
+ const host = hostname.toLowerCase();
57
+ const conferencing = /(^|\.)(zoom\.us|zoom\.com|zoomgov\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|meet\.jit\.si)$/;
58
+ const known = conferencing.test(host) || host === "meet.google.com" || host === "teams.microsoft.com" || host === "teams.live.com";
59
+ return known || /\/(join|meet|meeting|room|call)(\/|$)/i.test(pathname) || /^(meet|video|call|conference)\./.test(host) ? "Join" : "Open";
60
+ }
61
+
62
+ /** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
63
+ export function calendarEventHref(eventUid: string, startDate: string): string {
64
+ return `/calendar?event=${encodeURIComponent(eventUid)}&start=${encodeURIComponent(startDate)}`;
65
+ }
66
+
52
67
  /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
53
68
  export function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined {
54
69
  return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
55
70
  }
56
71
 
57
- /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
58
- * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
59
- export function snoozeDelayMs(startDate: string, now: number = Date.now()): number {
60
- return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
61
- }
62
-
63
72
  /** The pop-up's message line: when the meeting starts, relative to `now`. */
64
73
  export function reminderMessage(startDate: string, now: number = Date.now()): string {
65
74
  const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
@@ -2,14 +2,12 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { useEffect } from "react";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
6
  import { getPushClient } from "../../../lib/mail/pushClient.js";
7
+ import { getNotificationsEnabled } from "../notifications/preferences.js";
8
+ import { playNotificationSound } from "../notifications/sounds.js";
7
9
  import { notify } from "../notifications/store.js";
8
- import { CalendarReminderNotice, calendarReminderOf, joinMeetingUrl, reminderMessage, reminderNotificationId, snoozeDelayMs } from "./calendarReminders.js";
9
-
10
- /** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
11
- * `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
12
- export const CALENDAR_HREF = "/calendar";
10
+ import { CalendarReminderNotice, SNOOZE_MS, calendarReminderOf, reminderMessage, reminderNotificationId } from "./calendarReminders.js";
13
11
 
14
12
  export interface UseCalendarRemindersOptions {
15
13
  /** Nothing is shown without a signed-in user. */
@@ -18,76 +16,83 @@ export interface UseCalendarRemindersOptions {
18
16
  enabled: boolean;
19
17
  }
20
18
 
19
+ /** The calendar alarms waiting on the person, as `CalendarAlarmDialog` draws them. */
20
+ export interface CalendarAlarms {
21
+ /** The alarm on screen: the oldest one not yet answered. `undefined` when there is none. */
22
+ current?: CalendarReminderNotice;
23
+ /** How many more are waiting behind `current`. */
24
+ waiting: number;
25
+ /** Closes `current` and brings it back after `SNOOZE_MS` (five minutes). */
26
+ snooze: () => void;
27
+ /** Closes `current` for good. */
28
+ dismiss: () => void;
29
+ }
30
+
21
31
  /**
22
- * Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
23
- * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
24
- * (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
25
- * 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
26
- * own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
27
- * Mail, Calendar, Contacts, Settings.
32
+ * Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
33
+ * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
34
+ * every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
35
+ * mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its own. Lives in the persistent
36
+ * app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
37
+ *
38
+ * An alarm is a modal (`CalendarAlarmDialog`), with a bell: not a pop-up that goes by itself, since a meeting missed to a toast that timed out
39
+ * is the failure an alarm exists to prevent. Alarms that arrive together queue, one dialog at a time. **Snooze** closes it and brings it back -
40
+ * with the bell again - after exactly `SNOOZE_MS`. A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the alarm itself (the
41
+ * push event is never replayed), it does not survive the tab closing.
28
42
  *
29
- * Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
30
- * closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
31
- * sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
32
- * reminder itself (the push event is never replayed), it does not survive the tab closing.
43
+ * With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
33
44
  */
34
- export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): void {
35
- useEffect(() => {
36
- if (!enabled || !userUid) {
45
+ export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): CalendarAlarms {
46
+ const [queue, setQueue] = useState<CalendarReminderNotice[]>([]);
47
+ // The queue as of the last render, for the callbacks below to read (a state updater must not start a timer: React may run it twice).
48
+ const queueRef = useRef(queue);
49
+ queueRef.current = queue;
50
+ const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
51
+
52
+ const present = useCallback((notice: CalendarReminderNotice) => {
53
+ if (!getNotificationsEnabled()) {
54
+ // Off: only the history keeps it.
55
+ notify({ id: reminderNotificationId(notice.eventUid, notice.startDate), kind: "calendar", title: notice.title, message: reminderMessage(notice.startDate) });
37
56
  return;
38
57
  }
39
- const timers = new Set<ReturnType<typeof setTimeout>>();
58
+ const id = reminderNotificationId(notice.eventUid, notice.startDate);
59
+ setQueue((current) => (current.some((queued) => reminderNotificationId(queued.eventUid, queued.startDate) === id) ? current : [...current, notice]));
60
+ playNotificationSound("calendar");
61
+ }, []);
40
62
 
41
- function show(notice: CalendarReminderNotice): void {
42
- const joinUrl = joinMeetingUrl(notice.location);
43
- notify({
44
- id: reminderNotificationId(notice.eventUid, notice.startDate),
45
- kind: "calendar",
46
- title: notice.title,
47
- message: reminderMessage(notice.startDate),
48
- // Anyone who can invite you can put any address in an event's location: the button says where it goes.
49
- hint: joinUrl ? `Join Meeting opens ${new URL(joinUrl).host}` : undefined,
50
- href: CALENDAR_HREF,
51
- sticky: true,
52
- actions: [
53
- ...(joinUrl
54
- ? [
55
- {
56
- label: "Join Meeting",
57
- onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
58
- // Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
59
- keepOpen: true,
60
- },
61
- ]
62
- : []),
63
- { label: "Dismiss" },
64
- {
65
- label: "Snooze",
66
- onClick: () => {
67
- const timer = setTimeout(() => {
68
- timers.delete(timer);
69
- show(notice);
70
- }, snoozeDelayMs(notice.startDate));
71
- timers.add(timer);
72
- },
73
- },
74
- ],
75
- });
63
+ useEffect(() => {
64
+ if (!enabled || !userUid) {
65
+ return;
76
66
  }
77
-
78
67
  const offEvent = getPushClient().onEvent((event) => {
79
68
  const notice = calendarReminderOf(event);
80
69
  if (notice) {
81
- show(notice);
70
+ present(notice);
82
71
  }
83
72
  });
84
-
73
+ const pending = timers.current;
85
74
  return () => {
86
75
  offEvent();
87
- for (const timer of timers) {
76
+ for (const timer of pending) {
88
77
  clearTimeout(timer);
89
78
  }
90
- timers.clear();
79
+ pending.clear();
80
+ setQueue([]);
91
81
  };
92
- }, [enabled, userUid]);
82
+ }, [enabled, userUid, present]);
83
+
84
+ const dismiss = useCallback(() => setQueue((current) => current.slice(1)), []);
85
+ const snooze = useCallback(() => {
86
+ const first = queueRef.current[0];
87
+ if (first) {
88
+ const timer = setTimeout(() => {
89
+ timers.current.delete(timer);
90
+ present(first);
91
+ }, SNOOZE_MS);
92
+ timers.current.add(timer);
93
+ }
94
+ setQueue((current) => current.slice(1));
95
+ }, [present]);
96
+
97
+ return { current: queue[0], waiting: Math.max(0, queue.length - 1), snooze, dismiss };
93
98
  }
@@ -0,0 +1,75 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import Button from "../../../../lib/components/buttons/Button.js";
7
+ import Modal from "../../../../lib/components/overlays/Modal.js";
8
+ import { calendarEventHref, joinMeetingUrl, locationActionLabel, reminderMessage } from "../../calendar/calendarReminders.js";
9
+ import type { CalendarAlarms } from "../../calendar/useCalendarReminders.js";
10
+ import { useNavigate } from "../../navigation/index.js";
11
+
12
+ export interface CalendarAlarmDialogProps {
13
+ alarms: CalendarAlarms;
14
+ }
15
+
16
+ /**
17
+ * The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
18
+ * in the Calendar. **Join** or **Open** - named for what the event's location is, a video meeting or any other web address - opens that address in a new
19
+ * tab; it is only there when the location is a web address, and says which site it goes to, since anyone who can invite you can put any address in an
20
+ * event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
21
+ * View and Join/Open also dismiss it: the person has answered it.
22
+ */
23
+ export default function CalendarAlarmDialog({ alarms }: CalendarAlarmDialogProps) {
24
+ const navigate = useNavigate();
25
+ const { current: alarm, waiting, snooze, dismiss } = alarms;
26
+ if (!alarm) {
27
+ return null;
28
+ }
29
+ const url = joinMeetingUrl(alarm.location);
30
+ const location = alarm.location?.trim();
31
+ return (
32
+ <Modal open onClose={dismiss} title={alarm.title}>
33
+ <div className="flex flex-col gap-4">
34
+ <div className="flex flex-col gap-1 text-sm">
35
+ <p className="font-semibold">{reminderMessage(alarm.startDate)}</p>
36
+ {location && <p className="text-text-muted break-words">{location}</p>}
37
+ {waiting > 0 && (
38
+ <p className="text-xs text-text-muted">
39
+ {waiting} more alarm{waiting === 1 ? "" : "s"} after this one
40
+ </p>
41
+ )}
42
+ </div>
43
+ <div className="flex flex-wrap items-center gap-2">
44
+ <Button
45
+ type="button"
46
+ variant="secondary"
47
+ className="!w-auto"
48
+ onClick={() => {
49
+ navigate(calendarEventHref(alarm.eventUid, alarm.startDate));
50
+ dismiss();
51
+ }}
52
+ >
53
+ View
54
+ </Button>
55
+ {url && (
56
+ <Button
57
+ type="button"
58
+ className="!w-auto"
59
+ onClick={() => {
60
+ window.open(url, "_blank", "noopener,noreferrer");
61
+ dismiss();
62
+ }}
63
+ >
64
+ {locationActionLabel(url)}
65
+ </Button>
66
+ )}
67
+ <Button type="button" variant="secondary" className="!w-auto" onClick={snooze}>
68
+ Snooze
69
+ </Button>
70
+ </div>
71
+ {url && <p className="text-xs text-text-muted">{`${locationActionLabel(url)} opens ${new URL(url).host}`}</p>}
72
+ </div>
73
+ </Modal>
74
+ );
75
+ }
@@ -12,6 +12,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
12
12
 
13
13
  export type ContactsView =
14
14
  | { type: "all" }
15
+ | { type: "everyone" }
15
16
  | { type: "favorites" }
16
17
  | { type: "suggested" }
17
18
  | { type: "list"; uid: string; name: string }
@@ -94,7 +95,7 @@ function NavItem({
94
95
  }
95
96
 
96
97
  /**
97
- * 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
98
+ * 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
98
99
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
99
100
  * distinct `categories` values across the loaded contacts — no separate category registry exists
100
101
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
@@ -166,6 +167,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
166
167
  const navContent = (idPrefix: string) => (
167
168
  <>
168
169
  <div className="flex flex-col gap-0.5">
170
+ {suggestedCount !== undefined && (
171
+ <NavItem label="All contacts" count={contacts.length + suggestedCount} active={active.type === "everyone"} onClick={() => handleSelect({ type: "everyone" })} />
172
+ )}
169
173
  <NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
170
174
  <NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
171
175
  {suggestedCount !== undefined && (
@@ -104,8 +104,11 @@ export async function loadUpcomingEvents(folders: readonly Folder[], address: st
104
104
  .slice(0, CARD_LIST_SIZE);
105
105
  }
106
106
 
107
- /** A vCard attachment's text. */
108
- export async function fetchVCardText(attachmentUid: string): Promise<string> {
107
+ /** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
108
+ export async function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string> {
109
+ if (client) {
110
+ return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
111
+ }
109
112
  const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
110
113
  if (!res.ok) {
111
114
  throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
@@ -130,7 +133,7 @@ export async function loadParticipantCard(participant: Participant, context: Con
130
133
  const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
131
134
  let fallback: ParsedVCardContact | undefined;
132
135
  for (const attachment of await vCardAttachments(context, client)) {
133
- const cards = parseVCards(await fetchVCardText(attachment.uid));
136
+ const cards = parseVCards(await fetchVCardText(attachment.uid, client));
134
137
  const match = cardForAddress(cards, participant.address, false);
135
138
  if (match) {
136
139
  return match;
@@ -50,6 +50,7 @@ import { MailConnectionContext, useMailConnection } from "../../mail/useMailConn
50
50
  import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
51
51
  import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
52
52
  import NotificationCenter from "../../notifications/NotificationCenter.js";
53
+ import CalendarAlarmDialog from "../calendar/CalendarAlarmDialog.js";
53
54
  import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
54
55
  import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
55
56
  import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
@@ -210,8 +211,8 @@ export function AppChrome({
210
211
  const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
211
212
  // A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
212
213
  useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
213
- // A meeting reminder pops up on whichever page they are - see the hook.
214
- useCalendarReminders({ userUid, enabled: inFrame });
214
+ // An event's alarm raises a modal (drawn by `CalendarAlarmDialog`, below) on whichever page they are - see the hook.
215
+ const calendarAlarms = useCalendarReminders({ userUid, enabled: inFrame });
215
216
 
216
217
  // A refused refresh means the sign-in has ended: open compose windows save what they hold (the access token still works for a while) and the
217
218
  // browser goes to sign-in. Not while viewing as another user - the refresh cookie is the admin's own, and refreshing would end the impersonation.
@@ -459,6 +460,7 @@ export function AppChrome({
459
460
  )}
460
461
  {/* The one pop-up stack for the whole app: right under the header row, so it never covers the account menu. */}
461
462
  <NotificationCenter />
463
+ <CalendarAlarmDialog alarms={calendarAlarms} />
462
464
  <div
463
465
  id="app-content"
464
466
  tabIndex={-1}
@@ -10,6 +10,7 @@ import {
10
10
  HiOutlineArrowUturnLeft,
11
11
  HiOutlineBell,
12
12
  HiOutlineBellAlert,
13
+ HiOutlineSpeakerWave,
13
14
  HiOutlineClock,
14
15
  HiOutlineCog6Tooth,
15
16
  HiOutlineCommandLine,
@@ -34,7 +35,12 @@ import {
34
35
  requestDesktopPermission,
35
36
  setDesktopOfferDismissed,
36
37
  } from "../../mail/newMailNotifications.js";
37
- import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
38
+ import {
39
+ getNotificationSoundsEnabled,
40
+ getNotificationsEnabled,
41
+ setNotificationSoundsEnabled,
42
+ setNotificationsEnabled,
43
+ } from "../../notifications/preferences.js";
38
44
  import { dismissAll } from "../../notifications/store.js";
39
45
 
40
46
  export interface UserMenuProps {
@@ -134,6 +140,7 @@ export default function UserMenu({
134
140
  const menuRef = useRef<HTMLDivElement>(null);
135
141
  const [detectedAdmin, setDetectedAdmin] = useState(false);
136
142
  const [popups, setPopups] = useState(true);
143
+ const [sounds, setSounds] = useState(true);
137
144
  const [permission, setPermission] = useState<DesktopPermission>("unsupported");
138
145
  const [profile, setProfile] = useState<Profile | undefined>(undefined);
139
146
  // Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
@@ -230,10 +237,16 @@ export default function UserMenu({
230
237
  useEffect(() => {
231
238
  if (open && showNotificationSettings) {
232
239
  setPopups(getNotificationsEnabled());
240
+ setSounds(getNotificationSoundsEnabled());
233
241
  setPermission(desktopPermission());
234
242
  }
235
243
  }, [open, showNotificationSettings]);
236
244
 
245
+ function toggleSounds() {
246
+ setNotificationSoundsEnabled(!sounds);
247
+ setSounds(!sounds);
248
+ }
249
+
237
250
  function togglePopups() {
238
251
  setNotificationsEnabled(!popups);
239
252
  setPopups(!popups);
@@ -386,6 +399,21 @@ export default function UserMenu({
386
399
  <span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
387
400
  </button>
388
401
  )}
402
+ {showNotificationSettings && (
403
+ <button
404
+ role="menuitemcheckbox"
405
+ aria-checked={sounds}
406
+ type="button"
407
+ onClick={toggleSounds}
408
+ className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
409
+ >
410
+ <span className="flex items-center gap-2">
411
+ <HiOutlineSpeakerWave size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
412
+ Notification sounds
413
+ </span>
414
+ <span className="text-xs font-semibold text-text-muted">{sounds ? "On" : "Off"}</span>
415
+ </button>
416
+ )}
389
417
  {showNotificationSettings && permission === "default" && (
390
418
  <button
391
419
  role="menuitem"
@@ -16,7 +16,7 @@ import {
16
16
  HiOutlineNoSymbol,
17
17
  HiOutlineSun,
18
18
  } from "react-icons/hi2";
19
- import { ApiRequestError } from "../../../../lib/util/api.js";
19
+ import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
20
20
  import {
21
21
  Attachment,
22
22
  Folder,
@@ -200,6 +200,23 @@ function downloadFromUrl(url: string, filename: string): void {
200
200
  a.remove();
201
201
  }
202
202
 
203
+ /** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
204
+ const DOWNLOAD_URL_LIFETIME_MS = 60_000;
205
+
206
+ /**
207
+ * Saves the file at `path` through `client` (its origin and token, which a link's `href` cannot carry): its bytes become an object URL handed to
208
+ * `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
209
+ */
210
+ async function downloadThroughClient(client: ApiClient, path: string, filename: string, urls: Set<string>): Promise<void> {
211
+ const url = URL.createObjectURL(await client.fetchBlob(path));
212
+ urls.add(url);
213
+ downloadFromUrl(url, filename);
214
+ setTimeout(() => {
215
+ urls.delete(url);
216
+ URL.revokeObjectURL(url);
217
+ }, DOWNLOAD_URL_LIFETIME_MS);
218
+ }
219
+
203
220
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
204
221
  * opaque download (`application/octet-stream`), never rendered in this origin. */
205
222
  function downloadMimeAttachment(attachment: MimeAttachment): void {
@@ -529,6 +546,17 @@ function MessageDetailContent({
529
546
  const { mailboxes, trackMessageChange } = useMailShell();
530
547
  const { requestUnlock } = useUnlockPrompt();
531
548
  const client = useApiClient();
549
+ // Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
550
+ const downloadUrls = useRef(new Set<string>());
551
+ useEffect(() => {
552
+ const urls = downloadUrls.current;
553
+ return () => {
554
+ for (const url of urls) {
555
+ URL.revokeObjectURL(url);
556
+ }
557
+ urls.clear();
558
+ };
559
+ }, []);
532
560
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
533
561
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
534
562
  // state; see keySession.ts's own doc comment).
@@ -783,11 +811,23 @@ function MessageDetailContent({
783
811
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
784
812
  }
785
813
 
814
+ /** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
815
+ * cookie link always did. */
816
+ function saveAttachment(attachment: Attachment) {
817
+ if (client) {
818
+ downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch(
819
+ (err) => notifyApiError(err, "Couldn't download this attachment"),
820
+ );
821
+ return;
822
+ }
823
+ downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
824
+ }
825
+
786
826
  /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
787
827
  * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
788
828
  function prefetchReply() {
789
829
  prefetchComposeWindow();
790
- prefetchOriginalMessage(message);
830
+ prefetchOriginalMessage(message, client);
791
831
  }
792
832
 
793
833
  /**
@@ -812,7 +852,7 @@ function MessageDetailContent({
812
852
  // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
813
853
  const late = (async (): Promise<ComposeLateInput> => {
814
854
  const [original, own] = await Promise.all([
815
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
855
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
816
856
  kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
817
857
  ]);
818
858
  if (kind === "forward") {
@@ -1141,11 +1181,11 @@ function MessageDetailContent({
1141
1181
  * sanitized body - in a frame of its own; see `printMessage.ts`. */
1142
1182
  async function handlePrint() {
1143
1183
  try {
1144
- const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
1184
+ const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
1145
1185
  const older = await olderConversationMessages(message, client).catch(() => []);
1146
1186
  // A few at a time: a long conversation is hundreds of messages, each of them several requests.
1147
1187
  const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) =>
1148
- printableOlderMessage(other).catch(
1188
+ printableOlderMessage(other, client).catch(
1149
1189
  (): PrintableMessage => ({
1150
1190
  subject: other.subject || "(no subject)",
1151
1191
  headers: printHeaders(other, formatMailAddress(other.from)),
@@ -1643,9 +1683,18 @@ function MessageDetailContent({
1643
1683
  <VCardAttachmentChip
1644
1684
  label={`${attachment.filename} (${formatBytes(attachment.sizeBytes)})`}
1645
1685
  message={message}
1646
- loadText={() => fetchVCardText(attachment.uid)}
1647
- onDownload={() => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename)}
1686
+ loadText={() => fetchVCardText(attachment.uid, client)}
1687
+ onDownload={() => saveAttachment(attachment)}
1648
1688
  />
1689
+ ) : client ? (
1690
+ // Through the explicit client a link cannot carry the token, so the file is fetched and saved from a button.
1691
+ <button
1692
+ type="button"
1693
+ onClick={() => saveAttachment(attachment)}
1694
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1695
+ >
1696
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1697
+ </button>
1649
1698
  ) : (
1650
1699
  <a
1651
1700
  href={attachmentContentUrl(attachment.uid)}