@rapidmx/web-client 0.32.0 → 0.33.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 (47) hide show
  1. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
  2. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +24 -4
  3. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +3 -3
  4. package/apps/shared/components/calendar/EventDetails.tsx +10 -0
  5. package/apps/shared/components/calendar/EventFormParts.tsx +10 -0
  6. package/apps/shared/components/calendar/MonthView.tsx +10 -4
  7. package/apps/shared/components/contacts/ContactCard.tsx +4 -2
  8. package/apps/shared/components/contacts/ContactsSidebar.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +6 -0
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +34 -2
  11. package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
  12. package/apps/shared/components/mail/compose/draftResume.ts +54 -0
  13. package/apps/www/calendar/index.tsx +4 -4
  14. package/apps/www/contacts/index.tsx +75 -3
  15. package/apps/www/tasks/index.tsx +1 -1
  16. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +3 -2
  17. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
  18. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
  19. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  20. package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
  21. package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
  22. package/dist/apps/shared/components/calendar/MonthView.js +4 -4
  23. package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
  24. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
  25. package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
  26. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  27. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
  28. package/dist/apps/shared/components/mail/ConversationThreadPane.js +35 -1
  29. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
  30. package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
  31. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
  32. package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
  33. package/dist/apps/www/calendar/index.js +4 -4
  34. package/dist/apps/www/contacts/index.js +63 -5
  35. package/dist/apps/www/tasks/index.js +1 -1
  36. package/dist/lib/calendar/recurrence.js +1 -1
  37. package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
  38. package/dist/lib/components/overlays/overlayStack.js +10 -0
  39. package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
  40. package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
  41. package/dist/lib/contacts/contactsApi.d.ts +12 -0
  42. package/dist/lib/contacts/contactsApi.js +8 -0
  43. package/lib/calendar/recurrence.ts +1 -1
  44. package/lib/components/overlays/overlayStack.ts +11 -0
  45. package/lib/components/pickers/MiniDatePicker.tsx +4 -4
  46. package/lib/contacts/contactsApi.ts +16 -0
  47. package/package.json +1 -1
@@ -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
  }
@@ -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
  ];
@@ -15,7 +15,8 @@ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from
15
15
  import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
16
16
  import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
17
17
  import PendingMessageCard from "./reading/PendingMessageCard.js";
18
- import { InlineComposeSlot, useInlineCompose } from "./compose/ComposeContext.js";
18
+ import { InlineComposeSlot, useCompose, useInlineCompose } from "./compose/ComposeContext.js";
19
+ import { resumeFromDraft } from "./compose/draftResume.js";
19
20
  import { useMailShell } from "./layout/MailShell.js";
20
21
  import { setReadState } from "../../mail/messageReadState.js";
21
22
  import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
@@ -206,6 +207,11 @@ export default function ConversationThreadPane({
206
207
  // The Reply / Reply all / Forward compose windows of this thread's messages, drawn as cards at the top of the list; the thread's messages are
207
208
  // what makes the compose provider open a reply here rather than floating at the bottom right.
208
209
  const inlineSessions = useInlineCompose(messages.map((message) => message.uid));
210
+ const { openCompose } = useCompose();
211
+ /** The draft this pane has already opened an editor for, so it is opened once per visit - closing the editor leaves the draft as a message to read. */
212
+ const openedDraftRef = useRef<string | null>(null);
213
+ const selectedUidRef = useRef(selectedUid);
214
+ selectedUidRef.current = selectedUid;
209
215
 
210
216
  // Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
211
217
  // older generation belongs to a superseded conversation and is dropped rather than applied.
@@ -291,6 +297,26 @@ export default function ConversationThreadPane({
291
297
  setPendingFocusUid([...expanded][expanded.size - 1]);
292
298
  }, [conversationId, selectedUid, messages]);
293
299
 
300
+ // A draft the reader opens is edited where it is read: its compose window opens as the card at the top of the thread, with the draft as it was
301
+ // saved. It is opened once per visit to the draft (an encrypted draft, which only the server holds the ciphertext of, stays a message to read).
302
+ useEffect(() => {
303
+ if (openedDraftRef.current !== null && openedDraftRef.current !== selectedUid) {
304
+ openedDraftRef.current = null;
305
+ }
306
+ if (loading || loadedIdRef.current !== conversationId || !selectedUid || openedDraftRef.current === selectedUid) return;
307
+ const draft = messages.find((message) => message.uid === selectedUid);
308
+ if (!draft || folders.find((folder) => folder.uid === draft.folderUid)?.type !== "drafts") return;
309
+ openedDraftRef.current = draft.uid;
310
+ void resumeFromDraft(draft, client)
311
+ .then((resume) => {
312
+ // The reader may have moved on while it loaded.
313
+ if (resume && selectedUidRef.current === draft.uid) {
314
+ openCompose({ mailboxUid: draft.mailboxUid, resume, inlineFor: draft.uid });
315
+ }
316
+ })
317
+ .catch(() => undefined);
318
+ }, [conversationId, selectedUid, messages, loading, folders]);
319
+
294
320
  useLayoutEffect(() => {
295
321
  if (!pendingFocusUid) return;
296
322
  // The row is always rendered by now: this runs after the DOM update that added the message it
@@ -301,7 +327,9 @@ export default function ConversationThreadPane({
301
327
  // took the app header and the folder rail off the screen - so the adjustment goes on whichever
302
328
  // ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
303
329
  // itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
304
- const row = rowRefs.current[pendingFocusUid]!;
330
+ const row = rowRefs.current[pendingFocusUid];
331
+ // None for a draft opened as the editor card (see the effect above): the card takes the view when it is placed.
332
+ if (!row) return;
305
333
  const scroller = scrollingAncestor(row);
306
334
  if (scroller !== document.documentElement) {
307
335
  const rect = row.getBoundingClientRect();
@@ -571,6 +599,10 @@ export default function ConversationThreadPane({
571
599
  ))}
572
600
  {messages.map((message) => {
573
601
  const uid = message.uid;
602
+ // A draft being edited here is the editor card above, not a message to read as well.
603
+ if (inlineSessions.some((session) => session.draftUid === uid)) {
604
+ return null;
605
+ }
574
606
  const expanded = expandedUids.has(uid);
575
607
  const bodyId = `thread-message-${uid}`;
576
608
  const messageUnread = isUnread(message);
@@ -198,6 +198,8 @@ export interface InlineComposeSession {
198
198
  id: string;
199
199
  /** The uid of the message the window answers. */
200
200
  inlineFor: string;
201
+ /** The uid of the draft the window continues, when it was opened to edit one: the draft is then the card, not a message beside it. */
202
+ draftUid?: string;
201
203
  }
202
204
 
203
205
  interface InlineComposeValue {
@@ -258,6 +260,9 @@ export function useCompose(): ComposeContextValue {
258
260
  */
259
261
  export default function ComposeProvider({ children, userUid, trusted }: PropsWithChildren<{ userUid?: string; trusted?: boolean }>) {
260
262
  const [sessions, setSessions] = useState<ComposeSession[]>([]);
263
+ // The sessions as of the last render, for `openCompose()` to read at the click.
264
+ const sessionsRef = useRef(sessions);
265
+ sessionsRef.current = sessions;
261
266
  const client = useApiClient();
262
267
  const isMobile = useIsMobile();
263
268
  const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
@@ -283,7 +288,7 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
283
288
  const isInline = (session: ComposeSession) => !!session.inlineFor && !parked.has(session.id) && hosted.has(session.inlineFor);
284
289
  const inlineKey = sessions.filter(isInline).map((session) => `${session.id}:${session.inlineFor}`).join("|");
285
290
  const inlineSessions = useMemo<InlineComposeSession[]>(
286
- () => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor! })),
291
+ () => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor!, draftUid: session.resume?.draft.uid })),
287
292
  [inlineKey],
288
293
  );
289
294
  const inlineValue = useMemo<InlineComposeValue>(
@@ -354,6 +359,19 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
354
359
  resume,
355
360
  inlineFor,
356
361
  }: OpenComposeInput) {
362
+ // A draft already being edited has its window: it is brought back to the pane that was holding it rather than opened a second time.
363
+ const editing = resume && sessionsRef.current.find((session) => session.resume?.draft.uid === resume.draft.uid);
364
+ if (editing) {
365
+ setParked((prev) => {
366
+ if (!prev.has(editing.id)) {
367
+ return prev;
368
+ }
369
+ const next = new Set(prev);
370
+ next.delete(editing.id);
371
+ return next;
372
+ });
373
+ return;
374
+ }
357
375
  const id = crypto.randomUUID();
358
376
  markComposePhase(id, "click");
359
377
  // The window's own element, attached to the page from the start (it is moved to its slot or the stack as soon as that is drawn).
@@ -0,0 +1,54 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
6
+ import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
7
+ import type { ApiClient } from "../../../../../lib/util/api.js";
8
+ import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
9
+ import { loadOriginalMessage } from "./quotedBody.js";
10
+
11
+ /** The comma-separated address text a recipient field holds, for the recipients of one type. */
12
+ function recipientText(recipients: Recipient[], type: RecipientType): string {
13
+ return recipients
14
+ .filter((recipient) => recipient.type === type)
15
+ .map((recipient) => formatMailAddress(recipient))
16
+ .join(", ");
17
+ }
18
+
19
+ /** Plain text as the HTML paragraphs the editor holds. */
20
+ function textToHtml(text: string): string {
21
+ const escape = (value: string): string => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
22
+ return text
23
+ .split(/\n{2,}/)
24
+ .map((paragraph) => `<p>${escape(paragraph).replace(/\n/g, "<br>")}</p>`)
25
+ .join("");
26
+ }
27
+
28
+ /**
29
+ * What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
30
+ * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
31
+ * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
32
+ * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
33
+ */
34
+ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined> {
35
+ if (draft.encrypted) {
36
+ return undefined;
37
+ }
38
+ const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
39
+ const html =
40
+ original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
41
+ return {
42
+ draft,
43
+ mailboxUid: draft.mailboxUid,
44
+ to: recipientText(draft.recipients, "to"),
45
+ cc: recipientText(draft.recipients, "cc"),
46
+ bcc: recipientText(draft.recipients, "bcc"),
47
+ subject: draft.subject,
48
+ html,
49
+ attachments,
50
+ requestReceipt: !!draft.requestReceipt,
51
+ signEnabled: false,
52
+ encryptRequested: false,
53
+ };
54
+ }
@@ -217,13 +217,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
217
217
  return { rangeStart: new Date(-MAX_DATE_MS), rangeEnd: new Date(MAX_DATE_MS), days: [] as Date[] };
218
218
  }
219
219
  if (view === "month") {
220
- const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
221
- const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
220
+ const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
221
+ const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
222
222
  return { rangeStart: start, rangeEnd: end, days: [] as Date[] };
223
223
  }
224
224
  if (view === "week") {
225
- const start = startOfWeek(viewDate, { weekStartsOn: 1 });
226
- const end = endOfWeek(viewDate, { weekStartsOn: 1 });
225
+ const start = startOfWeek(viewDate, { weekStartsOn: 0 });
226
+ const end = endOfWeek(viewDate, { weekStartsOn: 0 });
227
227
  return { rangeStart: start, rangeEnd: end, days: eachDayOfInterval({ start, end }) };
228
228
  }
229
229
  if (view === "workWeek") {
@@ -11,6 +11,7 @@ import {
11
11
  createContact,
12
12
  deleteContact,
13
13
  listContacts,
14
+ ensureSuggestedContacts,
14
15
  listContactsInFolders,
15
16
  listDeletedContacts,
16
17
  setContactFavorite,
@@ -91,6 +92,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
91
92
  const client = useApiClient();
92
93
  const [contacts, setContacts] = useState<Contact[]>([]);
93
94
  const [deletedContacts, setDeletedContacts] = useState<Contact[]>([]);
95
+ // The mailbox's Suggested Contacts folder - the people it has corresponded with - once the server has made it; `null` while unknown (an older server has none).
96
+ const [suggestedFolderUid, setSuggestedFolderUid] = useState<string | null>(null);
97
+ const [suggestedContacts, setSuggestedContacts] = useState<Contact[]>([]);
94
98
  const [deletedLoading, setDeletedLoading] = useState(false);
95
99
  const [deletedError, setDeletedError] = useState<string | null>(null);
96
100
  const [loading, setLoading] = useState(true);
@@ -121,6 +125,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
121
125
  }
122
126
  setLoading(true);
123
127
  setError(null);
128
+ if (suggestedFolderUid) {
129
+ void loadSuggested(suggestedFolderUid);
130
+ }
124
131
  return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
125
132
  .then((result) => {
126
133
  setContacts(result.items);
@@ -134,6 +141,31 @@ function ContactsContent({ userUid }: { userUid?: string }) {
134
141
  void reload();
135
142
  }, [folderUid]);
136
143
 
144
+ /** Reads the Suggested Contacts folder's contacts. A failure leaves the list as it was. */
145
+ function loadSuggested(suggestedUid: string): Promise<void> {
146
+ return listAllPages((page) => listContacts(suggestedUid, { limit: LIST_PAGE_SIZE, page }, client))
147
+ .then((result) => setSuggestedContacts(result.items))
148
+ .catch(() => undefined);
149
+ }
150
+
151
+ // Asks the server to turn the people this mailbox has corresponded with into suggested contacts (it does so once for each person, so this is cheap when
152
+ // nothing is new), then lists them. An older server, or one that refuses, simply has no such view.
153
+ useEffect(() => {
154
+ let cancelled = false;
155
+ // `ContactsShell` only renders this with a resolved mailbox (see the Deleted view's comment above).
156
+ void ensureSuggestedContacts(mailboxUid!, client)
157
+ .then(async (result) => {
158
+ if (!cancelled) {
159
+ setSuggestedFolderUid(result.folderUid);
160
+ await loadSuggested(result.folderUid);
161
+ }
162
+ })
163
+ .catch(() => undefined);
164
+ return () => {
165
+ cancelled = true;
166
+ };
167
+ }, [mailboxUid]);
168
+
137
169
  // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
138
170
  // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
139
171
  // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
@@ -196,6 +228,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
196
228
  return contacts;
197
229
  case "favorites":
198
230
  return contacts.filter((c) => c.favorite);
231
+ case "suggested":
232
+ return suggestedContacts;
199
233
  case "list":
200
234
  return contacts.filter((c) => c.contactListUid === view.uid);
201
235
  case "category":
@@ -203,7 +237,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
203
237
  case "deleted":
204
238
  return deletedContacts;
205
239
  }
206
- }, [contacts, deletedContacts, view]);
240
+ }, [contacts, deletedContacts, suggestedContacts, view]);
207
241
 
208
242
  const filtered = useMemo(() => {
209
243
  const q = query.trim().toLowerCase();
@@ -226,7 +260,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
226
260
  return arr;
227
261
  }, [filtered, sortColumn, sortDesc]);
228
262
 
229
- const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
263
+ const selected = contacts.find((c) => c.uid === selectedUid) ?? suggestedContacts.find((c) => c.uid === selectedUid) ?? null;
230
264
  const isDeletedView = view.type === "deleted";
231
265
  const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
232
266
  const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
@@ -392,6 +426,36 @@ function ContactsContent({ userUid }: { userUid?: string }) {
392
426
  await reload();
393
427
  });
394
428
 
429
+ // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
430
+ const handleAddToContacts = singleFlight(async () => {
431
+ for (const contact of checkedContacts) {
432
+ try {
433
+ await createContact(
434
+ {
435
+ mailboxUid: contact.mailboxUid,
436
+ folderUid: folderUid!,
437
+ displayName: contact.displayName,
438
+ givenName: contact.givenName,
439
+ surname: contact.surname,
440
+ emails: contact.emails,
441
+ phones: contact.phones,
442
+ addresses: contact.addresses,
443
+ company: contact.company,
444
+ jobTitle: contact.jobTitle,
445
+ notes: contact.notes,
446
+ categories: contact.categories,
447
+ },
448
+ client,
449
+ );
450
+ await deleteContact(contact.uid, contact.version, client);
451
+ } catch (err) {
452
+ notifyApiError(err, "Couldn't add some of the contacts");
453
+ }
454
+ }
455
+ setCheckedUids(new Set());
456
+ await reload();
457
+ });
458
+
395
459
  function handleExportVCard() {
396
460
  downloadTextFile(
397
461
  checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
@@ -447,7 +511,14 @@ function ContactsContent({ userUid }: { userUid?: string }) {
447
511
  // open-ended, so a column's `overflow` would otherwise never apply); on a phone the whole page scrolls, as it always did. Below `lg` the
448
512
  // contacts menu is a drawer (a button above the columns), so the list and the detail pane keep a usable width.
449
513
  <div className="flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden">
450
- <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} showDeleted={canViewDeleted} />
514
+ <ContactsSidebar
515
+ mailboxUid={mailboxUid}
516
+ contacts={contacts}
517
+ active={view}
518
+ onSelect={handleSelectView}
519
+ showDeleted={canViewDeleted}
520
+ suggestedCount={suggestedFolderUid ? suggestedContacts.length : undefined}
521
+ />
451
522
  <div className="flex-1 min-w-0 min-h-0 flex">
452
523
  {/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
453
524
  in-place — see the pane's own comment below) is showing, so the two never compete for the
@@ -469,6 +540,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
469
540
  onToggleFavorite={handleToggleFavorite}
470
541
  onAddCategory={handleAddCategory}
471
542
  onExportVCard={handleExportVCard}
543
+ onAddToContacts={view.type === "suggested" ? handleAddToContacts : undefined}
472
544
  onImportFile={handleImportFile}
473
545
  shortcuts
474
546
  hideNew={isMobile}
@@ -61,7 +61,7 @@ function bucketFor(task: Task, now: Date): Bucket {
61
61
  if (isToday(due)) {
62
62
  return "Today";
63
63
  }
64
- if (!isAfter(due, endOfWeek(now, { weekStartsOn: 1 }))) {
64
+ if (!isAfter(due, endOfWeek(now, { weekStartsOn: 0 }))) {
65
65
  return "This Week";
66
66
  }
67
67
  return "Later";