@rapidmx/web-client 0.3.1 → 0.5.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/audit-log/index.tsx +35 -8
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +118 -82
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +717 -513
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -314
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +270 -169
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
- package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +316 -0
- package/apps/shared/search/localIndexSchema.ts +236 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +266 -0
- package/apps/shared/search/localIndexWorker.ts +926 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +438 -407
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +148 -17
- package/apps/www/index.tsx +1314 -486
- package/apps/www/messages/[uid].tsx +23 -7
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +800 -215
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +274 -0
- package/apps/www/tasks/index.tsx +638 -530
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +17 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +154 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +100 -8
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +408 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
- package/dist/apps/shared/search/localIndexSchema.js +185 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +258 -0
- package/dist/apps/shared/search/localIndexWorker.js +687 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +38 -18
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +94 -15
- package/dist/apps/www/index.js +770 -137
- package/dist/apps/www/messages/[uid].js +23 -7
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +541 -96
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +124 -0
- package/dist/apps/www/tasks/index.js +76 -14
- package/package.json +3 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { deriveLocalIndexKey } from "./localIndexKey.js";
|
|
2
|
+
import { getLocalCoverage, initLocalIndex, searchLocal } from "./localIndexRpcClient.js";
|
|
3
|
+
/** Ensures this mailbox's local index connection is open before it's queried - idempotent
|
|
4
|
+
* (`localIndexWorker.ts`'s own `init()` no-ops for an already-open mailbox), so callers never need to
|
|
5
|
+
* coordinate with `LocalIndexLifecycle.tsx`'s own unlock-triggered `init()` call; whichever runs first
|
|
6
|
+
* wins, and the other is a cheap no-op. */
|
|
7
|
+
async function ensureInitialized(mailboxUid, unlocked) {
|
|
8
|
+
const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
|
|
9
|
+
await initLocalIndex({ mailboxUid, indexKey });
|
|
10
|
+
}
|
|
11
|
+
export async function searchLocalIndex(mailboxUid, parsed, unlocked, limit = 50, offset = 0) {
|
|
12
|
+
if (!unlocked) {
|
|
13
|
+
return { results: [], hasMore: false };
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
await ensureInitialized(mailboxUid, unlocked);
|
|
17
|
+
const [page, coverage] = await Promise.all([searchLocal(mailboxUid, parsed, limit, offset), getLocalCoverage(mailboxUid)]);
|
|
18
|
+
const results = page.hits.map((hit) => ({
|
|
19
|
+
entityType: "message",
|
|
20
|
+
entityUid: hit.entityUid,
|
|
21
|
+
// Negated: bm25()'s own convention is "more negative is a better match" (SQLite), the
|
|
22
|
+
// opposite of every other score this codebase merges (Postgres ts_rank, OpenSearch _score,
|
|
23
|
+
// and normalizeServerScores() itself all treat "higher is better"). Negating here, once,
|
|
24
|
+
// keeps that convention uniform for the caller (apps/www/index.tsx's merge layer) - it never
|
|
25
|
+
// needs to know this tier's underlying scoring function works backwards from the others.
|
|
26
|
+
score: -hit.score,
|
|
27
|
+
snippet: hit.snippet,
|
|
28
|
+
source: "local",
|
|
29
|
+
metadataOnly: false,
|
|
30
|
+
}));
|
|
31
|
+
return { results, coverage, hasMore: page.hasMore };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// See this function's own doc comment - a broken local index degrades to "nothing to contribute
|
|
35
|
+
// this time," never a rejected search.
|
|
36
|
+
return { results: [], hasMore: false };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -3,12 +3,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
8
8
|
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
9
|
-
import { addDays, addMonths, addWeeks, eachDayOfInterval, endOfDay, endOfMonth, endOfWeek, format, startOfDay, startOfMonth, startOfWeek, } from "date-fns";
|
|
9
|
+
import { addDays, addMonths, addWeeks, eachDayOfInterval, endOfDay, endOfMonth, endOfWeek, format, parseISO, startOfDay, startOfMonth, startOfWeek, } from "date-fns";
|
|
10
10
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
11
|
-
import { colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
|
|
12
11
|
import { listCalendarEvents } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
13
12
|
import { moveOccurrence, resizeOccurrenceEnd } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
14
13
|
import { resolveDragAction } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
|
|
@@ -17,6 +16,7 @@ import { expandAllOccurrences } from "@rapidmx/react-shared/calendar/recurrence.
|
|
|
17
16
|
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
18
17
|
import CalendarShell, { useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
|
|
19
18
|
import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
|
|
19
|
+
import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
|
|
20
20
|
import EventModal from "../../shared/components/calendar/EventModal.js";
|
|
21
21
|
import MiniDatePicker from "@rapidmx/react-shared/components/pickers/MiniDatePicker.js";
|
|
22
22
|
import MonthView from "../../shared/components/calendar/MonthView.js";
|
|
@@ -25,15 +25,12 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
|
25
25
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
26
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
27
27
|
export default function CalendarPage(props) {
|
|
28
|
-
return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, {}) }));
|
|
28
|
+
return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, { userUid: props.userUid }) }));
|
|
29
29
|
}
|
|
30
30
|
const VIEW_TYPES = ["month", "week", "workWeek", "day", "split"];
|
|
31
31
|
const VIEW_LABELS = { month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
|
|
32
|
-
function CalendarContent() {
|
|
33
|
-
const { mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders } = useCalendarShell();
|
|
34
|
-
// `CalendarShell` only ever renders this component once `mailboxUid` is set, and always to a value
|
|
35
|
-
// drawn from `mailboxes` itself (see its own resolution logic) — the lookup below always succeeds.
|
|
36
|
-
const organizerAddress = mailboxes.find((mb) => mb.uid === mailboxUid).primarySmtpAddress;
|
|
32
|
+
function CalendarContent({ userUid }) {
|
|
33
|
+
const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } = useCalendarShell();
|
|
37
34
|
// `PointerSensor` alone activates a drag on the very first touch-move, indistinguishable from a
|
|
38
35
|
// scroll gesture on a touch device. `MouseSensor` (a small `distance` — desktop drags still start
|
|
39
36
|
// immediately on a deliberate movement, no change from before) + `TouchSensor` (a `delay`+`tolerance`
|
|
@@ -59,13 +56,15 @@ function CalendarContent() {
|
|
|
59
56
|
setView(requestedView);
|
|
60
57
|
}
|
|
61
58
|
const requestedDate = params.get("date");
|
|
62
|
-
|
|
59
|
+
// `parseISO()`, not `new Date()` - `?date=2026-06-16` is a local calendar day, but `new Date()` reads a
|
|
60
|
+
// date-only string as UTC midnight, i.e. the previous day anywhere west of UTC.
|
|
61
|
+
const parsedDate = requestedDate ? parseISO(requestedDate) : null;
|
|
63
62
|
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
|
64
63
|
setViewDate(startOfDay(parsedDate));
|
|
65
64
|
}
|
|
66
65
|
}, []);
|
|
67
66
|
const checkedFolderUids = checkedFolderUidsState ?? new Set(calendarFolders.map((f) => f.uid));
|
|
68
|
-
const folderColors = useMemo(() => Object.fromEntries(calendarFolders.map((f) => [f.uid,
|
|
67
|
+
const folderColors = useMemo(() => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorFor(f)])), [calendarFolders, colorFor]);
|
|
69
68
|
function toggleCalendar(toggledUid) {
|
|
70
69
|
setCheckedFolderUidsState((prev) => {
|
|
71
70
|
const next = new Set(prev ?? calendarFolders.map((f) => f.uid));
|
|
@@ -78,9 +77,8 @@ function CalendarContent() {
|
|
|
78
77
|
return next;
|
|
79
78
|
});
|
|
80
79
|
}
|
|
81
|
-
async function handleAddCalendar(name, color) {
|
|
82
|
-
|
|
83
|
-
const created = await createFolder({ mailboxUid: mailboxUid, name, type: "calendar", color });
|
|
80
|
+
async function handleAddCalendar(targetMailboxUid, name, color) {
|
|
81
|
+
const created = await createFolder({ mailboxUid: targetMailboxUid, name, type: "calendar", color });
|
|
84
82
|
setCheckedFolderUidsState((prev) => new Set([...(prev ?? calendarFolders.map((f) => f.uid)), created.uid]));
|
|
85
83
|
reloadFolders();
|
|
86
84
|
}
|
|
@@ -105,7 +103,13 @@ function CalendarContent() {
|
|
|
105
103
|
const end = endOfDay(viewDate);
|
|
106
104
|
return { rangeStart: start, rangeEnd: end, days: [start] };
|
|
107
105
|
}, [view, viewDate]);
|
|
106
|
+
// Only the most recent reload() may apply its results - toggling calendars (or saving/dragging an event)
|
|
107
|
+
// while an earlier, slower fetch is still in flight must not let that stale response overwrite newer
|
|
108
|
+
// events. (No date range is passed: `listCalendarEvents()` deliberately fetches the whole calendar and
|
|
109
|
+
// filters client-side - see its own doc comment on the server-side range query it works around.)
|
|
110
|
+
const reloadSeqRef = useRef(0);
|
|
108
111
|
function reload() {
|
|
112
|
+
const seq = ++reloadSeqRef.current;
|
|
109
113
|
const targets = calendarFolders.filter((f) => checkedFolderUids.has(f.uid));
|
|
110
114
|
if (targets.length === 0) {
|
|
111
115
|
setEvents([]);
|
|
@@ -116,6 +120,9 @@ function CalendarContent() {
|
|
|
116
120
|
setError(null);
|
|
117
121
|
void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid)))
|
|
118
122
|
.then((results) => {
|
|
123
|
+
if (seq !== reloadSeqRef.current) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
119
126
|
const loaded = [];
|
|
120
127
|
const failures = [];
|
|
121
128
|
results.forEach((result, i) => {
|
|
@@ -142,7 +149,11 @@ function CalendarContent() {
|
|
|
142
149
|
setError(`Could not load events for: ${failures.map((f) => f.name).join(", ")}.`);
|
|
143
150
|
}
|
|
144
151
|
})
|
|
145
|
-
.finally(() =>
|
|
152
|
+
.finally(() => {
|
|
153
|
+
if (seq === reloadSeqRef.current) {
|
|
154
|
+
setLoading(false);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
146
157
|
}
|
|
147
158
|
// Keyed on stable string summaries (not the array/set objects themselves, which are new references
|
|
148
159
|
// every render) so this only re-fires when the actual set of folders to fetch changes.
|
|
@@ -152,7 +163,7 @@ function CalendarContent() {
|
|
|
152
163
|
const occurrences = useMemo(() => expandAllOccurrences(events, rangeStart, rangeEnd), [events, rangeStart, rangeEnd]);
|
|
153
164
|
const splitColumns = useMemo(() => calendarFolders
|
|
154
165
|
.filter((f) => checkedFolderUids.has(f.uid))
|
|
155
|
-
.map((f) => ({ folderUid: f.uid, name: f.name, color:
|
|
166
|
+
.map((f) => ({ folderUid: f.uid, name: f.name, color: colorFor(f) })), [calendarFolders, checkedFolderUidsKey, colorFor]);
|
|
156
167
|
function shiftView(direction) {
|
|
157
168
|
setViewDate((d) => view === "month"
|
|
158
169
|
? addMonths(d, direction)
|
|
@@ -217,12 +228,21 @@ function CalendarContent() {
|
|
|
217
228
|
: view === "week" || view === "workWeek"
|
|
218
229
|
? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
|
|
219
230
|
: format(viewDate, "EEEE, MMMM d, yyyy");
|
|
220
|
-
const
|
|
231
|
+
const modalFolderUid = modal ? (modal.targetFolderUid ?? modal.occurrence?.folderUid ?? folderUid) : undefined;
|
|
232
|
+
const modalMailboxUid = calendarFolders.find((f) => f.uid === modalFolderUid)?.mailboxUid ?? mailboxUid;
|
|
233
|
+
const modalMailbox = mailboxes.find((mb) => mb.uid === modalMailboxUid);
|
|
234
|
+
// Mailboxes a new event can be created in - only those with at least one calendar that the caller can
|
|
235
|
+
// write to (a view-only share is left out; see writableMailboxes.ts).
|
|
236
|
+
const writableMailboxes = useWritableMailboxes(mailboxes, userUid, modalMailboxUid);
|
|
237
|
+
const mailboxOptions = useMemo(() => mailboxCalendars
|
|
238
|
+
.filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
|
|
239
|
+
.map((mc) => ({ mailbox: mc.mailbox, calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })) })), [mailboxCalendars, writableMailboxes]);
|
|
240
|
+
const sidebarContent = (_jsxs(_Fragment, { children: [_jsx(MiniDatePicker, { selected: viewDate, onSelect: (date) => setViewDate(startOfDay(date)) }), _jsx(CalendarListSidebar, { mailboxCalendars: mailboxCalendars, checkedFolderUids: checkedFolderUids, onToggle: toggleCalendar, onAddCalendar: handleAddCalendar, colorFor: colorFor })] }));
|
|
221
241
|
return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx("div", { className: "hidden md:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto", children: sidebarContent }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Calendars", children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex items-center gap-3 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "md:hidden w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text shrink-0", "aria-label": "Open calendars", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(Button, { type: "button", onClick: () => openNewEvent(), className: "!w-auto shrink-0", children: "+ New event" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, className: "px-2 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "Today" }), _jsx("button", { type: "button", onClick: () => shiftView(1), "aria-label": "Next", className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u203A" })] }), _jsx("h1", { className: "hidden md:block text-lg font-bold tracking-tight", children: title }), _jsx("div", { className: "ml-auto flex gap-1 overflow-x-auto", children: VIEW_TYPES.map((v) => (_jsx("button", { type: "button", onClick: () => setView(v), className: [
|
|
222
242
|
"px-3 h-7 text-sm rounded-sm whitespace-nowrap",
|
|
223
243
|
view === v ? "bg-primary text-white" : "hover:bg-surface-alt",
|
|
224
244
|
(v === "workWeek" || v === "split") && "hidden md:inline-block",
|
|
225
245
|
]
|
|
226
246
|
.filter(Boolean)
|
|
227
|
-
.join(" "), children: VIEW_LABELS[v] }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), actionError && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: actionError }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: openNewEvent })) })), modal && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid:
|
|
247
|
+
.join(" "), children: VIEW_LABELS[v] }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), actionError && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: actionError }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: openNewEvent })) })), modal && modalMailbox && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: modalMailbox.uid, folderUid: modalFolderUid, calendars: calendarFolders.filter((f) => f.mailboxUid === modalMailbox.uid).map((f) => ({ uid: f.uid, name: f.name })), mailboxOptions: mailboxOptions, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, onSaved: handleSaved, onDeleted: handleDeleted }))] })] }));
|
|
228
248
|
}
|
|
@@ -10,6 +10,7 @@ import ContactsShell from "../../shared/components/contacts/layout/ContactsShell
|
|
|
10
10
|
import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
|
|
11
11
|
import ContactForm from "../../shared/components/contacts/ContactForm.js";
|
|
12
12
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
|
+
import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
|
|
13
14
|
/**
|
|
14
15
|
* Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/contacts/index.tsx` keeps its
|
|
15
16
|
* existing inline detail pane and never navigates here; see that file's `handleSelectRow`. There is no
|
|
@@ -23,6 +24,9 @@ function ContactDetailContent({ uid }) {
|
|
|
23
24
|
const [contact, setContact] = useState(null);
|
|
24
25
|
const [loading, setLoading] = useState(true);
|
|
25
26
|
const [error, setError] = useState(null);
|
|
27
|
+
// Kept apart from the load `error` - a failed delete must leave the contact on screen (with the
|
|
28
|
+
// reason) rather than replacing the whole page with an error as if it had never loaded.
|
|
29
|
+
const [deleteError, setDeleteError] = useState(null);
|
|
26
30
|
const [mode, setMode] = useState("view");
|
|
27
31
|
useEffect(() => {
|
|
28
32
|
setLoading(true);
|
|
@@ -37,14 +41,21 @@ function ContactDetailContent({ uid }) {
|
|
|
37
41
|
// rendered once `contact` is already resolved (see the `error || !contact` guard above it), so this
|
|
38
42
|
// never runs with a stale/absent contact — no redundant null check needed here.
|
|
39
43
|
async function handleDelete(contact) {
|
|
44
|
+
setDeleteError(null);
|
|
40
45
|
try {
|
|
41
46
|
await deleteContact(contact.uid, contact.version);
|
|
47
|
+
// A deleted contact's pinned signing keys must stop vouching for signatures.
|
|
48
|
+
clearPinnedSignerCache();
|
|
42
49
|
window.location.href = "/contacts";
|
|
43
50
|
}
|
|
44
51
|
catch (err) {
|
|
45
|
-
|
|
52
|
+
setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
|
|
46
53
|
}
|
|
47
54
|
}
|
|
55
|
+
// After a key change was resolved (or found stale): re-read the contact, keeping the one shown if that fails.
|
|
56
|
+
function handleKeysChanged() {
|
|
57
|
+
getContact(uid).then(setContact, () => undefined);
|
|
58
|
+
}
|
|
48
59
|
if (loading) {
|
|
49
60
|
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading\u2026" });
|
|
50
61
|
}
|
|
@@ -58,5 +69,5 @@ function ContactDetailContent({ uid }) {
|
|
|
58
69
|
setMode("view");
|
|
59
70
|
}, onCancel: () => setMode("view") })] }));
|
|
60
71
|
}
|
|
61
|
-
return (
|
|
72
|
+
return (_jsxs("div", { className: "p-6", children: [deleteError && _jsx(Alert, { children: deleteError }), _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged })] }));
|
|
62
73
|
}
|
|
@@ -15,25 +15,40 @@ import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js
|
|
|
15
15
|
import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
|
|
16
16
|
import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
|
|
17
17
|
import ContactForm from "../../shared/components/contacts/ContactForm.js";
|
|
18
|
+
import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
|
|
19
|
+
import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
18
20
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
21
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
22
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
23
|
+
import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
|
|
24
|
+
import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
|
|
19
25
|
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
20
26
|
export default function ContactsPage(props) {
|
|
21
|
-
return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, {}) }));
|
|
27
|
+
return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, { userUid: props.userUid }) }));
|
|
22
28
|
}
|
|
29
|
+
/** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
|
|
30
|
+
const DELETED_NOT_PERMITTED_MESSAGE = "You don't have permission to view deleted contacts in this folder.";
|
|
23
31
|
function primaryInfo(contact) {
|
|
24
32
|
return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
|
|
25
33
|
}
|
|
34
|
+
/** How long an export's object URL is kept alive after the click - some browsers are still reading the
|
|
35
|
+
* blob after `click()` returns, so revoking it synchronously can cancel the download. */
|
|
36
|
+
const VCARD_DOWNLOAD_URL_LIFETIME_MS = 60000;
|
|
26
37
|
function downloadTextFile(filename, content) {
|
|
27
38
|
const blob = new Blob([content], { type: "text/vcard" });
|
|
28
39
|
const url = URL.createObjectURL(blob);
|
|
29
40
|
const a = document.createElement("a");
|
|
30
41
|
a.href = url;
|
|
31
42
|
a.download = filename;
|
|
43
|
+
document.body.appendChild(a);
|
|
32
44
|
a.click();
|
|
33
|
-
|
|
45
|
+
a.remove();
|
|
46
|
+
setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
|
|
34
47
|
}
|
|
35
|
-
function ContactsContent() {
|
|
36
|
-
const { folderUid, mailboxUid } = useContactsShell();
|
|
48
|
+
function ContactsContent({ userUid }) {
|
|
49
|
+
const { folderUid, mailboxUid, mailboxes } = useContactsShell();
|
|
50
|
+
// The new-contact form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
|
|
51
|
+
const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
|
|
37
52
|
const { openCompose } = useCompose();
|
|
38
53
|
const isMobile = useIsMobile();
|
|
39
54
|
const [contacts, setContacts] = useState([]);
|
|
@@ -42,6 +57,9 @@ function ContactsContent() {
|
|
|
42
57
|
const [deletedError, setDeletedError] = useState(null);
|
|
43
58
|
const [loading, setLoading] = useState(true);
|
|
44
59
|
const [error, setError] = useState(null);
|
|
60
|
+
// Set when `listAllPages()` stopped at its page cap - the list shown isn't every contact.
|
|
61
|
+
const [truncated, setTruncated] = useState(false);
|
|
62
|
+
const [deletedTruncated, setDeletedTruncated] = useState(false);
|
|
45
63
|
const [query, setQuery] = useState("");
|
|
46
64
|
const [view, setView] = useState({ type: "all" });
|
|
47
65
|
const [sortColumn, setSortColumn] = useState("name");
|
|
@@ -49,6 +67,9 @@ function ContactsContent() {
|
|
|
49
67
|
const [checkedUids, setCheckedUids] = useState(new Set());
|
|
50
68
|
const [selectedUid, setSelectedUid] = useState(null);
|
|
51
69
|
const [mode, setMode] = useState("view");
|
|
70
|
+
// Set after a contact is created in a different mailbox than the one this list shows.
|
|
71
|
+
const [savedElsewhere, setSavedElsewhere] = useState(null);
|
|
72
|
+
const [confirmingBulkDelete, setConfirmingBulkDelete] = useState(false);
|
|
52
73
|
/** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
|
|
53
74
|
* own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
|
|
54
75
|
* which would otherwise silently wipe out a bulk action's own just-set failure message before the user
|
|
@@ -56,27 +77,69 @@ function ContactsContent() {
|
|
|
56
77
|
function reload() {
|
|
57
78
|
if (!folderUid) {
|
|
58
79
|
setContacts([]);
|
|
80
|
+
setTruncated(false);
|
|
59
81
|
setLoading(false);
|
|
60
82
|
return Promise.resolve();
|
|
61
83
|
}
|
|
62
84
|
setLoading(true);
|
|
63
85
|
setError(null);
|
|
64
|
-
return listContacts(folderUid, { limit:
|
|
65
|
-
.then(
|
|
86
|
+
return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
|
|
87
|
+
.then((result) => {
|
|
88
|
+
setContacts(result.items);
|
|
89
|
+
setTruncated(result.truncated);
|
|
90
|
+
})
|
|
66
91
|
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
|
|
67
92
|
.finally(() => setLoading(false));
|
|
68
93
|
}
|
|
69
94
|
useEffect(() => {
|
|
70
95
|
void reload();
|
|
71
96
|
}, [folderUid]);
|
|
97
|
+
// restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
|
|
98
|
+
// (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
|
|
99
|
+
// contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
|
|
100
|
+
// access to a folder, so the mailbox-level check below is only a first gate (an owner always passes; anyone else
|
|
101
|
+
// asks the server; unknown - still loading, or the check failed - hides the view), and the Deleted view's own
|
|
102
|
+
// load below detects the server ignoring the filter. `ContactsShell` only renders this with a resolved mailbox,
|
|
103
|
+
// and switching mailboxes is a full page load, so `mailboxUid` never changes under this component.
|
|
104
|
+
const ownsMailbox = mailboxes.some((mb) => mb.uid === mailboxUid && mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
|
|
105
|
+
const [delegateCanViewDeleted, setDelegateCanViewDeleted] = useState(false);
|
|
106
|
+
// Resolving a contact's key change needs update rights on the mailbox - `undefined` while unknown.
|
|
107
|
+
const [delegateCanUpdate, setDelegateCanUpdate] = useState(undefined);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (ownsMailbox) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
getMyMailboxAccess(mailboxUid).then((access) => {
|
|
114
|
+
if (!cancelled) {
|
|
115
|
+
setDelegateCanViewDeleted(access.canDelete && access.canUpdate);
|
|
116
|
+
setDelegateCanUpdate(access.canUpdate);
|
|
117
|
+
}
|
|
118
|
+
}, () => undefined);
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
}, [ownsMailbox]);
|
|
123
|
+
const canViewDeleted = ownsMailbox || delegateCanViewDeleted;
|
|
72
124
|
useEffect(() => {
|
|
73
125
|
if (view.type !== "deleted" || !folderUid) {
|
|
74
126
|
return;
|
|
75
127
|
}
|
|
76
128
|
setDeletedLoading(true);
|
|
77
129
|
setDeletedError(null);
|
|
78
|
-
listDeletedContacts(folderUid, { limit:
|
|
79
|
-
.then(
|
|
130
|
+
listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
|
|
131
|
+
.then((result) => {
|
|
132
|
+
// A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
|
|
133
|
+
// contacts, which must never be listed as deleted.
|
|
134
|
+
if (result.items.some((c) => c.deleted !== true)) {
|
|
135
|
+
setDeletedContacts([]);
|
|
136
|
+
setDeletedTruncated(false);
|
|
137
|
+
setDeletedError(DELETED_NOT_PERMITTED_MESSAGE);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setDeletedContacts(result.items);
|
|
141
|
+
setDeletedTruncated(result.truncated);
|
|
142
|
+
})
|
|
80
143
|
.catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
|
|
81
144
|
.finally(() => setDeletedLoading(false));
|
|
82
145
|
// Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
|
|
@@ -156,8 +219,15 @@ function ContactsContent() {
|
|
|
156
219
|
setMode("new");
|
|
157
220
|
}
|
|
158
221
|
function handleSaved(contact) {
|
|
159
|
-
setSelectedUid(contact.uid);
|
|
160
222
|
setMode("view");
|
|
223
|
+
if (contact.mailboxUid !== mailboxUid) {
|
|
224
|
+
// Created in another mailbox (via the form's Mailbox selector) - it won't be in this list.
|
|
225
|
+
setSelectedUid(null);
|
|
226
|
+
setSavedElsewhere({ mailboxUid: contact.mailboxUid, displayName: contact.displayName });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
setSavedElsewhere(null);
|
|
230
|
+
setSelectedUid(contact.uid);
|
|
161
231
|
void reload();
|
|
162
232
|
}
|
|
163
233
|
function handleCancel() {
|
|
@@ -166,6 +236,8 @@ function ContactsContent() {
|
|
|
166
236
|
async function handleDelete(contact) {
|
|
167
237
|
try {
|
|
168
238
|
await deleteContact(contact.uid, contact.version);
|
|
239
|
+
// Trusted signer pins come from contacts - a deleted contact's keys must stop vouching for signatures.
|
|
240
|
+
clearPinnedSignerCache();
|
|
169
241
|
setSelectedUid(null);
|
|
170
242
|
setMode("view");
|
|
171
243
|
void reload();
|
|
@@ -175,6 +247,7 @@ function ContactsContent() {
|
|
|
175
247
|
}
|
|
176
248
|
}
|
|
177
249
|
async function handleBulkDelete() {
|
|
250
|
+
setConfirmingBulkDelete(false);
|
|
178
251
|
let bulkError = null;
|
|
179
252
|
for (const contact of checkedContacts) {
|
|
180
253
|
try {
|
|
@@ -184,6 +257,7 @@ function ContactsContent() {
|
|
|
184
257
|
bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
|
|
185
258
|
}
|
|
186
259
|
}
|
|
260
|
+
clearPinnedSignerCache();
|
|
187
261
|
setCheckedUids(new Set());
|
|
188
262
|
await reload();
|
|
189
263
|
setError(bulkError);
|
|
@@ -198,9 +272,9 @@ function ContactsContent() {
|
|
|
198
272
|
}
|
|
199
273
|
function handleEmail() {
|
|
200
274
|
const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a) => Boolean(a));
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
openCompose({
|
|
275
|
+
// No mailboxUid: a fresh message defaults to the caller's own mailbox, not whichever mailbox's
|
|
276
|
+
// contacts happen to be open - the compose window's From field can still change it.
|
|
277
|
+
openCompose({ to: addresses.join(", ") });
|
|
204
278
|
}
|
|
205
279
|
async function handleToggleFavorite() {
|
|
206
280
|
const allFavorited = checkedContacts.every((c) => c.favorite);
|
|
@@ -213,6 +287,7 @@ function ContactsContent() {
|
|
|
213
287
|
bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
|
|
214
288
|
}
|
|
215
289
|
}
|
|
290
|
+
clearPinnedSignerCache();
|
|
216
291
|
await reload();
|
|
217
292
|
setError(bulkError);
|
|
218
293
|
}
|
|
@@ -225,12 +300,15 @@ function ContactsContent() {
|
|
|
225
300
|
for (const contact of checkedContacts) {
|
|
226
301
|
const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
|
|
227
302
|
try {
|
|
228
|
-
|
|
303
|
+
// Only the changed field - sending the whole fetched contact back includes server-managed
|
|
304
|
+
// fields restapi rejects, and would overwrite any concurrent edit to the other fields.
|
|
305
|
+
await updateContact({ uid: contact.uid, version: contact.version, categories });
|
|
229
306
|
}
|
|
230
307
|
catch (err) {
|
|
231
308
|
bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
|
|
232
309
|
}
|
|
233
310
|
}
|
|
311
|
+
clearPinnedSignerCache();
|
|
234
312
|
await reload();
|
|
235
313
|
setError(bulkError);
|
|
236
314
|
}
|
|
@@ -252,11 +330,12 @@ function ContactsContent() {
|
|
|
252
330
|
bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
|
|
253
331
|
}
|
|
254
332
|
}
|
|
333
|
+
clearPinnedSignerCache();
|
|
255
334
|
await reload();
|
|
256
335
|
setError(bulkError);
|
|
257
336
|
}
|
|
258
|
-
return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView }), _jsxs("div", { className: [
|
|
337
|
+
return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted }), _jsxs("div", { className: [
|
|
259
338
|
"w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
|
|
260
339
|
mode === "new" ? "hidden md:flex" : "flex",
|
|
261
|
-
].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete:
|
|
340
|
+
].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 overflow-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "px-3 py-2 text-text-muted truncate", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate })) : (_jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }))] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] })] }));
|
|
262
341
|
}
|