@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
@@ -41,13 +41,13 @@ export default function InformationPanel({ versions, plugins, information }: Inf
41
41
  <SettingsTable
42
42
  id="diagnostics-environment"
43
43
  title="Environment variables"
44
- description="The server process's environment. Only variables known to be harmless show their value; the value of a secret, or of anything unrecognized, is withheld by the server and never sent."
44
+ description="The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent."
45
45
  settings={information.data.environment ?? []}
46
46
  />
47
47
  <SettingsTable
48
48
  id="diagnostics-configuration"
49
49
  title="Configuration"
50
- description="The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Passwords, tokens, keys, certificates and the credentials in URLs are withheld by the server and never sent."
50
+ description="The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs."
51
51
  settings={information.data.configuration ?? []}
52
52
  />
53
53
  </>
@@ -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
  ))}
@@ -76,8 +76,8 @@ export interface DiagnosticsVersions {
76
76
  }
77
77
 
78
78
  /**
79
- * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every secret-like
80
- * or unrecognized value and never sends it, so there is nothing for the client to hide.
79
+ * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds the value of every setting on its
80
+ * hidden list and never sends it, so there is nothing for the client to hide.
81
81
  */
82
82
  export interface DiagnosticsSetting {
83
83
  name: string;
@@ -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
  }
@@ -33,7 +33,9 @@ import {
33
33
  retryPluginPurge,
34
34
  searchPlugins,
35
35
  updatePlugin,
36
+ uploadPlugin,
36
37
  } from "../../../../../lib/admin/pluginsApi.js";
38
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
37
39
  import Alert from "../ActionAlert.js";
38
40
  import Button from "../../../../../lib/components/buttons/Button.js";
39
41
  import Modal from "../../../../../lib/components/overlays/Modal.js";
@@ -84,6 +86,21 @@ function purgeFailure(purge: PluginPurgeInfo): string {
84
86
  /** A day, in the administrator's own format. */
85
87
  const formatDate = (iso: string): string => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
86
88
 
89
+ /** The file names an uploaded plugin may have: what `npm pack` produces (`<name>-<version>.tgz`), or the same gzipped. */
90
+ const PLUGIN_PACK_NAME = /\.(tgz|tar\.gz|gz)$/i;
91
+
92
+ /** What the server answers (409) an upload of a plugin that is already installed, which the administrator may replace. */
93
+ const ALREADY_INSTALLED = /already installed/i;
94
+
95
+ /** An upload the administrator is being asked to let replace the plugin of the same name. */
96
+ interface PendingReplace {
97
+ file: File;
98
+ /** What the replacement would do, as a question. */
99
+ question: string;
100
+ /** Why the server refused, in its words. */
101
+ reason: string;
102
+ }
103
+
87
104
  /** A change waiting for the administrator to confirm the other plugins it also installs or enables. */
88
105
  interface PendingChange {
89
106
  displayName: string;
@@ -225,6 +242,11 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
225
242
  const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState<Plugin[] | null>(null);
226
243
  /** How many changes (or the lookups that come before them) are waiting on the server. */
227
244
  const [working, setWorking] = useState(0);
245
+ /** Set while an uploaded plugin is being sent to the server. */
246
+ const [uploading, setUploading] = useState(false);
247
+ /** An upload that would replace an installed plugin, waiting for the administrator to confirm it. */
248
+ const [replacing, setReplacing] = useState<PendingReplace | null>(null);
249
+ const fileInput = useRef<HTMLInputElement>(null);
228
250
  const statusRequest = useRef<Promise<void> | null>(null);
229
251
  /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
230
252
  const seenPurges = useRef<Map<string, PluginPurgeState>>(new Map());
@@ -517,6 +539,54 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
517
539
  return onRow(plugin, () => changeVersion(plugin, packageVersion));
518
540
  }
519
541
 
542
+ /** 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
543
+ * already installed isn't an error but a question, asked of the administrator before it is replaced. */
544
+ async function runUpload(file: File, replace: boolean) {
545
+ setUploading(true);
546
+ setError(null);
547
+ setRetryEnableUid(null);
548
+ try {
549
+ const uploaded: Plugin = await track(uploadPlugin(file, { replace }));
550
+ applied(uploaded);
551
+ notify({
552
+ kind: "success",
553
+ title: `${uploaded.name}@${uploaded.packageVersion} uploaded`,
554
+ message: "The servers restart one at a time to load it.",
555
+ });
556
+ } catch (err) {
557
+ if (!replace && err instanceof ApiRequestError && err.status === 409 && ALREADY_INSTALLED.test(err.message)) {
558
+ const installed: Plugin | undefined = plugins.find((plugin) => err.message.includes(plugin.name));
559
+ setReplacing({
560
+ file,
561
+ question: installed
562
+ ? `Replace ${installed.name} ${installed.packageVersion} with the uploaded pack?`
563
+ : "Replace the installed plugin with the uploaded pack?",
564
+ reason: err.message,
565
+ });
566
+ } else if (err instanceof ApiRequestError && err.status === 413) {
567
+ setError("That file is too large for the server to accept.");
568
+ } else {
569
+ setError(errorMessage(err, "Could not upload the plugin."));
570
+ }
571
+ }
572
+ setUploading(false);
573
+ }
574
+
575
+ /** Takes the file the administrator chose, unless it can't be what `npm pack` produces. */
576
+ function onFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
577
+ const file: File | undefined = e.target.files?.[0];
578
+ // So choosing the same file again (after fixing what was wrong) still counts as a change.
579
+ e.target.value = "";
580
+ if (!file) {
581
+ return;
582
+ }
583
+ if (!PLUGIN_PACK_NAME.test(file.name)) {
584
+ setError(`${file.name} isn't a plugin pack. Choose the .tgz file that npm pack produces.`);
585
+ return;
586
+ }
587
+ void runUpload(file, false);
588
+ }
589
+
520
590
  /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
521
591
  function reinstall(purge: PluginPurgeInfo) {
522
592
  const displayName: string = purgeName(purge);
@@ -682,12 +752,34 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
682
752
  const displayNameOf = (name: string): string => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
683
753
 
684
754
  const SectionHeading = embedded ? "h3" : "h2";
755
+ const bulkBusy: boolean = bulk !== null;
685
756
  const addButton = (
686
- <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => setAdding(true)}>
687
- Add by name
688
- </Button>
757
+ <div className="flex flex-wrap gap-2 shrink-0">
758
+ <input
759
+ ref={fileInput}
760
+ type="file"
761
+ className="hidden"
762
+ accept=".tgz,.tar.gz,application/gzip,application/x-gzip"
763
+ aria-label="Plugin pack file"
764
+ tabIndex={-1}
765
+ onChange={onFileChosen}
766
+ />
767
+ <Button
768
+ type="button"
769
+ variant="secondary"
770
+ className="!w-auto"
771
+ title="Install a plugin from the .tgz file that npm pack produces"
772
+ loading={uploading}
773
+ disabled={uploading || bulkBusy}
774
+ onClick={() => fileInput.current?.click()}
775
+ >
776
+ {uploading ? "Uploading\u2026" : "Upload"}
777
+ </Button>
778
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setAdding(true)}>
779
+ Add by name
780
+ </Button>
781
+ </div>
689
782
  );
690
- const bulkBusy: boolean = bulk !== null;
691
783
 
692
784
  return (
693
785
  <>
@@ -741,24 +833,18 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
741
833
  </div>
742
834
  </Alert>
743
835
  )}
744
- {error && (
745
- <Alert>
746
- {error}
747
- {retryEnable && (
748
- <>
749
- {" "}
750
- <Button
751
- type="button"
752
- variant="text"
753
- className="!w-auto !p-0"
754
- onClick={() => void toggle(retryEnable)}
755
- >
756
- Try again
757
- </Button>
758
- </>
759
- )}
760
- </Alert>
761
- )}
836
+ {/* A message on its own is a plain string child, which is what lets the alert offer to confirm the identity again. */}
837
+ {error &&
838
+ (retryEnable ? (
839
+ <Alert>
840
+ {error}{" "}
841
+ <Button type="button" variant="text" className="!w-auto !p-0" onClick={() => void toggle(retryEnable)}>
842
+ Try again
843
+ </Button>
844
+ </Alert>
845
+ ) : (
846
+ <Alert>{error}</Alert>
847
+ ))}
762
848
  <RolloutBanner status={status} />
763
849
  {statusStale && status && (
764
850
  <p className="mb-4 text-xs text-text-muted">Couldn&apos;t refresh server status. Showing the last status reported.</p>
@@ -871,7 +957,9 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
871
957
  <tbody role="rowgroup" className="block lg:table-row-group">
872
958
  {plugins.map((plugin) => {
873
959
  const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
874
- const latest: string | undefined = update?.updateAvailable ? update.latestVersion : undefined;
960
+ const uploaded: boolean = plugin.source === "upload";
961
+ // An uploaded pack has no registry version to be newer than.
962
+ const latest: string | undefined = !uploaded && update?.updateAvailable ? update.latestVersion : undefined;
875
963
  const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
876
964
  const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
877
965
  const requiredBy: string[] = plugins
@@ -895,6 +983,13 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
895
983
  {plugin.manifest.description && (
896
984
  <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
897
985
  )}
986
+ {uploaded && (
987
+ <div className="text-xs text-text-muted mt-1 break-words">
988
+ <span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Uploaded</span>{" "}
989
+ {plugin.uploadFilename}
990
+ {plugin.uploadedAt ? `, ${formatDate(plugin.uploadedAt)}` : ""}
991
+ </div>
992
+ )}
898
993
  {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
899
994
  {requiredBy.length > 0 && (
900
995
  <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
@@ -942,7 +1037,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
942
1037
  </div>
943
1038
  <div className="flex flex-wrap gap-x-4 lg:justify-end">
944
1039
  <Button variant="text" type="button" onClick={() => setUpgrading(plugin)}>
945
- Change version
1040
+ {uploaded ? "Install the registry version" : "Change version"}
946
1041
  </Button>
947
1042
  <Button variant="text" type="button" onClick={() => setRemoving(plugin)}>
948
1043
  Uninstall
@@ -1002,6 +1097,28 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
1002
1097
  }}
1003
1098
  />
1004
1099
  )}
1100
+ {replacing && (
1101
+ <Modal open onClose={() => setReplacing(null)} title="Replace plugin?">
1102
+ <p className="text-sm mb-2">{replacing.question}</p>
1103
+ <p className="text-sm text-text-muted mb-4 break-words">{replacing.reason}</p>
1104
+ <div className="flex gap-2 justify-end">
1105
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setReplacing(null)}>
1106
+ Cancel
1107
+ </Button>
1108
+ <Button
1109
+ type="button"
1110
+ className="!w-auto"
1111
+ onClick={() => {
1112
+ const { file } = replacing;
1113
+ setReplacing(null);
1114
+ void runUpload(file, true);
1115
+ }}
1116
+ >
1117
+ Replace
1118
+ </Button>
1119
+ </div>
1120
+ </Modal>
1121
+ )}
1005
1122
  {confirming && (
1006
1123
  <ConfirmDependenciesModal
1007
1124
  change={confirming}
@@ -1570,15 +1687,20 @@ function ChangeVersionModal({
1570
1687
  const [selected, setSelected] = useState(plugin.packageVersion);
1571
1688
  const [error, setError] = useState<string | null>(null);
1572
1689
  const [busy, setBusy] = useState(false);
1690
+ // An uploaded pack isn't a registry version, so any registry version is a change from it - the newest is offered first.
1691
+ const uploaded: boolean = plugin.source === "upload";
1573
1692
 
1574
1693
  useEffect(() => {
1575
1694
  lookupPluginPackage(plugin.name, undefined, { prerelease })
1576
1695
  .then((found) => {
1577
1696
  setVersions(found.package.versions);
1578
1697
  setLatest(found.package.latest);
1698
+ if (uploaded && found.package.latest) {
1699
+ setSelected(found.package.latest);
1700
+ }
1579
1701
  })
1580
1702
  .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
1581
- }, [plugin.name, prerelease]);
1703
+ }, [plugin.name, prerelease, uploaded]);
1582
1704
 
1583
1705
  async function save() {
1584
1706
  setBusy(true);
@@ -1591,12 +1713,17 @@ function ChangeVersionModal({
1591
1713
  }
1592
1714
 
1593
1715
  return (
1594
- <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
1716
+ <Modal open onClose={onClose} title={uploaded ? `Install the registry version of ${plugin.manifest.displayName}` : `${plugin.manifest.displayName} version`}>
1595
1717
  {error && <Alert>{error}</Alert>}
1596
1718
  {versions === null ? (
1597
1719
  !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
1598
1720
  ) : (
1599
1721
  <div className="flex flex-col gap-3">
1722
+ {uploaded && (
1723
+ <p className="text-sm text-text-muted">
1724
+ {plugin.manifest.displayName} {plugin.packageVersion} was uploaded as {plugin.uploadFilename}. This replaces it with the version from the registry.
1725
+ </p>
1726
+ )}
1600
1727
  <label className="flex flex-col gap-1.5 text-sm">
1601
1728
  <span className="font-semibold">Version</span>
1602
1729
  <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
@@ -1604,7 +1731,7 @@ function ChangeVersionModal({
1604
1731
  <option key={version} value={version}>
1605
1732
  {version}
1606
1733
  {version === latest ? " (latest)" : ""}
1607
- {version === plugin.packageVersion ? " (installed)" : ""}
1734
+ {!uploaded && version === plugin.packageVersion ? " (installed)" : ""}
1608
1735
  </option>
1609
1736
  ))}
1610
1737
  </select>
@@ -1617,10 +1744,10 @@ function ChangeVersionModal({
1617
1744
  type="button"
1618
1745
  className="!w-auto"
1619
1746
  loading={busy}
1620
- disabled={busy || selected === plugin.packageVersion}
1747
+ disabled={busy || (!uploaded && selected === plugin.packageVersion)}
1621
1748
  onClick={() => void save()}
1622
1749
  >
1623
- Save
1750
+ {uploaded ? "Install" : "Save"}
1624
1751
  </Button>
1625
1752
  </div>
1626
1753
  </div>
@@ -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
  ];