@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.
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +2 -2
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +2 -2
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +24 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +3 -3
- package/apps/shared/components/admin/settings/PluginsManager.tsx +156 -29
- package/apps/shared/components/calendar/EventDetails.tsx +10 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +10 -0
- package/apps/shared/components/calendar/MonthView.tsx +10 -4
- package/apps/shared/components/contacts/ContactCard.tsx +4 -2
- package/apps/shared/components/contacts/ContactsSidebar.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +6 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +34 -2
- package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
- package/apps/shared/components/mail/compose/draftResume.ts +54 -0
- package/apps/www/calendar/index.tsx +4 -4
- package/apps/www/contacts/index.tsx +75 -3
- package/apps/www/tasks/index.tsx +1 -1
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +1 -1
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +3 -2
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +2 -2
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +79 -8
- package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
- package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
- package/dist/apps/shared/components/calendar/MonthView.js +4 -4
- package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +35 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
- package/dist/apps/www/calendar/index.js +4 -4
- package/dist/apps/www/contacts/index.js +63 -5
- package/dist/apps/www/tasks/index.js +1 -1
- package/dist/lib/admin/pluginsApi.d.ts +18 -0
- package/dist/lib/admin/pluginsApi.js +15 -1
- package/dist/lib/calendar/recurrence.js +1 -1
- package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
- package/dist/lib/components/overlays/overlayStack.js +10 -0
- package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
- package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
- package/dist/lib/contacts/contactsApi.d.ts +12 -0
- package/dist/lib/contacts/contactsApi.js +8 -0
- package/lib/admin/pluginsApi.ts +24 -1
- package/lib/calendar/recurrence.ts +1 -1
- package/lib/components/overlays/overlayStack.ts +11 -0
- package/lib/components/pickers/MiniDatePicker.tsx +4 -4
- package/lib/contacts/contactsApi.ts +16 -0
- 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.
|
|
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.
|
|
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.
|
|
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
|
|
80
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
{
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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'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
|
|
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…</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 (
|
|
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:
|
|
34
|
-
const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn:
|
|
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
|
|
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
|
];
|