@rapidmx/web-client 0.15.1 → 0.16.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/README.md +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -164
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/calendar/index.tsx +578 -571
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2479
- package/apps/www/messages/[uid].tsx +135 -135
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -248
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/calendar/index.js +3 -2
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +426 -189
- package/package.json +2 -2
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useRef, useState } from "react";
|
|
6
|
-
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
-
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
-
|
|
9
|
-
/** Page size for the async-request lists (the server returns them newest first and caps `limit` at 500). */
|
|
10
|
-
export const REQUEST_LIST_PAGE_SIZE = 50;
|
|
11
|
-
|
|
12
|
-
export interface PagedList<T> {
|
|
13
|
-
items: T[];
|
|
14
|
-
/** `true` until the first page has loaded (or failed). */
|
|
15
|
-
loading: boolean;
|
|
16
|
-
loadError: string | null;
|
|
17
|
-
/** Whether the last page fetched was full, so another may follow. */
|
|
18
|
-
hasMore: boolean;
|
|
19
|
-
loadingMore: boolean;
|
|
20
|
-
/** Re-fetches from the first page, dropping anything loaded beyond it. */
|
|
21
|
-
reload: () => Promise<void>;
|
|
22
|
-
/** Fetches the next page and appends what's new in it. */
|
|
23
|
-
loadMore: () => Promise<void>;
|
|
24
|
-
/** Replaces one already-loaded item in place (e.g. with an action's response). */
|
|
25
|
-
replaceItem: (item: T) => void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* A newest-first list fetched a page at a time with "Load more". Only the most recently started `reload()`'s
|
|
30
|
-
* responses are applied, so a slow earlier fetch (e.g. the mount fetch racing a reload after a create) can't
|
|
31
|
-
* overwrite a newer list, and a "Load more" that was in flight during a reload is dropped.
|
|
32
|
-
*
|
|
33
|
-
* Paging is by offset, so "Load more" re-reads the previous page alongside the next one and continues after the last
|
|
34
|
-
* row already shown: rows created since shift older rows onto later pages (repeats are skipped), and rows deleted
|
|
35
|
-
* since shift them onto earlier pages (which a plain next-page fetch would silently skip). If that row can't be found
|
|
36
|
-
* in the two pages at all (it was deleted, or more than a page of rows came or went), the list reloads from the start.
|
|
37
|
-
*/
|
|
38
|
-
export function usePagedList<T extends { uid: string }>(
|
|
39
|
-
fetchPage: (params: { limit: number; page: number }) => Promise<T[]>,
|
|
40
|
-
errorMessage: string,
|
|
41
|
-
): PagedList<T> {
|
|
42
|
-
const pageSize = REQUEST_LIST_PAGE_SIZE;
|
|
43
|
-
const [items, setItems] = useState<T[]>([]);
|
|
44
|
-
const [loading, setLoading] = useState(true);
|
|
45
|
-
const [loadError, setLoadError] = useState<string | null>(null);
|
|
46
|
-
const [hasMore, setHasMore] = useState(false);
|
|
47
|
-
const [loadingMore, setLoadingMore] = useState(false);
|
|
48
|
-
const generation = useRef(0);
|
|
49
|
-
const nextPage = useRef(0);
|
|
50
|
-
const loaded = useRef<T[]>(items);
|
|
51
|
-
loaded.current = items;
|
|
52
|
-
|
|
53
|
-
function describe(err: unknown): string {
|
|
54
|
-
return err instanceof ApiRequestError ? err.message : errorMessage;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function reload() {
|
|
58
|
-
const current = ++generation.current;
|
|
59
|
-
try {
|
|
60
|
-
const data = await fetchPage({ limit: pageSize, page: 0 });
|
|
61
|
-
if (current !== generation.current) return;
|
|
62
|
-
setItems(data);
|
|
63
|
-
setHasMore(data.length === pageSize);
|
|
64
|
-
setLoadError(null);
|
|
65
|
-
nextPage.current = 1;
|
|
66
|
-
} catch (err) {
|
|
67
|
-
if (current === generation.current) setLoadError(describe(err));
|
|
68
|
-
} finally {
|
|
69
|
-
if (current === generation.current) setLoading(false);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function loadMore() {
|
|
74
|
-
const current = generation.current;
|
|
75
|
-
const page = nextPage.current;
|
|
76
|
-
setLoadingMore(true);
|
|
77
|
-
try {
|
|
78
|
-
const [previous, data] = await Promise.all([
|
|
79
|
-
fetchPage({ limit: pageSize, page: page - 1 }),
|
|
80
|
-
fetchPage({ limit: pageSize, page }),
|
|
81
|
-
]);
|
|
82
|
-
if (current !== generation.current) return;
|
|
83
|
-
const window: T[] = [...previous, ...data];
|
|
84
|
-
// "Load more" is only offered after a full page, so at least one row is shown.
|
|
85
|
-
const lastShown: string = loaded.current[loaded.current.length - 1].uid;
|
|
86
|
-
const boundary: number = window.map((item) => item.uid).lastIndexOf(lastShown);
|
|
87
|
-
if (boundary === -1) {
|
|
88
|
-
await reload();
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
const after: T[] = window.slice(boundary + 1);
|
|
92
|
-
setItems((prev) => [...prev, ...after.filter((item) => !prev.some((existing) => existing.uid === item.uid))]);
|
|
93
|
-
setHasMore(data.length === pageSize);
|
|
94
|
-
setLoadError(null);
|
|
95
|
-
nextPage.current = page + 1;
|
|
96
|
-
} catch (err) {
|
|
97
|
-
if (current === generation.current) setLoadError(describe(err));
|
|
98
|
-
} finally {
|
|
99
|
-
setLoadingMore(false);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function replaceItem(item: T) {
|
|
104
|
-
setItems((prev) => prev.map((existing) => (existing.uid === item.uid ? item : existing)));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return { items, loading, loadError, hasMore, loadingMore, reload, loadMore, replaceItem };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** The "Load more" button for a `usePagedList()` list - renders nothing once there's nothing more to load. */
|
|
111
|
-
export function LoadMoreButton({ list, label }: { list: Pick<PagedList<unknown>, "hasMore" | "loadingMore" | "loadMore">; label: string }) {
|
|
112
|
-
if (!list.hasMore) {
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
return (
|
|
116
|
-
<div className="mt-3">
|
|
117
|
-
<Button
|
|
118
|
-
type="button"
|
|
119
|
-
variant="secondary"
|
|
120
|
-
className="!w-auto"
|
|
121
|
-
loading={list.loadingMore}
|
|
122
|
-
disabled={list.loadingMore}
|
|
123
|
-
onClick={() => void list.loadMore()}
|
|
124
|
-
>
|
|
125
|
-
{label}
|
|
126
|
-
</Button>
|
|
127
|
-
</div>
|
|
128
|
-
);
|
|
129
|
-
}
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useRef, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
|
|
9
|
+
/** Page size for the async-request lists (the server returns them newest first and caps `limit` at 500). */
|
|
10
|
+
export const REQUEST_LIST_PAGE_SIZE = 50;
|
|
11
|
+
|
|
12
|
+
export interface PagedList<T> {
|
|
13
|
+
items: T[];
|
|
14
|
+
/** `true` until the first page has loaded (or failed). */
|
|
15
|
+
loading: boolean;
|
|
16
|
+
loadError: string | null;
|
|
17
|
+
/** Whether the last page fetched was full, so another may follow. */
|
|
18
|
+
hasMore: boolean;
|
|
19
|
+
loadingMore: boolean;
|
|
20
|
+
/** Re-fetches from the first page, dropping anything loaded beyond it. */
|
|
21
|
+
reload: () => Promise<void>;
|
|
22
|
+
/** Fetches the next page and appends what's new in it. */
|
|
23
|
+
loadMore: () => Promise<void>;
|
|
24
|
+
/** Replaces one already-loaded item in place (e.g. with an action's response). */
|
|
25
|
+
replaceItem: (item: T) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A newest-first list fetched a page at a time with "Load more". Only the most recently started `reload()`'s
|
|
30
|
+
* responses are applied, so a slow earlier fetch (e.g. the mount fetch racing a reload after a create) can't
|
|
31
|
+
* overwrite a newer list, and a "Load more" that was in flight during a reload is dropped.
|
|
32
|
+
*
|
|
33
|
+
* Paging is by offset, so "Load more" re-reads the previous page alongside the next one and continues after the last
|
|
34
|
+
* row already shown: rows created since shift older rows onto later pages (repeats are skipped), and rows deleted
|
|
35
|
+
* since shift them onto earlier pages (which a plain next-page fetch would silently skip). If that row can't be found
|
|
36
|
+
* in the two pages at all (it was deleted, or more than a page of rows came or went), the list reloads from the start.
|
|
37
|
+
*/
|
|
38
|
+
export function usePagedList<T extends { uid: string }>(
|
|
39
|
+
fetchPage: (params: { limit: number; page: number }) => Promise<T[]>,
|
|
40
|
+
errorMessage: string,
|
|
41
|
+
): PagedList<T> {
|
|
42
|
+
const pageSize = REQUEST_LIST_PAGE_SIZE;
|
|
43
|
+
const [items, setItems] = useState<T[]>([]);
|
|
44
|
+
const [loading, setLoading] = useState(true);
|
|
45
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
46
|
+
const [hasMore, setHasMore] = useState(false);
|
|
47
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
48
|
+
const generation = useRef(0);
|
|
49
|
+
const nextPage = useRef(0);
|
|
50
|
+
const loaded = useRef<T[]>(items);
|
|
51
|
+
loaded.current = items;
|
|
52
|
+
|
|
53
|
+
function describe(err: unknown): string {
|
|
54
|
+
return err instanceof ApiRequestError ? err.message : errorMessage;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function reload() {
|
|
58
|
+
const current = ++generation.current;
|
|
59
|
+
try {
|
|
60
|
+
const data = await fetchPage({ limit: pageSize, page: 0 });
|
|
61
|
+
if (current !== generation.current) return;
|
|
62
|
+
setItems(data);
|
|
63
|
+
setHasMore(data.length === pageSize);
|
|
64
|
+
setLoadError(null);
|
|
65
|
+
nextPage.current = 1;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (current === generation.current) setLoadError(describe(err));
|
|
68
|
+
} finally {
|
|
69
|
+
if (current === generation.current) setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function loadMore() {
|
|
74
|
+
const current = generation.current;
|
|
75
|
+
const page = nextPage.current;
|
|
76
|
+
setLoadingMore(true);
|
|
77
|
+
try {
|
|
78
|
+
const [previous, data] = await Promise.all([
|
|
79
|
+
fetchPage({ limit: pageSize, page: page - 1 }),
|
|
80
|
+
fetchPage({ limit: pageSize, page }),
|
|
81
|
+
]);
|
|
82
|
+
if (current !== generation.current) return;
|
|
83
|
+
const window: T[] = [...previous, ...data];
|
|
84
|
+
// "Load more" is only offered after a full page, so at least one row is shown.
|
|
85
|
+
const lastShown: string = loaded.current[loaded.current.length - 1].uid;
|
|
86
|
+
const boundary: number = window.map((item) => item.uid).lastIndexOf(lastShown);
|
|
87
|
+
if (boundary === -1) {
|
|
88
|
+
await reload();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const after: T[] = window.slice(boundary + 1);
|
|
92
|
+
setItems((prev) => [...prev, ...after.filter((item) => !prev.some((existing) => existing.uid === item.uid))]);
|
|
93
|
+
setHasMore(data.length === pageSize);
|
|
94
|
+
setLoadError(null);
|
|
95
|
+
nextPage.current = page + 1;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (current === generation.current) setLoadError(describe(err));
|
|
98
|
+
} finally {
|
|
99
|
+
setLoadingMore(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function replaceItem(item: T) {
|
|
104
|
+
setItems((prev) => prev.map((existing) => (existing.uid === item.uid ? item : existing)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { items, loading, loadError, hasMore, loadingMore, reload, loadMore, replaceItem };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The "Load more" button for a `usePagedList()` list - renders nothing once there's nothing more to load. */
|
|
111
|
+
export function LoadMoreButton({ list, label }: { list: Pick<PagedList<unknown>, "hasMore" | "loadingMore" | "loadMore">; label: string }) {
|
|
112
|
+
if (!list.hasMore) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return (
|
|
116
|
+
<div className="mt-3">
|
|
117
|
+
<Button
|
|
118
|
+
type="button"
|
|
119
|
+
variant="secondary"
|
|
120
|
+
className="!w-auto"
|
|
121
|
+
loading={list.loadingMore}
|
|
122
|
+
disabled={list.loadingMore}
|
|
123
|
+
onClick={() => void list.loadMore()}
|
|
124
|
+
>
|
|
125
|
+
{label}
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -201,6 +201,7 @@ export default function EventEditor({
|
|
|
201
201
|
timezone: occurrence?.timezone ?? deviceZone,
|
|
202
202
|
attendees: occurrence?.attendees ?? [],
|
|
203
203
|
guestDraft: "",
|
|
204
|
+
guestInvalid: [],
|
|
204
205
|
recurrenceRule: occurrence?.recurrenceRule ?? null,
|
|
205
206
|
reminderMinutes: occurrence?.reminderMinutesBeforeStart?.toString() ?? "",
|
|
206
207
|
busyStatus: occurrence?.busyStatus ?? "busy",
|
|
@@ -271,13 +272,8 @@ export default function EventEditor({
|
|
|
271
272
|
// "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
|
|
272
273
|
const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
|
|
273
274
|
|
|
274
|
-
dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || hasDescriptionText(values.descriptionHtml));
|
|
275
|
+
dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || values.guestInvalid.length > 0 || hasDescriptionText(values.descriptionHtml));
|
|
275
276
|
|
|
276
|
-
function addGuests(text: string): string[] {
|
|
277
|
-
const merged = mergeGuests(values.attendees, text);
|
|
278
|
-
update({ attendees: merged.attendees, guestDraft: merged.invalid.join(" ") });
|
|
279
|
-
return merged.invalid;
|
|
280
|
-
}
|
|
281
277
|
function updateAttendee(index: number, patch: Partial<Attendee>) {
|
|
282
278
|
setValues((prev) => ({ ...prev, attendees: prev.attendees.map((a, i) => (i === index ? { ...a, ...patch } : a)) }));
|
|
283
279
|
}
|
|
@@ -466,8 +462,8 @@ export default function EventEditor({
|
|
|
466
462
|
setError("A title is required.");
|
|
467
463
|
return;
|
|
468
464
|
}
|
|
469
|
-
// An address typed into the guests field but not added yet is added now - or, not being one, stops the save.
|
|
470
|
-
const merged = mergeGuests(values.attendees, values.guestDraft);
|
|
465
|
+
// An address typed into the guests field but not added yet is added now - or, not being one (nor what was already flagged there), stops the save.
|
|
466
|
+
const merged = mergeGuests(values.attendees, [...values.guestInvalid, values.guestDraft].join(", "), [effectiveOrganizerAddress]);
|
|
471
467
|
if (merged.invalid.length > 0) {
|
|
472
468
|
setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
|
|
473
469
|
return;
|
|
@@ -478,7 +474,7 @@ export default function EventEditor({
|
|
|
478
474
|
return;
|
|
479
475
|
}
|
|
480
476
|
if (values.guestDraft) {
|
|
481
|
-
update({ attendees: merged.attendees, guestDraft: "" });
|
|
477
|
+
update({ attendees: merged.attendees, guestDraft: "", guestInvalid: [] });
|
|
482
478
|
}
|
|
483
479
|
let startDate: string;
|
|
484
480
|
let endDate: string;
|
|
@@ -607,7 +603,6 @@ export default function EventEditor({
|
|
|
607
603
|
values.timezone,
|
|
608
604
|
),
|
|
609
605
|
editingSingleOccurrence,
|
|
610
|
-
addGuests,
|
|
611
606
|
updateAttendee,
|
|
612
607
|
removeAttendee,
|
|
613
608
|
addResource,
|
|
@@ -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 "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
8
8
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
9
|
+
import GuestInput from "./GuestInput.js";
|
|
9
10
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
10
11
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
11
12
|
import ResourcePicker from "./ResourcePicker.js";
|
|
@@ -103,63 +104,31 @@ export function VideoConferencingRow({ c }: { c: EventFormController }) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
* The guests field:
|
|
107
|
-
*
|
|
107
|
+
* The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
|
|
108
|
+
* turns what is picked, typed or pasted into chips - drawn in the field itself in the quick popover. In the full form the guests are rows with a
|
|
109
|
+
* role, the answer so far and a remove button instead (the field then draws only what is not an address), and rooms and equipment can be added.
|
|
108
110
|
*/
|
|
109
111
|
export function GuestsField({ c, compact }: { c: EventFormController; compact: boolean }) {
|
|
110
112
|
const { values } = c;
|
|
111
|
-
const [hint, setHint] = useState<string | null>(null);
|
|
112
113
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
113
114
|
const pickerAnchor = useRef<HTMLButtonElement>(null);
|
|
114
115
|
|
|
115
|
-
function commit() {
|
|
116
|
-
const invalid = c.addGuests(values.guestDraft);
|
|
117
|
-
setHint(invalid.length > 0 ? `“${invalid[0]}” isn’t a valid email address.` : null);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
116
|
return (
|
|
121
117
|
<div className="flex flex-col gap-2">
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
<GuestInput
|
|
119
|
+
id="event-guests"
|
|
120
|
+
label="Add guests"
|
|
125
121
|
placeholder="Add guests"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
e.preventDefault();
|
|
135
|
-
commit();
|
|
136
|
-
}
|
|
137
|
-
}}
|
|
122
|
+
variant={compact ? "quiet" : "box"}
|
|
123
|
+
listedElsewhere={!compact}
|
|
124
|
+
guests={values.attendees}
|
|
125
|
+
invalid={values.guestInvalid}
|
|
126
|
+
draft={values.guestDraft}
|
|
127
|
+
skipAddresses={[c.organizerAddress]}
|
|
128
|
+
mailboxUid={values.targetMailboxUid}
|
|
129
|
+
onChange={(next) => c.update({ attendees: next.guests, guestInvalid: next.invalid, guestDraft: next.draft })}
|
|
138
130
|
/>
|
|
139
|
-
{
|
|
140
|
-
<p role="alert" className="text-xs text-danger">
|
|
141
|
-
{hint}
|
|
142
|
-
</p>
|
|
143
|
-
)}
|
|
144
|
-
{compact ? (
|
|
145
|
-
values.attendees.length > 0 && (
|
|
146
|
-
<ul className="flex flex-wrap gap-1.5">
|
|
147
|
-
{values.attendees.map((attendee, i) => (
|
|
148
|
-
<li key={i} className="flex items-center gap-1 text-xs rounded-full bg-surface-alt pl-2.5 pr-1 py-0.5">
|
|
149
|
-
<span className="truncate max-w-[16rem]">{attendee.address}</span>
|
|
150
|
-
<button
|
|
151
|
-
type="button"
|
|
152
|
-
onClick={() => c.removeAttendee(i)}
|
|
153
|
-
className="w-4 h-4 flex items-center justify-center rounded-full text-text-muted hover:text-text"
|
|
154
|
-
aria-label={`Remove attendee ${i + 1}`}
|
|
155
|
-
>
|
|
156
|
-
<HiOutlineXMark size={12} aria-hidden="true" />
|
|
157
|
-
</button>
|
|
158
|
-
</li>
|
|
159
|
-
))}
|
|
160
|
-
</ul>
|
|
161
|
-
)
|
|
162
|
-
) : (
|
|
131
|
+
{compact ? null : (
|
|
163
132
|
<>
|
|
164
133
|
{values.attendees.length > 0 && (
|
|
165
134
|
<ul className="flex flex-col gap-2">
|