@rapidmx/web-client 0.11.0 → 0.13.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/admin/domains/[uid].tsx +73 -2
- package/apps/admin/domains/index.tsx +2 -1
- package/apps/admin/domains/new/index.tsx +28 -3
- package/apps/admin/mailboxes/new/index.tsx +3 -3
- package/apps/shared/calendar/calendarReminders.ts +76 -0
- package/apps/shared/calendar/useCalendarReminders.ts +91 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +68 -14
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
- package/apps/shared/components/calendar/EventModal.tsx +203 -11
- package/apps/shared/components/layout/AppShell.tsx +3 -0
- package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +40 -109
- package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
- package/apps/shared/notifications/NotificationCenter.tsx +2 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
- package/apps/shared/notifications/store.ts +3 -1
- package/dist/apps/admin/domains/[uid].js +38 -2
- package/dist/apps/admin/domains/index.js +1 -1
- package/dist/apps/admin/domains/new/index.js +13 -4
- package/dist/apps/admin/mailboxes/new/index.js +1 -1
- package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
- package/dist/apps/shared/calendar/calendarReminders.js +55 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
- package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +26 -6
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
- package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
- package/dist/apps/shared/components/calendar/EventModal.js +156 -12
- package/dist/apps/shared/components/layout/AppShell.js +3 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +8 -6
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +12 -65
- package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -1
- package/dist/apps/shared/notifications/store.js +2 -0
- package/package.json +2 -2
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, {
|
|
6
|
-
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
5
|
+
import React, { useState } from "react";
|
|
7
6
|
import {
|
|
8
7
|
MailboxAccessRole,
|
|
9
8
|
ResolvedPrincipal,
|
|
10
9
|
resolveMailboxPrincipal,
|
|
11
10
|
setMailboxAccess,
|
|
12
11
|
} from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
13
|
-
import
|
|
14
|
-
|
|
12
|
+
import PrincipalResolver, { describePerson } from "./PrincipalResolver.js";
|
|
13
|
+
|
|
14
|
+
export { describePerson };
|
|
15
15
|
|
|
16
16
|
export interface PrincipalPickerProps {
|
|
17
17
|
mailboxUid: string;
|
|
@@ -29,117 +29,48 @@ export interface PrincipalPickerProps {
|
|
|
29
29
|
onCancel?: () => void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Who a resolved person is, for a person to check before saving: their name and address, else their uid. */
|
|
33
|
-
export function describePerson(person: ResolvedPrincipal): string {
|
|
34
|
-
if (person.displayName && person.address) return `${person.displayName} <${person.address}>`;
|
|
35
|
-
return person.displayName ?? person.address ?? `user ${person.userUid}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
32
|
/**
|
|
39
|
-
* "Who?" for a sharing screen
|
|
40
|
-
*
|
|
41
|
-
* is
|
|
42
|
-
*
|
|
33
|
+
* "Who?" for a sharing screen - a thin wrapper around the general `PrincipalResolver`, supplying mailbox sharing's
|
|
34
|
+
* own resolve call (`resolveMailboxPrincipal()`), its role selector, and its grant action (`setMailboxAccess()`).
|
|
35
|
+
* What is typed - a mailbox address, an auth-server username or e-mail alias, or a user id - is RESOLVED by the
|
|
36
|
+
* server before anything is saved, and the person is shown (name and address) for the caller to confirm; only then
|
|
37
|
+
* is the grant made, against that person's uid. Free text is never stored: an ACL record matches a user uid and
|
|
38
|
+
* nothing else, so a grant to a typed username applies to nobody.
|
|
43
39
|
*/
|
|
44
40
|
export default function PrincipalPicker({ mailboxUid, roleLabels, initialPrincipal, defaultRole = "viewer", placeholder, onGranted, onCancel }: PrincipalPickerProps) {
|
|
45
|
-
const [typed, setTyped] = useState(initialPrincipal ?? "");
|
|
46
41
|
const [role, setRole] = useState<MailboxAccessRole>(defaultRole);
|
|
47
|
-
const [person, setPerson] = useState<ResolvedPrincipal | null>(null);
|
|
48
|
-
const [busy, setBusy] = useState(false);
|
|
49
|
-
const [error, setError] = useState<string | null>(null);
|
|
50
|
-
|
|
51
|
-
// A stored entry being replaced is looked up straight away, so its person is shown without a click.
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (initialPrincipal) {
|
|
54
|
-
void find(initialPrincipal);
|
|
55
|
-
}
|
|
56
|
-
}, [initialPrincipal]);
|
|
57
|
-
|
|
58
|
-
async function find(principal: string) {
|
|
59
|
-
setBusy(true);
|
|
60
|
-
setError(null);
|
|
61
|
-
setPerson(null);
|
|
62
|
-
try {
|
|
63
|
-
setPerson(await resolveMailboxPrincipal(mailboxUid, principal));
|
|
64
|
-
} catch (err) {
|
|
65
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not look that person up.");
|
|
66
|
-
} finally {
|
|
67
|
-
setBusy(false);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function handleFind(e: FormEvent) {
|
|
72
|
-
e.preventDefault();
|
|
73
|
-
const principal = typed.trim();
|
|
74
|
-
if (principal) {
|
|
75
|
-
void find(principal);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Only reachable from the confirm button, which only renders once `person` is set.
|
|
80
|
-
async function handleGrant() {
|
|
81
|
-
setBusy(true);
|
|
82
|
-
setError(null);
|
|
83
|
-
try {
|
|
84
|
-
await setMailboxAccess(mailboxUid, person!.userUid, role);
|
|
85
|
-
await onGranted(person!, role);
|
|
86
|
-
setTyped("");
|
|
87
|
-
setPerson(null);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
|
|
90
|
-
} finally {
|
|
91
|
-
setBusy(false);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
42
|
|
|
95
43
|
return (
|
|
96
|
-
<
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<select
|
|
112
|
-
aria-label="Access level"
|
|
113
|
-
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
|
|
114
|
-
value={role}
|
|
115
|
-
onChange={(e) => setRole(e.target.value as MailboxAccessRole)}
|
|
116
|
-
disabled={busy}
|
|
117
|
-
>
|
|
118
|
-
{(Object.keys(roleLabels) as MailboxAccessRole[]).map((value) => (
|
|
119
|
-
<option key={value} value={value}>
|
|
120
|
-
{roleLabels[value]}
|
|
121
|
-
</option>
|
|
122
|
-
))}
|
|
123
|
-
</select>
|
|
124
|
-
<Button type="submit" variant="secondary" loading={busy && !person} disabled={busy} className="!w-auto">
|
|
125
|
-
Find
|
|
126
|
-
</Button>
|
|
127
|
-
{onCancel && (
|
|
128
|
-
<Button type="button" variant="text" className="!w-auto" onClick={onCancel}>
|
|
129
|
-
Cancel
|
|
130
|
-
</Button>
|
|
131
|
-
)}
|
|
132
|
-
</form>
|
|
133
|
-
{person && (
|
|
134
|
-
<div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
|
|
135
|
-
<span>
|
|
136
|
-
Share with <strong className="break-all">{describePerson(person)}</strong> as <em>{roleLabels[role]}</em>?
|
|
137
|
-
</span>
|
|
138
|
-
<Button type="button" loading={busy} disabled={busy} className="!w-auto" onClick={() => void handleGrant()}>
|
|
139
|
-
Grant
|
|
140
|
-
</Button>
|
|
141
|
-
</div>
|
|
44
|
+
<PrincipalResolver
|
|
45
|
+
resolve={(principal) => resolveMailboxPrincipal(mailboxUid, principal)}
|
|
46
|
+
onResolved={async (person) => {
|
|
47
|
+
await setMailboxAccess(mailboxUid, person.userUid, role);
|
|
48
|
+
await onGranted(person, role);
|
|
49
|
+
}}
|
|
50
|
+
initialPrincipal={initialPrincipal}
|
|
51
|
+
placeholder={placeholder ?? "Email address, username or user id"}
|
|
52
|
+
ariaLabel="Who to share with"
|
|
53
|
+
confirmLabel="Grant"
|
|
54
|
+
confirmErrorMessage="Could not grant access."
|
|
55
|
+
describeConfirm={(person) => (
|
|
56
|
+
<>
|
|
57
|
+
Share with <strong className="break-all">{describePerson(person)}</strong> as <em>{roleLabels[role]}</em>?
|
|
58
|
+
</>
|
|
142
59
|
)}
|
|
143
|
-
|
|
60
|
+
onCancel={onCancel}
|
|
61
|
+
>
|
|
62
|
+
<select
|
|
63
|
+
aria-label="Access level"
|
|
64
|
+
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
|
|
65
|
+
value={role}
|
|
66
|
+
onChange={(e) => setRole(e.target.value as MailboxAccessRole)}
|
|
67
|
+
>
|
|
68
|
+
{(Object.keys(roleLabels) as MailboxAccessRole[]).map((value) => (
|
|
69
|
+
<option key={value} value={value}>
|
|
70
|
+
{roleLabels[value]}
|
|
71
|
+
</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
</PrincipalResolver>
|
|
144
75
|
);
|
|
145
76
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { KeyboardEvent, ReactNode, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
|
|
11
|
+
export interface PrincipalResolverProps {
|
|
12
|
+
/** Resolves what was typed - a mailbox address, an auth-server username or e-mail alias, or a user uid - to
|
|
13
|
+
* the one person it names, exact match only. Rejects (typically with an `ApiRequestError`) for nobody. */
|
|
14
|
+
resolve: (principal: string) => Promise<ResolvedPrincipal>;
|
|
15
|
+
/** Called once the caller confirms the resolved person is the right one. The box is cleared once this
|
|
16
|
+
* resolves - throw (or reject) to keep the person on screen and show an error instead. */
|
|
17
|
+
onResolved: (person: ResolvedPrincipal) => void | Promise<void>;
|
|
18
|
+
/** Text put in the box to start with - a stored entry being replaced, say - looked up automatically on mount. */
|
|
19
|
+
initialPrincipal?: string;
|
|
20
|
+
/** What the box asks for. */
|
|
21
|
+
placeholder: string;
|
|
22
|
+
/** The box's accessible name. */
|
|
23
|
+
ariaLabel: string;
|
|
24
|
+
/** Extra controls rendered between the text box and the Find button (e.g. a role selector). */
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
/** The confirm button's label (e.g. "Grant", "Use this owner"). */
|
|
27
|
+
confirmLabel: string;
|
|
28
|
+
/** What the confirmation row says once a person is found - e.g. "Share with X as Y?". */
|
|
29
|
+
describeConfirm: (person: ResolvedPrincipal) => ReactNode;
|
|
30
|
+
/** Shown if `onResolved` rejects, unless it was an `ApiRequestError` (then its own message is shown). */
|
|
31
|
+
confirmErrorMessage: string;
|
|
32
|
+
/** Shown as a Cancel button next to the box when set (replacing an entry). */
|
|
33
|
+
onCancel?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Who a resolved person is, for a person to check before confirming: their name and address, else their uid. */
|
|
37
|
+
export function describePerson(person: ResolvedPrincipal): string {
|
|
38
|
+
if (person.displayName && person.address) return `${person.displayName} <${person.address}>`;
|
|
39
|
+
return person.displayName ?? person.address ?? `user ${person.userUid}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* "Who?" - anywhere a person is assigned by typed identifier rather than picked from an already-loaded list. What
|
|
44
|
+
* is typed is RESOLVED by the server (`resolve`) to the person it names before anything is saved, and the person is
|
|
45
|
+
* shown (name and address) for the caller to confirm; only `onResolved` ever sees the resolved, verified person -
|
|
46
|
+
* never the free text. This is deliberately exact-match, type-then-confirm - never a live/fuzzy search-as-you-type
|
|
47
|
+
* autocomplete, which would let anyone with access to the field enumerate the user directory by partial name.
|
|
48
|
+
*
|
|
49
|
+
* Parameterized entirely by `resolve`/`onResolved` (and the wording props) so every caller - mailbox sharing
|
|
50
|
+
* (`PrincipalPicker`, a thin wrapper around this component), mailbox ownership, escrow-scope holders, ... - reuses
|
|
51
|
+
* the same resolve-then-confirm UX and privacy posture rather than each reimplementing it.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately renders no `<form>` of its own (Enter-to-find is wired up by hand instead, see `handleKeyDown()`) -
|
|
54
|
+
* some callers (`MailboxCreateForm`) use this as one field of their own, larger `<form>`, and a nested `<form>` is
|
|
55
|
+
* invalid HTML that real browsers mis-handle (an inner Enter keypress can submit the wrong one).
|
|
56
|
+
*/
|
|
57
|
+
export default function PrincipalResolver({
|
|
58
|
+
resolve,
|
|
59
|
+
onResolved,
|
|
60
|
+
initialPrincipal,
|
|
61
|
+
placeholder,
|
|
62
|
+
ariaLabel,
|
|
63
|
+
children,
|
|
64
|
+
confirmLabel,
|
|
65
|
+
describeConfirm,
|
|
66
|
+
confirmErrorMessage,
|
|
67
|
+
onCancel,
|
|
68
|
+
}: PrincipalResolverProps) {
|
|
69
|
+
const [typed, setTyped] = useState(initialPrincipal ?? "");
|
|
70
|
+
const [person, setPerson] = useState<ResolvedPrincipal | null>(null);
|
|
71
|
+
const [busy, setBusy] = useState(false);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
|
|
74
|
+
// A stored entry being replaced is looked up straight away, so its person is shown without a click.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (initialPrincipal) {
|
|
77
|
+
void find(initialPrincipal);
|
|
78
|
+
}
|
|
79
|
+
}, [initialPrincipal]);
|
|
80
|
+
|
|
81
|
+
async function find(principal: string) {
|
|
82
|
+
setBusy(true);
|
|
83
|
+
setError(null);
|
|
84
|
+
setPerson(null);
|
|
85
|
+
try {
|
|
86
|
+
setPerson(await resolve(principal));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not look that person up.");
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function submitFind() {
|
|
95
|
+
const principal = typed.trim();
|
|
96
|
+
if (principal) {
|
|
97
|
+
void find(principal);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Enter in the text box finds, same as clicking the button - handled here (not a native `<form>` submit,
|
|
102
|
+
* see this component's own doc comment on why) so it works whether or not a caller nests this inside one of
|
|
103
|
+
* their own forms (`MailboxCreateForm` does). */
|
|
104
|
+
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
105
|
+
if (e.key === "Enter") {
|
|
106
|
+
// Stops the keystroke from also submitting an ANCESTOR `<form>` this component happens to be nested in.
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
submitFind();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Only reachable from the confirm button, which only renders once `person` is set.
|
|
113
|
+
async function handleConfirm() {
|
|
114
|
+
setBusy(true);
|
|
115
|
+
setError(null);
|
|
116
|
+
try {
|
|
117
|
+
await onResolved(person!);
|
|
118
|
+
setTyped("");
|
|
119
|
+
setPerson(null);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
setError(err instanceof ApiRequestError ? err.message : confirmErrorMessage);
|
|
122
|
+
} finally {
|
|
123
|
+
setBusy(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex flex-col gap-2">
|
|
129
|
+
{error && <Alert>{error}</Alert>}
|
|
130
|
+
<div className="flex gap-2">
|
|
131
|
+
<input
|
|
132
|
+
type="text"
|
|
133
|
+
aria-label={ariaLabel}
|
|
134
|
+
placeholder={placeholder}
|
|
135
|
+
className="flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
|
|
136
|
+
value={typed}
|
|
137
|
+
onChange={(e) => {
|
|
138
|
+
setTyped(e.target.value);
|
|
139
|
+
setPerson(null);
|
|
140
|
+
}}
|
|
141
|
+
onKeyDown={handleKeyDown}
|
|
142
|
+
disabled={busy}
|
|
143
|
+
/>
|
|
144
|
+
{children}
|
|
145
|
+
<Button type="button" variant="secondary" loading={busy && !person} disabled={busy} className="!w-auto" onClick={submitFind}>
|
|
146
|
+
Find
|
|
147
|
+
</Button>
|
|
148
|
+
{onCancel && (
|
|
149
|
+
<Button type="button" variant="text" className="!w-auto" onClick={onCancel}>
|
|
150
|
+
Cancel
|
|
151
|
+
</Button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
{person && (
|
|
155
|
+
<div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
|
|
156
|
+
<span>{describeConfirm(person)}</span>
|
|
157
|
+
<Button type="button" loading={busy} disabled={busy} className="!w-auto" onClick={() => void handleConfirm()}>
|
|
158
|
+
{confirmLabel}
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import React, { useEffect } from "react";
|
|
6
6
|
import type { IconType } from "react-icons";
|
|
7
7
|
import {
|
|
8
|
+
HiOutlineBell,
|
|
8
9
|
HiOutlineCheckCircle,
|
|
9
10
|
HiOutlineEnvelope,
|
|
10
11
|
HiOutlineExclamationCircle,
|
|
@@ -19,6 +20,7 @@ import { useSystemErrorNotifications } from "./systemErrors.js";
|
|
|
19
20
|
|
|
20
21
|
const KIND_STYLE: Record<NotificationKind, { border: string; icon: string; Icon: IconType; label: string }> = {
|
|
21
22
|
mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
|
|
23
|
+
calendar: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineBell, label: "Reminder" },
|
|
22
24
|
info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
|
|
23
25
|
success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
|
|
24
26
|
warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* `notify()` only hands back an id.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
|
|
29
|
+
export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
|
|
30
30
|
|
|
31
31
|
export interface NotificationAction {
|
|
32
32
|
label: string;
|
|
@@ -119,6 +119,8 @@ export const MAX_DETAIL_LINE_LENGTH = 500;
|
|
|
119
119
|
/** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
|
|
120
120
|
export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
|
|
121
121
|
mail: 8_000,
|
|
122
|
+
// Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
|
|
123
|
+
calendar: 8_000,
|
|
122
124
|
info: 6_000,
|
|
123
125
|
success: 5_000,
|
|
124
126
|
warning: 10_000,
|
|
@@ -5,12 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { deleteDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
8
|
+
import { deleteDomain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
|
|
11
11
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
12
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
13
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
13
14
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
15
|
+
const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
14
16
|
export default function DomainDetailPage(props) {
|
|
15
17
|
return (_jsx(AdminShell, { ...props, active: "domains", children: _jsx(DomainDetailContent, { uid: props.params.uid }) }));
|
|
16
18
|
}
|
|
@@ -19,6 +21,15 @@ function DomainDetailContent({ uid }) {
|
|
|
19
21
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
20
22
|
const [deleting, setDeleting] = useState(false);
|
|
21
23
|
const [deleteError, setDeleteError] = useState(null);
|
|
24
|
+
// Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
|
|
25
|
+
// the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
|
|
26
|
+
const [aliasInput, setAliasInput] = useState(null);
|
|
27
|
+
const [savingAlias, setSavingAlias] = useState(false);
|
|
28
|
+
const [aliasError, setAliasError] = useState(null);
|
|
29
|
+
const [aliasSaved, setAliasSaved] = useState(false);
|
|
30
|
+
// Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
|
|
31
|
+
// fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
|
|
32
|
+
const [refreshCount, setRefreshCount] = useState(0);
|
|
22
33
|
function closeDeleteModal() {
|
|
23
34
|
setConfirmingDelete(false);
|
|
24
35
|
}
|
|
@@ -35,5 +46,30 @@ function DomainDetailContent({ uid }) {
|
|
|
35
46
|
setDeleting(false);
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
|
-
|
|
49
|
+
function handleLoaded(d) {
|
|
50
|
+
setDomain(d);
|
|
51
|
+
setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
|
|
52
|
+
}
|
|
53
|
+
async function handleSaveAlias() {
|
|
54
|
+
setSavingAlias(true);
|
|
55
|
+
setAliasError(null);
|
|
56
|
+
setAliasSaved(false);
|
|
57
|
+
try {
|
|
58
|
+
const updated = await updateDomain({ uid: domain.uid, version: domain.version, aliasOf: aliasInput.trim() || undefined });
|
|
59
|
+
setDomain(updated);
|
|
60
|
+
setAliasInput(updated.aliasOf ?? "");
|
|
61
|
+
setAliasSaved(true);
|
|
62
|
+
setRefreshCount((c) => c + 1);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setSavingAlias(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS, value: aliasInput ?? "", onChange: (e) => {
|
|
72
|
+
setAliasInput(e.target.value);
|
|
73
|
+
setAliasSaved(false);
|
|
74
|
+
}, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
|
|
39
75
|
}
|
|
@@ -27,5 +27,5 @@ function DomainsListContent() {
|
|
|
27
27
|
.finally(() => setLoading(false));
|
|
28
28
|
}, [page]);
|
|
29
29
|
const hasNextPage = domains.length === PAGE_SIZE;
|
|
30
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
|
|
30
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", "Alias of", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: domain.aliasOf || "—" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
|
|
31
31
|
}
|
|
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useState } from "react";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
8
|
+
import { createDomain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
11
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
@@ -16,8 +16,17 @@ export default function NewDomainPage(props) {
|
|
|
16
16
|
}
|
|
17
17
|
function NewDomainForm() {
|
|
18
18
|
const [name, setName] = useState("");
|
|
19
|
+
const [aliasOf, setAliasOf] = useState("");
|
|
20
|
+
// Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
|
|
21
|
+
// on the `@rapidmx/restapi` side.
|
|
22
|
+
const [primaryDomains, setPrimaryDomains] = useState([]);
|
|
19
23
|
const [error, setError] = useState(null);
|
|
20
24
|
const [saving, setSaving] = useState(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
listDomains({ limit: 200 })
|
|
27
|
+
.then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
|
|
28
|
+
.catch(() => setPrimaryDomains([]));
|
|
29
|
+
}, []);
|
|
21
30
|
async function handleSubmit(e) {
|
|
22
31
|
e.preventDefault();
|
|
23
32
|
setError(null);
|
|
@@ -27,7 +36,7 @@ function NewDomainForm() {
|
|
|
27
36
|
}
|
|
28
37
|
setSaving(true);
|
|
29
38
|
try {
|
|
30
|
-
const domain = await createDomain({ name: name.trim() });
|
|
39
|
+
const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
|
|
31
40
|
window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
|
|
32
41
|
}
|
|
33
42
|
catch (err) {
|
|
@@ -37,5 +46,5 @@ function NewDomainForm() {
|
|
|
37
46
|
setSaving(false);
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
|
-
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
49
|
+
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs(FormField, { label: "Alias of", htmlFor: "aliasOf", children: [_jsxs("select", { id: "aliasOf", className: INPUT_CLASS, value: aliasOf, onChange: (e) => setAliasOf(e.target.value), children: [_jsx("option", { value: "", children: "None - a regular domain with mailboxes of its own" }), primaryDomains.map((domain) => (_jsx("option", { value: domain.name, children: domain.name }, domain.uid)))] }), _jsx("p", { className: "text-xs text-text-muted mt-1.5", children: "A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but has no mailboxes of its own - it still needs its own DNS setup below." })] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
41
50
|
}
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
3
3
|
import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
|
|
4
4
|
export default function NewMailboxPage(props) {
|
|
5
|
-
return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "
|
|
5
|
+
return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose \"Shared mailbox\" to create a true ownerless mailbox (the Exchange \"shared mailbox\" concept) \u2014 access is then granted entirely to delegates afterward, not to a single owner. Choose \"Owned by a specific person\" and look them up to create a mailbox for a specific user instead." }), _jsx(MailboxCreateForm, { cancelHref: "/admin", onCreated: (mailbox) => {
|
|
6
6
|
window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
|
|
7
7
|
} })] }) }));
|
|
8
8
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
2
|
+
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
3
|
+
import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
4
|
+
/** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
|
|
5
|
+
export declare const SNOOZE_MS: number;
|
|
6
|
+
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
7
|
+
export interface CalendarReminderNotice {
|
|
8
|
+
eventUid: string;
|
|
9
|
+
title: string;
|
|
10
|
+
/** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
|
|
11
|
+
startDate: string;
|
|
12
|
+
/** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
|
|
13
|
+
* see `joinMeetingUrl()` for when it's shown as a join link. */
|
|
14
|
+
location?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
|
|
17
|
+
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
18
|
+
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
19
|
+
export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
|
|
20
|
+
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
21
|
+
export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
|
|
22
|
+
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
23
|
+
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
24
|
+
export declare function snoozeDelayMs(startDate: string, now?: number): number;
|
|
25
|
+
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
26
|
+
export declare function reminderMessage(startDate: string, now?: number): string;
|
|
27
|
+
/** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
|
|
28
|
+
export declare function reminderNotificationId(eventUid: string, startDate: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
6
|
+
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
7
|
+
/** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
|
|
8
|
+
export const SNOOZE_MS = 5 * 60000;
|
|
9
|
+
function isReminderNotice(data) {
|
|
10
|
+
const notice = data;
|
|
11
|
+
return (!!notice &&
|
|
12
|
+
typeof notice.eventUid === "string" &&
|
|
13
|
+
typeof notice.title === "string" &&
|
|
14
|
+
typeof notice.startDate === "string" &&
|
|
15
|
+
(notice.location === undefined || notice.location === null || typeof notice.location === "string"));
|
|
16
|
+
}
|
|
17
|
+
/** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
|
|
18
|
+
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
19
|
+
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
20
|
+
export function joinMeetingUrl(location) {
|
|
21
|
+
const trimmed = location?.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(trimmed);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
35
|
+
export function calendarReminderOf(event) {
|
|
36
|
+
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
39
|
+
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
40
|
+
export function snoozeDelayMs(startDate, now = Date.now()) {
|
|
41
|
+
return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
|
|
42
|
+
}
|
|
43
|
+
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
44
|
+
export function reminderMessage(startDate, now = Date.now()) {
|
|
45
|
+
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
46
|
+
const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60000);
|
|
47
|
+
if (diffMinutes <= 0) {
|
|
48
|
+
return `Starting now - ${time}`;
|
|
49
|
+
}
|
|
50
|
+
return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
|
|
51
|
+
}
|
|
52
|
+
/** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
|
|
53
|
+
export function reminderNotificationId(eventUid, startDate) {
|
|
54
|
+
return `calendar-reminder:${eventUid}:${startDate}`;
|
|
55
|
+
}
|