@rapidmx/web-client 0.32.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.
Files changed (68) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +16 -7
  2. package/apps/shared/calendar/useCalendarReminders.ts +66 -61
  3. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
  4. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +24 -4
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +3 -3
  6. package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
  7. package/apps/shared/components/calendar/EventDetails.tsx +10 -0
  8. package/apps/shared/components/calendar/EventFormParts.tsx +10 -0
  9. package/apps/shared/components/calendar/MonthView.tsx +10 -4
  10. package/apps/shared/components/contacts/ContactCard.tsx +4 -2
  11. package/apps/shared/components/contacts/ContactsSidebar.tsx +8 -2
  12. package/apps/shared/components/contacts/ContactsToolbar.tsx +6 -0
  13. package/apps/shared/components/layout/AppShell.tsx +4 -2
  14. package/apps/shared/components/layout/UserMenu.tsx +29 -1
  15. package/apps/shared/components/mail/ConversationThreadPane.tsx +34 -2
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
  17. package/apps/shared/components/mail/compose/draftResume.ts +54 -0
  18. package/apps/shared/notifications/preferences.ts +27 -0
  19. package/apps/shared/notifications/sounds.ts +135 -0
  20. package/apps/shared/notifications/store.ts +9 -0
  21. package/apps/www/calendar/index.tsx +35 -5
  22. package/apps/www/contacts/index.tsx +75 -3
  23. package/apps/www/tasks/index.tsx +1 -1
  24. package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
  25. package/dist/apps/shared/calendar/calendarReminders.js +14 -6
  26. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
  27. package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
  28. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +3 -2
  29. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
  30. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
  31. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
  32. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
  33. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  34. package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
  35. package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
  36. package/dist/apps/shared/components/calendar/MonthView.js +4 -4
  37. package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
  38. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
  39. package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
  40. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  41. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
  42. package/dist/apps/shared/components/layout/AppShell.js +4 -3
  43. package/dist/apps/shared/components/layout/UserMenu.js +9 -3
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +35 -1
  45. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
  46. package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
  47. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
  48. package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
  49. package/dist/apps/shared/notifications/preferences.d.ts +8 -0
  50. package/dist/apps/shared/notifications/preferences.js +27 -0
  51. package/dist/apps/shared/notifications/sounds.d.ts +15 -0
  52. package/dist/apps/shared/notifications/sounds.js +101 -0
  53. package/dist/apps/shared/notifications/store.js +11 -0
  54. package/dist/apps/www/calendar/index.js +34 -5
  55. package/dist/apps/www/contacts/index.js +63 -5
  56. package/dist/apps/www/tasks/index.js +1 -1
  57. package/dist/lib/calendar/recurrence.js +1 -1
  58. package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
  59. package/dist/lib/components/overlays/overlayStack.js +10 -0
  60. package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
  61. package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
  62. package/dist/lib/contacts/contactsApi.d.ts +12 -0
  63. package/dist/lib/contacts/contactsApi.js +8 -0
  64. package/lib/calendar/recurrence.ts +1 -1
  65. package/lib/components/overlays/overlayStack.ts +11 -0
  66. package/lib/components/pickers/MiniDatePicker.tsx +4 -4
  67. package/lib/contacts/contactsApi.ts +16 -0
  68. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { PushEvent } from "../../../lib/mail/pushClient.js";
9
9
 
10
- /** How long "Snooze" waits before the 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
  }
@@ -4,6 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useMemo, useState } from "react";
6
6
  import Button from "../../../../../lib/components/buttons/Button.js";
7
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
7
8
  import type { DiagnosticsSetting } from "./diagnosticsApi.js";
8
9
 
9
10
  /** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
@@ -83,9 +84,19 @@ export default function SettingsTable({ title, description, id, settings }: Sett
83
84
  <tbody>
84
85
  {shown.map((item) => (
85
86
  <tr key={item.name}>
86
- <td className="py-2 px-2.5 border-b border-border font-mono break-all">{item.name}</td>
87
87
  <td className="py-2 px-2.5 border-b border-border font-mono break-all">
88
- {item.redacted ? <span className="text-text-muted">{HIDDEN_VALUE}</span> : item.value}
88
+ {item.name}
89
+ <CopyIconButton value={item.name} label={`Copy the name of ${item.name}`} />
90
+ </td>
91
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">
92
+ {item.redacted ? (
93
+ <span className="text-text-muted">{HIDDEN_VALUE}</span>
94
+ ) : (
95
+ <>
96
+ {item.value}
97
+ <CopyIconButton value={item.value ?? ""} label={`Copy the value of ${item.name}`} />
98
+ </>
99
+ )}
89
100
  </td>
90
101
  </tr>
91
102
  ))}
@@ -4,6 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React from "react";
6
6
  import { Mailbox } from "../../../../../lib/mail/mailApi.js";
7
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
7
8
 
8
9
  function formatBytes(bytes: number): string {
9
10
  if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
@@ -12,6 +13,16 @@ function formatBytes(bytes: number): string {
12
13
  return `${bytes} B`;
13
14
  }
14
15
 
16
+ /** A cell's value with the icon button that copies it. */
17
+ function Copyable({ value, label }: { value: string; label: string }) {
18
+ return (
19
+ <span className="inline-flex items-center gap-1">
20
+ {value}
21
+ <CopyIconButton value={value} label={label} />
22
+ </span>
23
+ );
24
+ }
25
+
15
26
  export interface MailboxTableProps {
16
27
  mailboxes: Mailbox[];
17
28
  }
@@ -44,17 +55,26 @@ export default function MailboxTable({ mailboxes }: MailboxTableProps) {
44
55
  <tbody>
45
56
  {mailboxes.map((mailbox) => (
46
57
  <tr key={mailbox.uid}>
47
- <td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.primarySmtpAddress}</td>
48
- <td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.displayName}</td>
49
58
  <td className="py-2.5 px-2.5 border-b border-border align-middle">
50
- {mailbox.ownerUserUid ?? (
59
+ <Copyable value={mailbox.primarySmtpAddress} label={`Copy the address of ${mailbox.primarySmtpAddress}`} />
60
+ </td>
61
+ <td className="py-2.5 px-2.5 border-b border-border align-middle">
62
+ <Copyable value={mailbox.displayName} label={`Copy the display name of ${mailbox.primarySmtpAddress}`} />
63
+ </td>
64
+ <td className="py-2.5 px-2.5 border-b border-border align-middle">
65
+ {mailbox.ownerUserUid ? (
66
+ <Copyable value={mailbox.ownerUserUid} label={`Copy the owner of ${mailbox.primarySmtpAddress}`} />
67
+ ) : (
51
68
  <span className="inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
52
69
  Shared
53
70
  </span>
54
71
  )}
55
72
  </td>
56
73
  <td className="py-2.5 px-2.5 border-b border-border align-middle">
57
- {formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
74
+ <Copyable
75
+ value={`${formatBytes(mailbox.usedBytes)} / ${formatBytes(mailbox.quotaBytes)}`}
76
+ label={`Copy the quota used of ${mailbox.primarySmtpAddress}`}
77
+ />
58
78
  </td>
59
79
  <td className="py-2.5 px-2.5 border-b border-border align-middle text-right">
60
80
  <a
@@ -6,7 +6,7 @@ import React, { useEffect, useState } from "react";
6
6
  import { DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
7
7
  import Alert from "../ActionAlert.js";
8
8
  import Button from "../../../../../lib/components/buttons/Button.js";
9
- import CopyButton from "../../../../../lib/components/buttons/CopyButton.js";
9
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
10
10
  import { actionErrorMessage } from "../elevation.js";
11
11
 
12
12
  const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
@@ -51,12 +51,12 @@ function splitSrvValue(value: string): { priority: string; weight: string; port:
51
51
  return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
52
52
  }
53
53
 
54
- /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
54
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
55
55
  function CopyableValue({ value, copyLabel }: { value: string; copyLabel: string }) {
56
56
  return (
57
57
  <div className="flex items-start gap-2">
58
58
  <code className="flex-1 min-w-0 text-xs break-all">{value}</code>
59
- <CopyButton value={value} label={copyLabel} />
59
+ <CopyIconButton value={value} label={copyLabel} className="shrink-0" />
60
60
  </div>
61
61
  );
62
62
  }
@@ -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
+ }
@@ -25,6 +25,7 @@ import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
25
25
  import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
26
26
  import Alert from "../../../../lib/components/feedback/Alert.js";
27
27
  import Button from "../../../../lib/components/buttons/Button.js";
28
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
28
29
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
29
30
  import ParticipantLink from "../contacts/ParticipantLink.js";
30
31
  import EventDescriptionView from "./EventDescriptionView.js";
@@ -273,6 +274,15 @@ export default function EventDetails({
273
274
  </span>
274
275
  )}
275
276
  </div>
277
+ {joinUrl && (
278
+ // The link itself, for when the button is not enough (a blocked pop-up, a link to open in another browser or share).
279
+ <div className="mt-1.5 flex items-center gap-1 min-w-0 text-xs text-text-muted">
280
+ <a href={joinUrl} target="_blank" rel="noopener noreferrer" className="break-all underline hover:text-text">
281
+ {joinUrl}
282
+ </a>
283
+ <CopyIconButton value={joinUrl} label="Copy the video call link" className="shrink-0" />
284
+ </div>
285
+ )}
276
286
  </DetailRow>
277
287
  )}
278
288
 
@@ -6,6 +6,7 @@ import React, { ReactNode, useRef, useState } from "react";
6
6
  import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineMapPin, HiOutlineUserGroup, HiOutlineVideoCamera, HiOutlineXMark } from "react-icons/hi2";
7
7
  import { AttendeeRole, EventVisibility, RecurrenceRule, WeekdayCode } from "../../../../lib/calendar/calendarApi.js";
8
8
  import Button from "../../../../lib/components/buttons/Button.js";
9
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
9
10
  import GuestInput from "./GuestInput.js";
10
11
  import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
11
12
  import RecurrenceEditor from "./RecurrenceEditor.js";
@@ -103,6 +104,15 @@ export function VideoConferencingRow({ c }: { c: EventFormController }) {
103
104
  )}
104
105
  </div>
105
106
  )}
107
+ {c.videoMeetingUid && c.joinUrl && (
108
+ // The link itself, for when the button is not enough (a blocked pop-up, a link to open in another browser or share).
109
+ <div className="mb-1 flex items-center gap-1 min-w-0 text-xs text-text-muted">
110
+ <a href={c.joinUrl} target="_blank" rel="noopener noreferrer" className="break-all underline hover:text-text">
111
+ {c.joinUrl}
112
+ </a>
113
+ <CopyIconButton value={c.joinUrl} label="Copy the video call link" className="shrink-0" />
114
+ </div>
115
+ )}
106
116
  </IconRow>
107
117
  );
108
118
  }
@@ -27,15 +27,21 @@ export interface MonthViewProps {
27
27
  onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
28
28
  }
29
29
 
30
- /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
30
+ /** A real 6-week month grid (Sunday-start), matching Outlook/Gmail's month view. Multi-day and all-day
31
31
  * events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
32
32
  export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: MonthViewProps) {
33
- const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
34
- const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
33
+ const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
34
+ const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
35
35
  const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
36
36
 
37
37
  return (
38
- <div className="flex-1 grid grid-cols-7 grid-rows-6 min-h-0" role="grid" aria-label="Month">
38
+ <div className="flex-1 grid grid-cols-7 grid-rows-[auto_repeat(6,minmax(0,1fr))] min-h-0" role="grid" aria-label="Month">
39
+ {days.slice(0, 7).map((day) => (
40
+ <div key={day.getDay()} role="columnheader" className="px-2 py-1 text-xs font-semibold text-text-muted border-b border-border">
41
+ <span className="sm:hidden">{format(day, "EEEEE")}</span>
42
+ <span className="hidden sm:inline">{format(day, "EEEE")}</span>
43
+ </div>
44
+ ))}
39
45
  {days.map((day) => (
40
46
  <DayCell
41
47
  key={day.toISOString()}
@@ -22,6 +22,7 @@ import { useMailboxUpdateAccess } from "../../mail/useMailboxUpdateAccess.js";
22
22
  import { notify } from "../../notifications/store.js";
23
23
  import { notifyApiError } from "../../notifications/apiErrors.js";
24
24
  import { APP_HREFS } from "../../navigation/appHrefs.js";
25
+ import { closeAllOverlays } from "../../../../lib/components/overlays/overlayStack.js";
25
26
  import { useCompose } from "../mail/compose/ComposeContext.js";
26
27
  import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
27
28
  import {
@@ -234,6 +235,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
234
235
  }
235
236
 
236
237
  function handleEmail() {
238
+ // Every card open, not just this one: it may have been reached from another (a guest of an event), which would stay on screen behind the new window.
239
+ closeAllOverlays();
237
240
  openCompose({ to: formatMailAddress(participant) });
238
241
  onClose();
239
242
  }
@@ -261,9 +264,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
261
264
  {stored && <FavoriteStarButton favorite={!!stored.favorite} disabled={photoBusy} onToggle={() => void handleToggleFavorite(stored)} className="shrink-0 self-start" />}
262
265
  </div>
263
266
  <div className="mt-4 flex flex-wrap items-center gap-2">
264
- <Button type="button" variant="secondary" className={`${ACTION_CLASS} inline-flex items-center gap-1.5`} onClick={handleEmail}>
267
+ <Button type="button" variant="secondary" aria-label="Email" title="Email" className={`${ACTION_CLASS} inline-flex items-center`} onClick={handleEmail}>
265
268
  <HiOutlineEnvelope aria-hidden="true" className="size-4" />
266
- Email
267
269
  </Button>
268
270
  {stored ? (
269
271
  <a
@@ -13,6 +13,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
13
13
  export type ContactsView =
14
14
  | { type: "all" }
15
15
  | { type: "favorites" }
16
+ | { type: "suggested" }
16
17
  | { type: "list"; uid: string; name: string }
17
18
  | { type: "deleted" }
18
19
  | { type: "category"; name: string };
@@ -49,6 +50,8 @@ export interface ContactsSidebarProps {
49
50
  contacts: Contact[];
50
51
  active: ContactsView;
51
52
  onSelect: (view: ContactsView) => void;
53
+ /** How many contacts the mailbox's Suggested Contacts folder holds; `undefined` when it has none to show (the folder isn't known), which hides the view. */
54
+ suggestedCount?: number;
52
55
  /** Whether to offer the "Deleted" view - restapi only honors `deleted=true` for a caller with delete and
53
56
  * update rights on the mailbox (anyone else silently gets the live contacts), so the parent hides it
54
57
  * otherwise. Defaults to `true`. */
@@ -91,13 +94,13 @@ function NavItem({
91
94
  }
92
95
 
93
96
  /**
94
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
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
95
98
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
96
99
  * distinct `categories` values across the loaded contacts — no separate category registry exists
97
100
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
98
101
  * `ContactsShellContext` itself minimal (mailbox/folder resolution only).
99
102
  */
100
- export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true }: ContactsSidebarProps) {
103
+ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true, suggestedCount }: ContactsSidebarProps) {
101
104
  const client = useApiClient();
102
105
  const [lists, setLists] = useState<ContactList[]>([]);
103
106
  const [listsError, setListsError] = useState<string | null>(null);
@@ -165,6 +168,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
165
168
  <div className="flex flex-col gap-0.5">
166
169
  <NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
167
170
  <NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
171
+ {suggestedCount !== undefined && (
172
+ <NavItem label="Suggested contacts" count={suggestedCount} active={active.type === "suggested"} onClick={() => handleSelect({ type: "suggested" })} />
173
+ )}
168
174
  {showDeleted && <NavItem label="Deleted" active={active.type === "deleted"} onClick={() => handleSelect({ type: "deleted" })} />}
169
175
  </div>
170
176
 
@@ -30,6 +30,8 @@ export interface ContactsToolbarProps {
30
30
  onToggleFavorite: () => void;
31
31
  onAddCategory: () => void;
32
32
  onExportVCard: () => void;
33
+ /** Turns the selected suggested contacts into contacts of the mailbox. Given only in the Suggested contacts view, which is also the only place the button is offered. */
34
+ onAddToContacts?: () => void;
33
35
  /** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
34
36
  * page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
35
37
  onImportFile: (file: File) => void;
@@ -58,6 +60,7 @@ export default function ContactsToolbar({
58
60
  onToggleFavorite,
59
61
  onAddCategory,
60
62
  onExportVCard,
63
+ onAddToContacts,
61
64
  onImportFile,
62
65
  shortcuts,
63
66
  hideNew,
@@ -91,6 +94,9 @@ export default function ContactsToolbar({
91
94
  rank: 3,
92
95
  },
93
96
  { id: "category", label: "Add category", icon: HiOutlineTag, onClick: onAddCategory, disabled: !hasSelection, group: 2, rank: 2 },
97
+ ...(onAddToContacts
98
+ ? [{ id: "addToContacts", label: "Add to contacts", icon: HiOutlineUserPlus, onClick: onAddToContacts, disabled: !hasSelection, group: 1, rank: 7 }]
99
+ : []),
94
100
  { id: "export", label: "Export", icon: HiOutlineArrowUpTray, onClick: onExportVCard, disabled: !hasSelection, group: 3, rank: 1 },
95
101
  { id: "import", label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click(), group: 3, rank: 0 },
96
102
  ];
@@ -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}