@rapidmx/web-client 0.31.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 (56) hide show
  1. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +2 -2
  2. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
  3. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +2 -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/admin/settings/PluginsManager.tsx +156 -29
  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/mail/ConversationThreadPane.tsx +34 -2
  14. package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
  15. package/apps/shared/components/mail/compose/draftResume.ts +54 -0
  16. package/apps/www/calendar/index.tsx +4 -4
  17. package/apps/www/contacts/index.tsx +75 -3
  18. package/apps/www/tasks/index.tsx +1 -1
  19. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +1 -1
  20. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +3 -2
  21. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +2 -2
  22. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
  23. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
  24. package/dist/apps/shared/components/admin/settings/PluginsManager.js +79 -8
  25. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  26. package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
  27. package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
  28. package/dist/apps/shared/components/calendar/MonthView.js +4 -4
  29. package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
  30. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
  31. package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
  32. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  33. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
  34. package/dist/apps/shared/components/mail/ConversationThreadPane.js +35 -1
  35. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
  36. package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
  37. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
  38. package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
  39. package/dist/apps/www/calendar/index.js +4 -4
  40. package/dist/apps/www/contacts/index.js +63 -5
  41. package/dist/apps/www/tasks/index.js +1 -1
  42. package/dist/lib/admin/pluginsApi.d.ts +18 -0
  43. package/dist/lib/admin/pluginsApi.js +15 -1
  44. package/dist/lib/calendar/recurrence.js +1 -1
  45. package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
  46. package/dist/lib/components/overlays/overlayStack.js +10 -0
  47. package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
  48. package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
  49. package/dist/lib/contacts/contactsApi.d.ts +12 -0
  50. package/dist/lib/contacts/contactsApi.js +8 -0
  51. package/lib/admin/pluginsApi.ts +24 -1
  52. package/lib/calendar/recurrence.ts +1 -1
  53. package/lib/components/overlays/overlayStack.ts +11 -0
  54. package/lib/components/pickers/MiniDatePicker.tsx +4 -4
  55. package/lib/contacts/contactsApi.ts +16 -0
  56. package/package.json +1 -1
@@ -4,7 +4,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useCallback, useEffect, useId, useRef, useState } from "react";
7
- import { addPlugin, expectedPlanOf, getPluginStatus, getPluginUpdates, isSecretSetting, isSecretSettingKey, listPluginNamespaces, listPlugins, lookupPluginPackage, planPluginChange, removePlugin, retryPluginPurge, searchPlugins, updatePlugin, } from "../../../../../lib/admin/pluginsApi.js";
7
+ import { addPlugin, expectedPlanOf, getPluginStatus, getPluginUpdates, isSecretSetting, isSecretSettingKey, listPluginNamespaces, listPlugins, lookupPluginPackage, planPluginChange, removePlugin, retryPluginPurge, searchPlugins, updatePlugin, uploadPlugin, } from "../../../../../lib/admin/pluginsApi.js";
8
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
8
9
  import Alert from "../ActionAlert.js";
9
10
  import Button from "../../../../../lib/components/buttons/Button.js";
10
11
  import Modal from "../../../../../lib/components/overlays/Modal.js";
@@ -43,6 +44,10 @@ function purgeFailure(purge) {
43
44
  }
44
45
  /** A day, in the administrator's own format. */
45
46
  const formatDate = (iso) => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
47
+ /** The file names an uploaded plugin may have: what `npm pack` produces (`<name>-<version>.tgz`), or the same gzipped. */
48
+ const PLUGIN_PACK_NAME = /\.(tgz|tar\.gz|gz)$/i;
49
+ /** What the server answers (409) an upload of a plugin that is already installed, which the administrator may replace. */
50
+ const ALREADY_INSTALLED = /already installed/i;
46
51
  const BULK_WORDS = {
47
52
  upgrade: { doing: "Upgrading", done: "upgraded" },
48
53
  disable: { doing: "Disabling", done: "disabled" },
@@ -136,6 +141,11 @@ export default function PluginsManager({ embedded = false } = {}) {
136
141
  const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState(null);
137
142
  /** How many changes (or the lookups that come before them) are waiting on the server. */
138
143
  const [working, setWorking] = useState(0);
144
+ /** Set while an uploaded plugin is being sent to the server. */
145
+ const [uploading, setUploading] = useState(false);
146
+ /** An upload that would replace an installed plugin, waiting for the administrator to confirm it. */
147
+ const [replacing, setReplacing] = useState(null);
148
+ const fileInput = useRef(null);
139
149
  const statusRequest = useRef(null);
140
150
  /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
141
151
  const seenPurges = useRef(new Map());
@@ -373,6 +383,55 @@ export default function PluginsManager({ embedded = false } = {}) {
373
383
  function upgrade(plugin, packageVersion) {
374
384
  return onRow(plugin, () => changeVersion(plugin, packageVersion));
375
385
  }
386
+ /** Sends `file` to the server as a plugin to install - replacing the installed plugin of that name only when `replace` is set. A plugin that is
387
+ * already installed isn't an error but a question, asked of the administrator before it is replaced. */
388
+ async function runUpload(file, replace) {
389
+ setUploading(true);
390
+ setError(null);
391
+ setRetryEnableUid(null);
392
+ try {
393
+ const uploaded = await track(uploadPlugin(file, { replace }));
394
+ applied(uploaded);
395
+ notify({
396
+ kind: "success",
397
+ title: `${uploaded.name}@${uploaded.packageVersion} uploaded`,
398
+ message: "The servers restart one at a time to load it.",
399
+ });
400
+ }
401
+ catch (err) {
402
+ if (!replace && err instanceof ApiRequestError && err.status === 409 && ALREADY_INSTALLED.test(err.message)) {
403
+ const installed = plugins.find((plugin) => err.message.includes(plugin.name));
404
+ setReplacing({
405
+ file,
406
+ question: installed
407
+ ? `Replace ${installed.name} ${installed.packageVersion} with the uploaded pack?`
408
+ : "Replace the installed plugin with the uploaded pack?",
409
+ reason: err.message,
410
+ });
411
+ }
412
+ else if (err instanceof ApiRequestError && err.status === 413) {
413
+ setError("That file is too large for the server to accept.");
414
+ }
415
+ else {
416
+ setError(errorMessage(err, "Could not upload the plugin."));
417
+ }
418
+ }
419
+ setUploading(false);
420
+ }
421
+ /** Takes the file the administrator chose, unless it can't be what `npm pack` produces. */
422
+ function onFileChosen(e) {
423
+ const file = e.target.files?.[0];
424
+ // So choosing the same file again (after fixing what was wrong) still counts as a change.
425
+ e.target.value = "";
426
+ if (!file) {
427
+ return;
428
+ }
429
+ if (!PLUGIN_PACK_NAME.test(file.name)) {
430
+ setError(`${file.name} isn't a plugin pack. Choose the .tgz file that npm pack produces.`);
431
+ return;
432
+ }
433
+ void runUpload(file, false);
434
+ }
376
435
  /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
377
436
  function reinstall(purge) {
378
437
  const displayName = purgeName(purge);
@@ -523,9 +582,10 @@ export default function PluginsManager({ embedded = false } = {}) {
523
582
  const retryEnable = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
524
583
  const displayNameOf = (name) => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
525
584
  const SectionHeading = embedded ? "h3" : "h2";
526
- const addButton = (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setAdding(true), children: "Add by name" }));
527
585
  const bulkBusy = bulk !== null;
528
- return (_jsxs(_Fragment, { children: [(working > 0 || pending) && _jsx(PluginProgressBar, { label: working > 0 ? "Working on plugins" : "Applying plugin changes" }), embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0", checked: allowPrerelease, onChange: (e) => changePrerelease(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: "Allow pre-release versions" }), _jsx("span", { className: "text-xs text-text-muted", children: "Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to install. They can be unstable. This is remembered in this browser." })] })] }), bulkFailure && (_jsx(Alert, { children: _jsxs("div", { children: [bulkFailure.title, _jsx("ul", { className: "list-disc pl-5 mt-1", children: bulkFailure.reasons.map((reason) => (_jsx("li", { className: "break-words", children: reason }, reason))) })] }) })), error && (_jsxs(Alert, { children: [error, retryEnable && (_jsxs(_Fragment, { children: [" ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] }))] })), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsxs("div", { className: "overflow-x-auto", children: [plugins.length > 0 && (_jsxs("div", { role: "toolbar", "aria-label": "Actions for several plugins", className: "flex flex-wrap items-center gap-2 py-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm mr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "h-4 w-4 shrink-0", "aria-label": "Select all plugins", checked: allSelected, disabled: bulkBusy, ref: (input) => {
586
+ const addButton = (_jsxs("div", { className: "flex flex-wrap gap-2 shrink-0", children: [_jsx("input", { ref: fileInput, type: "file", className: "hidden", accept: ".tgz,.tar.gz,application/gzip,application/x-gzip", "aria-label": "Plugin pack file", tabIndex: -1, onChange: onFileChosen }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", title: "Install a plugin from the .tgz file that npm pack produces", loading: uploading, disabled: uploading || bulkBusy, onClick: () => fileInput.current?.click(), children: uploading ? "Uploading\u2026" : "Upload" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setAdding(true), children: "Add by name" })] }));
587
+ return (_jsxs(_Fragment, { children: [(working > 0 || pending) && _jsx(PluginProgressBar, { label: working > 0 ? "Working on plugins" : "Applying plugin changes" }), embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0", checked: allowPrerelease, onChange: (e) => changePrerelease(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: "Allow pre-release versions" }), _jsx("span", { className: "text-xs text-text-muted", children: "Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to install. They can be unstable. This is remembered in this browser." })] })] }), bulkFailure && (_jsx(Alert, { children: _jsxs("div", { children: [bulkFailure.title, _jsx("ul", { className: "list-disc pl-5 mt-1", children: bulkFailure.reasons.map((reason) => (_jsx("li", { className: "break-words", children: reason }, reason))) })] }) })), error &&
588
+ (retryEnable ? (_jsxs(Alert, { children: [error, " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] })) : (_jsx(Alert, { children: error }))), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsxs("div", { className: "overflow-x-auto", children: [plugins.length > 0 && (_jsxs("div", { role: "toolbar", "aria-label": "Actions for several plugins", className: "flex flex-wrap items-center gap-2 py-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm mr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "h-4 w-4 shrink-0", "aria-label": "Select all plugins", checked: allSelected, disabled: bulkBusy, ref: (input) => {
529
589
  if (input) {
530
590
  input.indeterminate = selectedPlugins.length > 0 && !allSelected;
531
591
  }
@@ -533,13 +593,15 @@ export default function PluginsManager({ embedded = false } = {}) {
533
593
  versions: new Map(selectedUpgrades.map((plugin) => [plugin.uid, updates.get(plugin.uid).latestVersion])),
534
594
  }), children: ["Upgrade", selectedUpgrades.length > 0 ? ` (${selectedUpgrades.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Disable selected plugins", disabled: bulkBusy || selectedEnabled.length === 0, onClick: () => void runBulk("disable", selectedEnabled), children: ["Disable", selectedEnabled.length > 0 ? ` (${selectedEnabled.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Uninstall selected plugins", disabled: bulkBusy || selectedPlugins.length === 0, onClick: () => setConfirmingBulkUninstall(selectedPlugins), children: ["Uninstall", selectedPlugins.length > 0 ? ` (${selectedPlugins.length})` : ""] }), _jsx(Button, { type: "button", className: "!w-auto ml-auto", "aria-label": "Upgrade all plugins", loading: bulk?.kind === "upgrade", disabled: bulkBusy, onClick: () => void upgradeAll(), children: "Upgrade all" }), bulk && (_jsxs("span", { "aria-live": "polite", className: "basis-full text-xs text-text-muted", children: [BULK_WORDS[bulk.kind].doing, " ", bulk.current, " (", bulk.done + 1, " of ", bulk.total, ")\u2026"] }))] })), _jsxs("table", { role: "table", className: "w-full text-sm border-collapse block lg:table", children: [_jsx("thead", { role: "rowgroup", className: "hidden lg:table-header-group", children: _jsxs("tr", { role: "row", children: [_jsx("th", { role: "columnheader", className: TH_CLASS, children: "Plugin" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Version" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Status" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsxs("tbody", { role: "rowgroup", className: "block lg:table-row-group", children: [plugins.map((plugin) => {
535
595
  const update = updates.get(plugin.uid);
536
- const latest = update?.updateAvailable ? update.latestVersion : undefined;
596
+ const uploaded = plugin.source === "upload";
597
+ // An uploaded pack has no registry version to be newer than.
598
+ const latest = !uploaded && update?.updateAvailable ? update.latestVersion : undefined;
537
599
  const busy = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
538
600
  const requires = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
539
601
  const requiredBy = plugins
540
602
  .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
541
603
  .map((other) => other.manifest.displayName);
542
- return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
604
+ return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), uploaded && (_jsxs("div", { className: "text-xs text-text-muted mt-1 break-words", children: [_jsx("span", { className: `${BADGE_CLASS} bg-surface-alt text-text-muted`, children: "Uploaded" }), " ", plugin.uploadFilename, plugin.uploadedAt ? `, ${formatDate(plugin.uploadedAt)}` : ""] })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: uploaded ? "Install the registry version" : "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
543
605
  }), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge), onInstall: () => void reinstall(purge) }, purge.uid)))] })] })] })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, prerelease: allowPrerelease, onInstall: (result) => install(result.name, result.version, result.name),
544
606
  // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
545
607
  onUpgrade: (uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid), packageVersion) }), _jsx(AddPluginModal, { open: adding, prerelease: allowPrerelease, onClose: () => setAdding(false), onAdd: async (name, packageVersion, displayName) => {
@@ -554,7 +616,11 @@ export default function PluginsManager({ embedded = false } = {}) {
554
616
  setUpgrading(null);
555
617
  }
556
618
  return problem;
557
- } })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), confirmingBulkUninstall && (_jsx(BulkUninstallModal, { plugins: confirmingBulkUninstall, onClose: () => setConfirmingBulkUninstall(null), onConfirm: (purgeData) => {
619
+ } })), replacing && (_jsxs(Modal, { open: true, onClose: () => setReplacing(null), title: "Replace plugin?", children: [_jsx("p", { className: "text-sm mb-2", children: replacing.question }), _jsx("p", { className: "text-sm text-text-muted mb-4 break-words", children: replacing.reason }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setReplacing(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => {
620
+ const { file } = replacing;
621
+ setReplacing(null);
622
+ void runUpload(file, true);
623
+ }, children: "Replace" })] })] })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), confirmingBulkUninstall && (_jsx(BulkUninstallModal, { plugins: confirmingBulkUninstall, onClose: () => setConfirmingBulkUninstall(null), onConfirm: (purgeData) => {
558
624
  const targets = confirmingBulkUninstall;
559
625
  setConfirmingBulkUninstall(null);
560
626
  void runBulk("uninstall", targets, { purgeData });
@@ -777,14 +843,19 @@ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
777
843
  const [selected, setSelected] = useState(plugin.packageVersion);
778
844
  const [error, setError] = useState(null);
779
845
  const [busy, setBusy] = useState(false);
846
+ // An uploaded pack isn't a registry version, so any registry version is a change from it - the newest is offered first.
847
+ const uploaded = plugin.source === "upload";
780
848
  useEffect(() => {
781
849
  lookupPluginPackage(plugin.name, undefined, { prerelease })
782
850
  .then((found) => {
783
851
  setVersions(found.package.versions);
784
852
  setLatest(found.package.latest);
853
+ if (uploaded && found.package.latest) {
854
+ setSelected(found.package.latest);
855
+ }
785
856
  })
786
857
  .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
787
- }, [plugin.name, prerelease]);
858
+ }, [plugin.name, prerelease, uploaded]);
788
859
  async function save() {
789
860
  setBusy(true);
790
861
  setError(null);
@@ -794,7 +865,7 @@ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
794
865
  setBusy(false);
795
866
  }
796
867
  }
797
- return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || selected === plugin.packageVersion, onClick: () => void save(), children: "Save" })] })] }))] }));
868
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: uploaded ? `Install the registry version of ${plugin.manifest.displayName}` : `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [uploaded && (_jsxs("p", { className: "text-sm text-text-muted", children: [plugin.manifest.displayName, " ", plugin.packageVersion, " was uploaded as ", plugin.uploadFilename, ". This replaces it with the version from the registry."] })), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", !uploaded && version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || (!uploaded && selected === plugin.packageVersion), onClick: () => void save(), children: uploaded ? "Install" : "Save" })] })] }))] }));
798
869
  }
799
870
  /** Lists the plugins a change also installs or enables, and makes the change once confirmed. */
800
871
  function ConfirmDependenciesModal({ change, displayNameOf, onClose, onDone, }) {
@@ -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
  }
@@ -11,6 +11,8 @@ export interface ContactsToolbarProps {
11
11
  onToggleFavorite: () => void;
12
12
  onAddCategory: () => void;
13
13
  onExportVCard: () => void;
14
+ /** 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. */
15
+ onAddToContacts?: () => void;
14
16
  /** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
15
17
  * page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
16
18
  onImportFile: (file: File) => void;
@@ -28,4 +30,4 @@ export interface ContactsToolbarProps {
28
30
  * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
29
31
  * "+ New list" affordance next to the "Your contact lists" heading.
30
32
  */
31
- export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, hideNew, }: ContactsToolbarProps): React.JSX.Element;
33
+ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onAddToContacts, onImportFile, shortcuts, hideNew, }: ContactsToolbarProps): React.JSX.Element;
@@ -17,7 +17,7 @@ import ResponsiveToolbar from "../layout/ResponsiveToolbar.js";
17
17
  * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
18
18
  * "+ New list" affordance next to the "Your contact lists" heading.
19
19
  */
20
- export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, hideNew, }) {
20
+ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onAddToContacts, onImportFile, shortcuts, hideNew, }) {
21
21
  const newContactHint = useShortcutProps("New contact", SHORTCUTS.contacts.create, !!shortcuts);
22
22
  const importInputRef = useRef(null);
23
23
  const hasSelection = selectedCount > 0;
@@ -45,6 +45,9 @@ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, o
45
45
  rank: 3,
46
46
  },
47
47
  { id: "category", label: "Add category", icon: HiOutlineTag, onClick: onAddCategory, disabled: !hasSelection, group: 2, rank: 2 },
48
+ ...(onAddToContacts
49
+ ? [{ id: "addToContacts", label: "Add to contacts", icon: HiOutlineUserPlus, onClick: onAddToContacts, disabled: !hasSelection, group: 1, rank: 7 }]
50
+ : []),
48
51
  { id: "export", label: "Export", icon: HiOutlineArrowUpTray, onClick: onExportVCard, disabled: !hasSelection, group: 3, rank: 1 },
49
52
  { id: "import", label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click(), group: 3, rank: 0 },
50
53
  ];
@@ -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";
@@ -141,6 +142,11 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
141
142
  // 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
142
143
  // what makes the compose provider open a reply here rather than floating at the bottom right.
143
144
  const inlineSessions = useInlineCompose(messages.map((message) => message.uid));
145
+ const { openCompose } = useCompose();
146
+ /** 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. */
147
+ const openedDraftRef = useRef(null);
148
+ const selectedUidRef = useRef(selectedUid);
149
+ selectedUidRef.current = selectedUid;
144
150
  // Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
145
151
  // older generation belongs to a superseded conversation and is dropped rather than applied.
146
152
  const generationRef = useRef(0);
@@ -226,6 +232,27 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
226
232
  // this newest-first order is the *last* of the run, not the first.
227
233
  setPendingFocusUid([...expanded][expanded.size - 1]);
228
234
  }, [conversationId, selectedUid, messages]);
235
+ // 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
236
+ // 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).
237
+ useEffect(() => {
238
+ if (openedDraftRef.current !== null && openedDraftRef.current !== selectedUid) {
239
+ openedDraftRef.current = null;
240
+ }
241
+ if (loading || loadedIdRef.current !== conversationId || !selectedUid || openedDraftRef.current === selectedUid)
242
+ return;
243
+ const draft = messages.find((message) => message.uid === selectedUid);
244
+ if (!draft || folders.find((folder) => folder.uid === draft.folderUid)?.type !== "drafts")
245
+ return;
246
+ openedDraftRef.current = draft.uid;
247
+ void resumeFromDraft(draft, client)
248
+ .then((resume) => {
249
+ // The reader may have moved on while it loaded.
250
+ if (resume && selectedUidRef.current === draft.uid) {
251
+ openCompose({ mailboxUid: draft.mailboxUid, resume, inlineFor: draft.uid });
252
+ }
253
+ })
254
+ .catch(() => undefined);
255
+ }, [conversationId, selectedUid, messages, loading, folders]);
229
256
  useLayoutEffect(() => {
230
257
  if (!pendingFocusUid)
231
258
  return;
@@ -238,6 +265,9 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
238
265
  // ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
239
266
  // itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
240
267
  const row = rowRefs.current[pendingFocusUid];
268
+ // None for a draft opened as the editor card (see the effect above): the card takes the view when it is placed.
269
+ if (!row)
270
+ return;
241
271
  const scroller = scrollingAncestor(row);
242
272
  if (scroller !== document.documentElement) {
243
273
  const rect = row.getBoundingClientRect();
@@ -450,6 +480,10 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
450
480
  headerRefs.current[reply.uid] = node;
451
481
  } }) }, reply.uid))), messages.map((message) => {
452
482
  const uid = message.uid;
483
+ // A draft being edited here is the editor card above, not a message to read as well.
484
+ if (inlineSessions.some((session) => session.draftUid === uid)) {
485
+ return null;
486
+ }
453
487
  const expanded = expandedUids.has(uid);
454
488
  const bodyId = `thread-message-${uid}`;
455
489
  const messageUnread = isUnread(message);
@@ -119,6 +119,8 @@ export interface InlineComposeSession {
119
119
  id: string;
120
120
  /** The uid of the message the window answers. */
121
121
  inlineFor: string;
122
+ /** 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. */
123
+ draftUid?: string;
122
124
  }
123
125
  /**
124
126
  * For a reading pane: announces that it is showing the messages `uids`, so a Reply or Forward of one of them opens its compose
@@ -115,6 +115,9 @@ export function useCompose() {
115
115
  */
116
116
  export default function ComposeProvider({ children, userUid, trusted }) {
117
117
  const [sessions, setSessions] = useState([]);
118
+ // The sessions as of the last render, for `openCompose()` to read at the click.
119
+ const sessionsRef = useRef(sessions);
120
+ sessionsRef.current = sessions;
118
121
  const client = useApiClient();
119
122
  const isMobile = useIsMobile();
120
123
  const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
@@ -138,7 +141,7 @@ export default function ComposeProvider({ children, userUid, trusted }) {
138
141
  const hosted = new Set(hostedUids);
139
142
  const isInline = (session) => !!session.inlineFor && !parked.has(session.id) && hosted.has(session.inlineFor);
140
143
  const inlineKey = sessions.filter(isInline).map((session) => `${session.id}:${session.inlineFor}`).join("|");
141
- const inlineSessions = useMemo(() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor })), [inlineKey]);
144
+ const inlineSessions = useMemo(() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor, draftUid: session.resume?.draft.uid })), [inlineKey]);
142
145
  const inlineValue = useMemo(() => ({ sessions: inlineSessions, register, containerFor: (id) => containersRef.current.get(id) }), [inlineSessions, register]);
143
146
  const floatingSessions = sessions.filter((session) => !inlineSessions.some((inline) => inline.id === session.id));
144
147
  // A session whose message is no longer in any pane (the reader went to another conversation) is parked in the stack.
@@ -184,6 +187,19 @@ export default function ComposeProvider({ children, userUid, trusted }) {
184
187
  };
185
188
  }, [hasSessions]);
186
189
  function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, pending, resume, inlineFor, }) {
190
+ // 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.
191
+ const editing = resume && sessionsRef.current.find((session) => session.resume?.draft.uid === resume.draft.uid);
192
+ if (editing) {
193
+ setParked((prev) => {
194
+ if (!prev.has(editing.id)) {
195
+ return prev;
196
+ }
197
+ const next = new Set(prev);
198
+ next.delete(editing.id);
199
+ return next;
200
+ });
201
+ return;
202
+ }
187
203
  const id = crypto.randomUUID();
188
204
  markComposePhase(id, "click");
189
205
  // 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,10 @@
1
+ import { type Message } from "../../../../../lib/mail/mailApi.js";
2
+ import type { ApiClient } from "../../../../../lib/util/api.js";
3
+ import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
4
+ /**
5
+ * 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
6
+ * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
7
+ * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
8
+ * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
9
+ */
10
+ export declare function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined>;