@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
@@ -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
  }
@@ -1,10 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useMemo, useState } from "react";
7
7
  import Button from "../../../../../lib/components/buttons/Button.js";
8
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
8
9
  /** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
9
10
  export const SETTINGS_PAGE_SIZE = 100;
10
11
  /** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
@@ -27,5 +28,5 @@ export default function SettingsTable({ title, description, id, settings }) {
27
28
  setQuery(event.target.value);
28
29
  setLimit(SETTINGS_PAGE_SIZE);
29
30
  } }) }), _jsx("p", { role: "status", className: "mb-2 text-xs text-text-muted", children: (matching.length === settings.length ? `${settings.length} settings` : `${matching.length} of ${settings.length} settings match`) +
30
- (hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? "Nothing is set." : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.name }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.redacted ? _jsx("span", { className: "text-text-muted", children: HIDDEN_VALUE }) : item.value })] }, item.name))) })] }) })), shown.length < matching.length && (_jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLimit(limit + SETTINGS_PAGE_SIZE), children: "Show more" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Showing ", shown.length, " of ", matching.length] })] }))] }));
31
+ (hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? "Nothing is set." : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: [item.name, _jsx(CopyIconButton, { value: item.name, label: `Copy the name of ${item.name}` })] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.redacted ? (_jsx("span", { className: "text-text-muted", children: HIDDEN_VALUE })) : (_jsxs(_Fragment, { children: [item.value, _jsx(CopyIconButton, { value: item.value ?? "", label: `Copy the value of ${item.name}` })] })) })] }, item.name))) })] }) })), shown.length < matching.length && (_jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLimit(limit + SETTINGS_PAGE_SIZE), children: "Show more" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Showing ", shown.length, " of ", matching.length] })] }))] }));
31
32
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
2
3
  function formatBytes(bytes) {
3
4
  if (bytes >= 1000000000)
4
5
  return `${(bytes / 1000000000).toFixed(1)} GB`;
@@ -8,9 +9,13 @@ function formatBytes(bytes) {
8
9
  return `${(bytes / 1000).toFixed(1)} KB`;
9
10
  return `${bytes} B`;
10
11
  }
12
+ /** A cell's value with the icon button that copies it. */
13
+ function Copyable({ value, label }) {
14
+ return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [value, _jsx(CopyIconButton, { value: value, label: label })] }));
15
+ }
11
16
  export default function MailboxTable({ mailboxes }) {
12
17
  if (mailboxes.length === 0) {
13
18
  return _jsx("p", { className: "text-sm text-text-muted", children: "No mailboxes found." });
14
19
  }
15
- return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Address" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Display name" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Owner" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Quota used" }), _jsx("th", { className: "border-b border-border" })] }) }), _jsx("tbody", { children: mailboxes.map((mailbox) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.primarySmtpAddress }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.displayName }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.ownerUserUid ?? (_jsx("span", { className: "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Shared" })) }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle text-right", children: _jsx("a", { href: `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`, className: "text-primary-dark font-medium hover:underline", children: "View" }) })] }, mailbox.uid))) })] }) }));
20
+ return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Address" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Display name" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Owner" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Quota used" }), _jsx("th", { className: "border-b border-border" })] }) }), _jsx("tbody", { children: mailboxes.map((mailbox) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: _jsx(Copyable, { value: mailbox.primarySmtpAddress, label: `Copy the address of ${mailbox.primarySmtpAddress}` }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: _jsx(Copyable, { value: mailbox.displayName, label: `Copy the display name of ${mailbox.primarySmtpAddress}` }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.ownerUserUid ? (_jsx(Copyable, { value: mailbox.ownerUserUid, label: `Copy the owner of ${mailbox.primarySmtpAddress}` })) : (_jsx("span", { className: "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Shared" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: _jsx(Copyable, { value: `${formatBytes(mailbox.usedBytes)} / ${formatBytes(mailbox.quotaBytes)}`, label: `Copy the quota used of ${mailbox.primarySmtpAddress}` }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle text-right", children: _jsx("a", { href: `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`, className: "text-primary-dark font-medium hover:underline", children: "View" }) })] }, mailbox.uid))) })] }) }));
16
21
  }
@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
7
7
  import { getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
8
8
  import Alert from "../ActionAlert.js";
9
9
  import Button from "../../../../../lib/components/buttons/Button.js";
10
- import CopyButton from "../../../../../lib/components/buttons/CopyButton.js";
10
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
11
11
  import { actionErrorMessage } from "../elevation.js";
12
12
  const RECORD_TYPE_LABELS = {
13
13
  ownership: "Ownership (TXT)",
@@ -44,9 +44,9 @@ function splitSrvValue(value) {
44
44
  const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/.exec(value.trim());
45
45
  return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
46
46
  }
47
- /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
47
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
48
48
  function CopyableValue({ value, copyLabel }) {
49
- return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyButton, { value: value, label: copyLabel })] }));
49
+ return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyIconButton, { value: value, label: copyLabel, className: "shrink-0" })] }));
50
50
  }
51
51
  /**
52
52
  * A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
@@ -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
+ }
@@ -15,6 +15,7 @@ import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
15
15
  import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
16
16
  import Alert from "../../../../lib/components/feedback/Alert.js";
17
17
  import Button from "../../../../lib/components/buttons/Button.js";
18
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
18
19
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
19
20
  import ParticipantLink from "../contacts/ParticipantLink.js";
20
21
  import EventDescriptionView from "./EventDescriptionView.js";
@@ -125,7 +126,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
125
126
  { response: "tentative", label: "Tentative" },
126
127
  { response: "declined", label: "Decline" },
127
128
  ];
128
- return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: _jsx(ParticipantLink, { participant: occurrence.organizer, children: organizerName || occurrence.organizer.address }) }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx(ParticipantLink, { participant: guest, children: guest.displayName || guest.address }), ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
129
+ return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsxs(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: [_jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }), joinUrl && (_jsxs("div", { className: "mt-1.5 flex items-center gap-1 min-w-0 text-xs text-text-muted", children: [_jsx("a", { href: joinUrl, target: "_blank", rel: "noopener noreferrer", className: "break-all underline hover:text-text", children: joinUrl }), _jsx(CopyIconButton, { value: joinUrl, label: "Copy the video call link", className: "shrink-0" })] }))] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: _jsx(ParticipantLink, { participant: occurrence.organizer, children: organizerName || occurrence.organizer.address }) }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx(ParticipantLink, { participant: guest, children: guest.displayName || guest.address }), ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
129
130
  uid: occurrence.uid,
130
131
  title: occurrence.title,
131
132
  location: occurrence.location,
@@ -6,6 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useRef, useState } from "react";
7
7
  import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineMapPin, HiOutlineUserGroup, HiOutlineVideoCamera, HiOutlineXMark } from "react-icons/hi2";
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";
@@ -32,7 +33,7 @@ export function LocationRow({ c }) {
32
33
  /** "Add video conferencing" and, once the event has a meeting, the button that joins it. */
33
34
  export function VideoConferencingRow({ c }) {
34
35
  const { values } = c;
35
- return (_jsxs(IconRow, { icon: _jsx(HiOutlineVideoCamera, { size: 20 }), children: [_jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: values.videoEnabled, onChange: (e) => c.update({ videoEnabled: e.target.checked }) }), "Add video conferencing"] }), values.videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), c.videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mb-1", children: c.videoError })), c.videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-1", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !c.joinUrl, onClick: () => window.open(c.joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !c.joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }))] }));
36
+ return (_jsxs(IconRow, { icon: _jsx(HiOutlineVideoCamera, { size: 20 }), children: [_jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: values.videoEnabled, onChange: (e) => c.update({ videoEnabled: e.target.checked }) }), "Add video conferencing"] }), values.videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), c.videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mb-1", children: c.videoError })), c.videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-1", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !c.joinUrl, onClick: () => window.open(c.joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !c.joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] })), c.videoMeetingUid && c.joinUrl && (_jsxs("div", { className: "mb-1 flex items-center gap-1 min-w-0 text-xs text-text-muted", children: [_jsx("a", { href: c.joinUrl, target: "_blank", rel: "noopener noreferrer", className: "break-all underline hover:text-text", children: c.joinUrl }), _jsx(CopyIconButton, { value: c.joinUrl, label: "Copy the video call link", className: "shrink-0" })] }))] }));
36
37
  }
37
38
  /**
38
39
  * The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
@@ -14,6 +14,6 @@ export interface MonthViewProps {
14
14
  * popover to open beside. */
15
15
  onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
16
16
  }
17
- /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
17
+ /** A real 6-week month grid (Sunday-start), matching Outlook/Gmail's month view. Multi-day and all-day
18
18
  * events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
19
19
  export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: MonthViewProps): React.JSX.Element;
@@ -6,13 +6,13 @@ import { occursOnDay, startsOnDay } from "./allDay.js";
6
6
  import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
7
7
  import { anchorOf } from "./EventShell.js";
8
8
  const MAX_CHIPS_PER_DAY = 3;
9
- /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
9
+ /** A real 6-week month grid (Sunday-start), matching Outlook/Gmail's month view. Multi-day and all-day
10
10
  * events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
11
11
  export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
12
- const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
13
- const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
12
+ const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
13
+ const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
14
14
  const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
15
- return (_jsx("div", { className: "flex-1 grid grid-cols-7 grid-rows-6 min-h-0", role: "grid", "aria-label": "Month", children: days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString()))) }));
15
+ return (_jsxs("div", { className: "flex-1 grid grid-cols-7 grid-rows-[auto_repeat(6,minmax(0,1fr))] min-h-0", role: "grid", "aria-label": "Month", children: [days.slice(0, 7).map((day) => (_jsxs("div", { role: "columnheader", className: "px-2 py-1 text-xs font-semibold text-text-muted border-b border-border", children: [_jsx("span", { className: "sm:hidden", children: format(day, "EEEEE") }), _jsx("span", { className: "hidden sm:inline", children: format(day, "EEEE") })] }, day.getDay()))), days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] }));
16
16
  }
17
17
  function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
18
18
  const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
@@ -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 { contactScope, createContactFor, findContact, loadFolders, loadParticipantCard, loadRecentMessages, loadUpcomingEvents, scopeMailboxes, } from "./contactCardData.js";
@@ -147,8 +148,10 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
147
148
  }
148
149
  }
149
150
  function handleEmail() {
151
+ // 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.
152
+ closeAllOverlays();
150
153
  openCompose({ to: formatMailAddress(participant) });
151
154
  onClose();
152
155
  }
153
- return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [stored && canEditPhoto ? (_jsx(ContactPhotoEditor, { contact: stored, displayName: name, size: 72, email: participant.address, onChanged: setChanged, onBusyChange: setPhotoBusy })) : (_jsx(ContactPhotoAvatar, { displayName: name, size: 72, contact: stored, email: participant.address })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, onClick: handleEmail, children: [_jsx(HiOutlineEnvelope, { "aria-hidden": "true", className: "size-4" }), "Email"] }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: [_jsx(HiOutlineUserPlus, { "aria-hidden": "true", className: "size-4" }), "Add to contacts"] }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsxs(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: [email.address, _jsx(CopyIconButton, { value: email.address, label: "Copy email address", className: "ml-1" })] }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
156
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [stored && canEditPhoto ? (_jsx(ContactPhotoEditor, { contact: stored, displayName: name, size: 72, email: participant.address, onChanged: setChanged, onBusyChange: setPhotoBusy })) : (_jsx(ContactPhotoAvatar, { displayName: name, size: 72, contact: stored, email: participant.address })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", "aria-label": "Email", title: "Email", className: `${ACTION_CLASS} inline-flex items-center`, onClick: handleEmail, children: _jsx(HiOutlineEnvelope, { "aria-hidden": "true", className: "size-4" }) }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: [_jsx(HiOutlineUserPlus, { "aria-hidden": "true", className: "size-4" }), "Add to contacts"] }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsxs(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: [email.address, _jsx(CopyIconButton, { value: email.address, label: "Copy email address", className: "ml-1" })] }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
154
157
  }
@@ -4,6 +4,8 @@ export type ContactsView = {
4
4
  type: "all";
5
5
  } | {
6
6
  type: "favorites";
7
+ } | {
8
+ type: "suggested";
7
9
  } | {
8
10
  type: "list";
9
11
  uid: string;
@@ -22,6 +24,8 @@ export interface ContactsSidebarProps {
22
24
  contacts: Contact[];
23
25
  active: ContactsView;
24
26
  onSelect: (view: ContactsView) => void;
27
+ /** 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. */
28
+ suggestedCount?: number;
25
29
  /** Whether to offer the "Deleted" view - restapi only honors `deleted=true` for a caller with delete and
26
30
  * update rights on the mailbox (anyone else silently gets the live contacts), so the parent hides it
27
31
  * otherwise. Defaults to `true`. */
@@ -31,10 +35,10 @@ export interface ContactsSidebarProps {
31
35
  refreshToken?: number;
32
36
  }
33
37
  /**
34
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
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
35
39
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
36
40
  * distinct `categories` values across the loaded contacts — no separate category registry exists
37
41
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
38
42
  * `ContactsShellContext` itself minimal (mailbox/folder resolution only).
39
43
  */
40
- export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted }: ContactsSidebarProps): React.JSX.Element;
44
+ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted, suggestedCount }: ContactsSidebarProps): React.JSX.Element;
@@ -39,13 +39,13 @@ 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", "Your contact
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
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
46
46
  * `ContactsShellContext` itself minimal (mailbox/folder resolution only).
47
47
  */
48
- export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true }) {
48
+ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true, suggestedCount }) {
49
49
  const client = useApiClient();
50
50
  const [lists, setLists] = useState([]);
51
51
  const [listsError, setListsError] = useState(null);
@@ -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" }) }), 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: [_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
  }