@remit/web-client 0.0.86 → 0.0.88
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/package.json +1 -1
- package/src/auth/BetterAuthShell.tsx +2 -2
- package/src/components/compose/AddressField.tsx +60 -77
- package/src/components/mail/AutoMovedIndicator.tsx +7 -9
- package/src/components/mail/BriefPane.tsx +4 -2
- package/src/components/mail/DailyBrief.selection.test.ts +49 -0
- package/src/components/mail/DailyBrief.tsx +484 -76
- package/src/components/mail/FlaggedList.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +157 -38
- package/src/components/mail/MailViewChrome.tsx +18 -1
- package/src/components/mail/MailboxPane.tsx +55 -20
- package/src/components/mail/MessageCard.tsx +0 -1
- package/src/components/mail/MessageList.tsx +70 -15
- package/src/components/mail/MessageListItem.tsx +4 -21
- package/src/components/mail/MessageRow.tsx +13 -31
- package/src/components/mail/SwipeableMessageRow.tsx +13 -6
- package/src/components/mail/ThreadListInteraction.tsx +51 -6
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
- package/src/components/mail/organize/SearchFilterDialog.tsx +5 -6
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useOrganizeWiden.ts +1 -2
- package/src/hooks/useRulePreview.ts +11 -1
- package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
- package/src/hooks/useSearchFilterSeed.ts +21 -3
- package/src/hooks/useSearchSuggestions.ts +126 -0
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/hooks/useSemanticSearch.ts +26 -7
- package/src/hooks/useThreadActions.ts +11 -2
- package/src/lib/brief.test.ts +67 -0
- package/src/lib/brief.ts +11 -1
- package/src/lib/bulk-actions.ts +29 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/organize/clause-suggestions.test.ts +113 -0
- package/src/lib/organize/clause-suggestions.ts +89 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +117 -38
- package/src/lib/organize/search-to-rule.test.ts +8 -28
- package/src/lib/organize/search-to-rule.ts +33 -89
- package/src/lib/organize/sender-fallback.test.ts +1 -97
- package/src/lib/organize/sender-fallback.ts +8 -71
- package/src/lib/search-result.ts +2 -0
- package/src/lib/search-suggestions.test.ts +249 -0
- package/src/lib/search-suggestions.ts +296 -0
- package/src/lib/search-token-index.test.ts +99 -0
- package/src/lib/search-token-index.ts +67 -1
- package/src/lib/search-tokens.test.ts +236 -0
- package/src/lib/search-tokens.ts +303 -36
- package/src/lib/thread-cache.ts +1 -1
- package/src/lib/thread-search-tokens.test.ts +193 -0
- package/src/lib/thread-search-tokens.ts +148 -0
- package/src/routes/onboarding.tsx +9 -4
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
FieldLabel,
|
|
35
35
|
Input,
|
|
36
36
|
Kbd,
|
|
37
|
+
PasswordInput,
|
|
37
38
|
Select,
|
|
38
39
|
ServerFields,
|
|
39
40
|
securityToApi,
|
|
@@ -766,9 +767,8 @@ function StepCredentials({
|
|
|
766
767
|
<FieldLabel htmlFor="credentials-password">
|
|
767
768
|
Password or app password
|
|
768
769
|
</FieldLabel>
|
|
769
|
-
<
|
|
770
|
+
<PasswordInput
|
|
770
771
|
id="credentials-password"
|
|
771
|
-
type="password"
|
|
772
772
|
value={localPassword}
|
|
773
773
|
onChange={(e) => {
|
|
774
774
|
setLocalPassword(e.target.value);
|
|
@@ -1033,6 +1033,13 @@ function StepTest({
|
|
|
1033
1033
|
);
|
|
1034
1034
|
}
|
|
1035
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* How long the sync step holds "Go to inbox" shut while it waits for the first
|
|
1038
|
+
* messages. Long enough for a normal IMAP handshake and first batch, short
|
|
1039
|
+
* enough that a stalled or silent sync never becomes a dead end.
|
|
1040
|
+
*/
|
|
1041
|
+
const INBOX_GATE_MAX_WAIT_MS = 20_000;
|
|
1042
|
+
|
|
1036
1043
|
function StepSync({
|
|
1037
1044
|
email,
|
|
1038
1045
|
displayName,
|
|
@@ -1121,7 +1128,7 @@ function StepSync({
|
|
|
1121
1128
|
// Poll sync status every 2s once account is created. Stop polling once the
|
|
1122
1129
|
// account reaches a terminal phase (complete/error) so we don't hammer the
|
|
1123
1130
|
// endpoint while the user lingers on this step (pattern from ComposeProvider).
|
|
1124
|
-
const { data: syncStatus } = useQuery({
|
|
1131
|
+
const { data: syncStatus, isError: syncStatusUnavailable } = useQuery({
|
|
1125
1132
|
...syncOperationsGetSyncStatusOptions({
|
|
1126
1133
|
path: { accountId: accountId ?? "" },
|
|
1127
1134
|
}),
|
|
@@ -1187,6 +1194,33 @@ function StepSync({
|
|
|
1187
1194
|
? "running"
|
|
1188
1195
|
: "ok";
|
|
1189
1196
|
|
|
1197
|
+
// The inbox has something to show once the first messages have landed, the
|
|
1198
|
+
// INBOX mailbox is drained, or the account's sync reached a terminal phase.
|
|
1199
|
+
// Until then "Go to inbox" leads to an empty list, so it does not offer (#452).
|
|
1200
|
+
const inboxHasArrived =
|
|
1201
|
+
inboxSynced > 0 ||
|
|
1202
|
+
inboxMailbox?.phase === "complete" ||
|
|
1203
|
+
syncPhase === "complete" ||
|
|
1204
|
+
syncErrored;
|
|
1205
|
+
|
|
1206
|
+
// Nothing above is allowed to strand the user: if the first messages have not
|
|
1207
|
+
// landed within the wait, or the status endpoint cannot answer at all, the
|
|
1208
|
+
// button opens regardless. The brief tells the truth about an empty list on
|
|
1209
|
+
// the other side, so an early exit costs a wait, not a lie.
|
|
1210
|
+
const [waitedLongEnough, setWaitedLongEnough] = useState(false);
|
|
1211
|
+
useEffect(() => {
|
|
1212
|
+
if (!accountId) return;
|
|
1213
|
+
const timer = setTimeout(
|
|
1214
|
+
() => setWaitedLongEnough(true),
|
|
1215
|
+
INBOX_GATE_MAX_WAIT_MS,
|
|
1216
|
+
);
|
|
1217
|
+
return () => clearTimeout(timer);
|
|
1218
|
+
}, [accountId]);
|
|
1219
|
+
|
|
1220
|
+
const canGoToInbox =
|
|
1221
|
+
!!accountId &&
|
|
1222
|
+
(inboxHasArrived || syncStatusUnavailable || waitedLongEnough);
|
|
1223
|
+
|
|
1190
1224
|
if (createError) {
|
|
1191
1225
|
return (
|
|
1192
1226
|
<WizardShell
|
|
@@ -1243,12 +1277,22 @@ function StepSync({
|
|
|
1243
1277
|
<span className="text-2xs text-fg-subtle">
|
|
1244
1278
|
Sync continues in the background
|
|
1245
1279
|
</span>
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1280
|
+
{canGoToInbox ? (
|
|
1281
|
+
<Button
|
|
1282
|
+
variant="primary"
|
|
1283
|
+
onClick={() => accountId && onGoToInbox(accountId)}
|
|
1284
|
+
>
|
|
1285
|
+
Go to inbox
|
|
1286
|
+
</Button>
|
|
1287
|
+
) : (
|
|
1288
|
+
<Button
|
|
1289
|
+
variant="primary"
|
|
1290
|
+
disabled
|
|
1291
|
+
icon={<Loader2 className="size-3.5 animate-spin" />}
|
|
1292
|
+
>
|
|
1293
|
+
Getting your mail…
|
|
1294
|
+
</Button>
|
|
1295
|
+
)}
|
|
1252
1296
|
</>
|
|
1253
1297
|
}
|
|
1254
1298
|
>
|
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
configOperationsGetConfigQueryKey,
|
|
7
7
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
8
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Button,
|
|
11
|
+
Input,
|
|
12
|
+
PasswordInput,
|
|
13
|
+
Select,
|
|
14
|
+
SlidePanel,
|
|
15
|
+
securityToApi,
|
|
16
|
+
} from "@remit/ui";
|
|
10
17
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
11
18
|
import { Check, Loader2, X } from "lucide-react";
|
|
12
19
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
@@ -580,12 +587,11 @@ export const AccountFormPanel = ({
|
|
|
580
587
|
>
|
|
581
588
|
Password
|
|
582
589
|
</label>
|
|
583
|
-
<
|
|
590
|
+
<PasswordInput
|
|
584
591
|
id="account-password"
|
|
585
592
|
{...form.register("password", {
|
|
586
593
|
onChange: () => setPasswordModified(true),
|
|
587
594
|
})}
|
|
588
|
-
type="password"
|
|
589
595
|
onFocus={() => {
|
|
590
596
|
// Clear placeholder when user focuses the field
|
|
591
597
|
if (isEditing && !passwordModified) {
|
|
@@ -862,10 +868,9 @@ export const AccountFormPanel = ({
|
|
|
862
868
|
>
|
|
863
869
|
SMTP Password
|
|
864
870
|
</label>
|
|
865
|
-
<
|
|
871
|
+
<PasswordInput
|
|
866
872
|
id="smtp-password"
|
|
867
873
|
{...form.register("smtpPassword")}
|
|
868
|
-
type="password"
|
|
869
874
|
/>
|
|
870
875
|
</div>
|
|
871
876
|
</>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bulk endpoints cap a call at 100 message ids, so the hooks that send a
|
|
3
|
+
* whole selection have to split it. Select-all over the brief's 200-result
|
|
4
|
+
* unscoped search is the reachable case: one call of 200 is a 400, and a
|
|
5
|
+
* mutation 4xx escalates to the fatal error overlay (#453).
|
|
6
|
+
*
|
|
7
|
+
* These mount the real hooks against the real fetch seam and read the requests
|
|
8
|
+
* that actually left, so the boundary is pinned where the call is made rather
|
|
9
|
+
* than at whichever surface assembled the selection.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
14
|
+
import { createElement } from "react";
|
|
15
|
+
import { BULK_ACTION_CHUNK_SIZE } from "../lib/bulk-actions";
|
|
16
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
17
|
+
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
18
|
+
import { useApplyLabel } from "./useApplyLabel";
|
|
19
|
+
import { useDeleteMessages } from "./useDeleteMessages";
|
|
20
|
+
import { useToggleReadFor } from "./useMarkAsRead";
|
|
21
|
+
import { useMoveMessages } from "./useMoveMessages";
|
|
22
|
+
|
|
23
|
+
const INBOX = "mbx-inbox";
|
|
24
|
+
const ARCHIVE = "mbx-archive";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock;
|
|
28
|
+
|
|
29
|
+
const ids = (count: number): string[] =>
|
|
30
|
+
Array.from({ length: count }, (_, i) => `msg-${i}`);
|
|
31
|
+
|
|
32
|
+
const mountHook = <T>(useHook: () => T): (() => T) => {
|
|
33
|
+
let value: T | undefined;
|
|
34
|
+
const Probe = () => {
|
|
35
|
+
value = useHook();
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
harness = createDomHarness();
|
|
39
|
+
harness.renderApp(createElement(Probe));
|
|
40
|
+
return () => {
|
|
41
|
+
if (value === undefined) throw new Error("hook did not render");
|
|
42
|
+
return value;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Enough turns for a run of sequential chunks to finish. */
|
|
47
|
+
const settle = async (): Promise<void> => {
|
|
48
|
+
if (!harness) throw new Error("nothing mounted");
|
|
49
|
+
for (let round = 0; round < 8; round += 1) await harness.flush();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The message-id list of every request that left, in the order they left. */
|
|
53
|
+
const sentBatches = (): string[][] =>
|
|
54
|
+
http.calls.map(
|
|
55
|
+
(call) => (call.body as { messageIds?: string[] })?.messageIds ?? [],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const sentSizes = (): number[] => sentBatches().map((batch) => batch.length);
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
http = mockFetch(() => ({}));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
harness?.close();
|
|
66
|
+
harness = undefined;
|
|
67
|
+
http.restore();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("a bulk selection at the server's cap", () => {
|
|
71
|
+
it("sends exactly 100 ids as one call", async () => {
|
|
72
|
+
const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
|
|
73
|
+
|
|
74
|
+
hook().deleteMessages(ids(BULK_ACTION_CHUNK_SIZE));
|
|
75
|
+
await settle();
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(sentSizes(), [BULK_ACTION_CHUNK_SIZE]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("splits 101 ids rather than sending a call the server refuses", async () => {
|
|
81
|
+
const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
|
|
82
|
+
|
|
83
|
+
hook().deleteMessages(ids(BULK_ACTION_CHUNK_SIZE + 1));
|
|
84
|
+
await settle();
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(sentSizes(), [BULK_ACTION_CHUNK_SIZE, 1]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("splits a 200-row select-all into two full calls, every id once", async () => {
|
|
90
|
+
const selection = ids(BULK_ACTION_CHUNK_SIZE * 2);
|
|
91
|
+
const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
|
|
92
|
+
|
|
93
|
+
hook().deleteMessages(selection);
|
|
94
|
+
await settle();
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(sentSizes(), [
|
|
97
|
+
BULK_ACTION_CHUNK_SIZE,
|
|
98
|
+
BULK_ACTION_CHUNK_SIZE,
|
|
99
|
+
]);
|
|
100
|
+
assert.deepEqual(sentBatches().flat(), selection);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("stops at the first refused chunk instead of sending the rest", async () => {
|
|
104
|
+
http.restore();
|
|
105
|
+
http = mockFetch(() => httpError(409, "mailbox is locked"));
|
|
106
|
+
const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
|
|
107
|
+
|
|
108
|
+
hook().deleteMessages(ids(BULK_ACTION_CHUNK_SIZE * 3));
|
|
109
|
+
await settle();
|
|
110
|
+
|
|
111
|
+
assert.deepEqual(sentSizes(), [BULK_ACTION_CHUNK_SIZE]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("the other verbs a selection reaches", () => {
|
|
116
|
+
it("moves a 200-row selection in chunks, each carrying the destination", async () => {
|
|
117
|
+
const hook = mountHook(() => useMoveMessages({ mailboxId: INBOX }));
|
|
118
|
+
|
|
119
|
+
hook().moveMessages(ids(BULK_ACTION_CHUNK_SIZE * 2), ARCHIVE);
|
|
120
|
+
await settle();
|
|
121
|
+
|
|
122
|
+
assert.deepEqual(sentSizes(), [
|
|
123
|
+
BULK_ACTION_CHUNK_SIZE,
|
|
124
|
+
BULK_ACTION_CHUNK_SIZE,
|
|
125
|
+
]);
|
|
126
|
+
for (const call of http.calls) {
|
|
127
|
+
assert.equal(
|
|
128
|
+
(call.body as { destinationMailboxId?: string })?.destinationMailboxId,
|
|
129
|
+
ARCHIVE,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("marks 101 messages read in chunks, each carrying the flag", async () => {
|
|
135
|
+
const hook = mountHook(() => useToggleReadFor({ mailboxId: INBOX }));
|
|
136
|
+
|
|
137
|
+
hook().toggleReadFor(ids(BULK_ACTION_CHUNK_SIZE + 1), true);
|
|
138
|
+
await settle();
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(sentSizes(), [BULK_ACTION_CHUNK_SIZE, 1]);
|
|
141
|
+
for (const call of http.calls) {
|
|
142
|
+
assert.equal((call.body as { isRead?: boolean })?.isRead, true);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("labels 101 messages in chunks, each carrying the label", async () => {
|
|
147
|
+
const hook = mountHook(() => useApplyLabel({ mailboxId: INBOX }));
|
|
148
|
+
|
|
149
|
+
hook().applyLabel(ids(BULK_ACTION_CHUNK_SIZE + 1), "lbl-1", "Apply");
|
|
150
|
+
await settle();
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(sentSizes(), [BULK_ACTION_CHUNK_SIZE, 1]);
|
|
153
|
+
for (const call of http.calls) {
|
|
154
|
+
assert.equal((call.body as { labelId?: string })?.labelId, "lbl-1");
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -8,6 +8,7 @@ import { useCallback } from "react";
|
|
|
8
8
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
9
9
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
10
10
|
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
11
|
+
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
11
12
|
import {
|
|
12
13
|
invalidateThreadListQueries,
|
|
13
14
|
threadListCacheKeys,
|
|
@@ -30,7 +31,7 @@ export const useApplyLabel = (options: {
|
|
|
30
31
|
const queryClient = useQueryClient();
|
|
31
32
|
const { pushError } = useErrorBanners();
|
|
32
33
|
|
|
33
|
-
const {
|
|
34
|
+
const { mutateAsync, isPending } = useMutation({
|
|
34
35
|
...messageBulkOperationsUpdateMessageLabelsMutation(),
|
|
35
36
|
onError: (error, variables) => {
|
|
36
37
|
pushError({
|
|
@@ -59,9 +60,11 @@ export const useApplyLabel = (options: {
|
|
|
59
60
|
const applyLabel = useCallback(
|
|
60
61
|
(messageIds: string[], labelId: string, action: RemitImapLabelAction) => {
|
|
61
62
|
if (messageIds.length === 0) return;
|
|
62
|
-
|
|
63
|
+
void runChunkedMutation(messageIds, (chunk) =>
|
|
64
|
+
mutateAsync({ body: { messageIds: chunk, labelId, action } }),
|
|
65
|
+
);
|
|
63
66
|
},
|
|
64
|
-
[
|
|
67
|
+
[mutateAsync],
|
|
65
68
|
);
|
|
66
69
|
|
|
67
70
|
return { applyLabel, isPending };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { ClauseField, Suggestion } from "@remit/ui";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import {
|
|
5
|
+
buildClauseSuggestions,
|
|
6
|
+
CLAUSE_SUGGESTION_LIMIT,
|
|
7
|
+
fieldTakesAddressSuggestions,
|
|
8
|
+
type KnownAddress,
|
|
9
|
+
} from "@/lib/organize/clause-suggestions";
|
|
10
|
+
import { useDebouncedValue } from "./useDebouncedValue";
|
|
11
|
+
|
|
12
|
+
/** How long typing settles before the address lookup fires. */
|
|
13
|
+
export const CLAUSE_SUGGESTION_DEBOUNCE_MS = 250;
|
|
14
|
+
|
|
15
|
+
/** The shortest query worth a round-trip. */
|
|
16
|
+
const MIN_QUERY_LENGTH = 2;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Values to offer for the clause being edited: the senders of the messages the
|
|
20
|
+
* user selected first — already in hand, and the likeliest answer — then the
|
|
21
|
+
* known addresses a lookup turns up for what has been typed.
|
|
22
|
+
*
|
|
23
|
+
* The lookup is the address search the compose recipient field already uses, on
|
|
24
|
+
* a debounce so a request does not fire per keystroke. The list narrows against
|
|
25
|
+
* the live value while a request is still out, so it never lags what is on
|
|
26
|
+
* screen; nothing it returns can change what the user typed.
|
|
27
|
+
*/
|
|
28
|
+
export const useClauseSuggestions = (
|
|
29
|
+
field: ClauseField | undefined,
|
|
30
|
+
value: string,
|
|
31
|
+
selectionSenders: readonly string[],
|
|
32
|
+
): Suggestion[] => {
|
|
33
|
+
const query = value.trim();
|
|
34
|
+
const debouncedQuery = useDebouncedValue(
|
|
35
|
+
query,
|
|
36
|
+
CLAUSE_SUGGESTION_DEBOUNCE_MS,
|
|
37
|
+
);
|
|
38
|
+
const offersAddresses =
|
|
39
|
+
field !== undefined && fieldTakesAddressSuggestions(field);
|
|
40
|
+
|
|
41
|
+
const { data } = useQuery({
|
|
42
|
+
...addressOperationsSearchAddressesOptions({
|
|
43
|
+
query: { q: debouncedQuery, limit: CLAUSE_SUGGESTION_LIMIT },
|
|
44
|
+
}),
|
|
45
|
+
enabled: offersAddresses && debouncedQuery.length >= MIN_QUERY_LENGTH,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!offersAddresses || field === undefined) return [];
|
|
49
|
+
const addresses: KnownAddress[] = [
|
|
50
|
+
...selectionSenders.map((email) => ({ email, fromSelection: true })),
|
|
51
|
+
...(data?.items ?? []).map((address) => ({
|
|
52
|
+
email: address.normalizedEmail,
|
|
53
|
+
...(address.displayName ? { displayName: address.displayName } : {}),
|
|
54
|
+
})),
|
|
55
|
+
];
|
|
56
|
+
return buildClauseSuggestions(field, query, addresses);
|
|
57
|
+
};
|
|
@@ -9,6 +9,7 @@ import { useCallback } from "react";
|
|
|
9
9
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
10
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
11
11
|
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
12
|
+
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
12
13
|
import {
|
|
13
14
|
cancelThreadListQueries,
|
|
14
15
|
invalidateThreadListQueries,
|
|
@@ -91,7 +92,7 @@ export const useDeleteMessages = ({
|
|
|
91
92
|
const queryClient = useQueryClient();
|
|
92
93
|
const { pushError } = useErrorBanners();
|
|
93
94
|
|
|
94
|
-
const {
|
|
95
|
+
const { mutateAsync, isPending } = useMutation({
|
|
95
96
|
...messageBulkOperationsDeleteMessagesMutation(),
|
|
96
97
|
onMutate: async (variables): Promise<DeleteContext> => {
|
|
97
98
|
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
@@ -195,9 +196,11 @@ export const useDeleteMessages = ({
|
|
|
195
196
|
const deleteMessages = useCallback(
|
|
196
197
|
(messageIds: string[]) => {
|
|
197
198
|
if (messageIds.length === 0) return;
|
|
198
|
-
|
|
199
|
+
void runChunkedMutation(messageIds, (chunk) =>
|
|
200
|
+
mutateAsync({ body: { messageIds: chunk } }),
|
|
201
|
+
);
|
|
199
202
|
},
|
|
200
|
-
[
|
|
203
|
+
[mutateAsync],
|
|
201
204
|
);
|
|
202
205
|
|
|
203
206
|
return { deleteMessages, isPending };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reading pane following the keyboard cursor.
|
|
3
|
+
*
|
|
4
|
+
* Three properties keep it from being a request per keystroke or a surprise
|
|
5
|
+
* open: a held key coalesces into one load for the row it stops on, following is
|
|
6
|
+
* off while a selection is being built, and a move made while it was off is
|
|
7
|
+
* spent rather than deferred — so turning following back on opens nothing until
|
|
8
|
+
* the cursor moves again.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import { createElement } from "react";
|
|
14
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
15
|
+
import {
|
|
16
|
+
FOLLOW_FOCUS_DELAY_MS,
|
|
17
|
+
useFollowFocusOpen,
|
|
18
|
+
} from "./useFollowFocusOpen";
|
|
19
|
+
|
|
20
|
+
let harness: DomHarness | undefined;
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
harness?.close();
|
|
24
|
+
harness = undefined;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const DELAY = 20;
|
|
28
|
+
|
|
29
|
+
interface ProbeProps {
|
|
30
|
+
keyboardFocusedMessageId: string | undefined;
|
|
31
|
+
openMessageId: string | undefined;
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
open: (messageId: string) => void;
|
|
34
|
+
delayMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Probe(props: ProbeProps) {
|
|
38
|
+
useFollowFocusOpen(props);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Driver {
|
|
43
|
+
/** Re-render with a new cursor state, as a keypress would. */
|
|
44
|
+
move: (props: Partial<ProbeProps>) => void;
|
|
45
|
+
/** Let the settle timer fire — twice over, so a missed load still shows. */
|
|
46
|
+
settle: () => Promise<void>;
|
|
47
|
+
wait: (ms: number) => Promise<void>;
|
|
48
|
+
opened: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const mount = (initial: Partial<ProbeProps> = {}, delayMs = DELAY): Driver => {
|
|
52
|
+
const opened: string[] = [];
|
|
53
|
+
let props: ProbeProps = {
|
|
54
|
+
keyboardFocusedMessageId: undefined,
|
|
55
|
+
openMessageId: undefined,
|
|
56
|
+
enabled: true,
|
|
57
|
+
open: (messageId) => opened.push(messageId),
|
|
58
|
+
delayMs,
|
|
59
|
+
...initial,
|
|
60
|
+
};
|
|
61
|
+
const dom = createDomHarness();
|
|
62
|
+
harness = dom;
|
|
63
|
+
const paint = () => dom.render(createElement(Probe, props));
|
|
64
|
+
paint();
|
|
65
|
+
return {
|
|
66
|
+
opened,
|
|
67
|
+
move: (next) => {
|
|
68
|
+
props = { ...props, ...next };
|
|
69
|
+
paint();
|
|
70
|
+
},
|
|
71
|
+
settle: () => dom.wait(delayMs * 2),
|
|
72
|
+
wait: (ms) => dom.wait(ms),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("useFollowFocusOpen", () => {
|
|
77
|
+
it("opens the row the cursor lands on", async () => {
|
|
78
|
+
const driver = mount();
|
|
79
|
+
driver.move({ keyboardFocusedMessageId: "m1" });
|
|
80
|
+
await driver.settle();
|
|
81
|
+
assert.deepEqual(driver.opened, ["m1"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("opens nothing until the cursor has settled", async () => {
|
|
85
|
+
const driver = mount();
|
|
86
|
+
driver.move({ keyboardFocusedMessageId: "m1" });
|
|
87
|
+
assert.deepEqual(driver.opened, []);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("coalesces a held key into one load, for the row it stops on", async () => {
|
|
91
|
+
const driver = mount();
|
|
92
|
+
for (const id of ["m1", "m2", "m3", "m4"])
|
|
93
|
+
driver.move({ keyboardFocusedMessageId: id });
|
|
94
|
+
await driver.settle();
|
|
95
|
+
assert.deepEqual(driver.opened, ["m4"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not re-arm on a re-render that changes only the opener's identity", async () => {
|
|
99
|
+
// A long delay so the re-render lands mid-flight with room to spare: a
|
|
100
|
+
// re-arm would push the load out past the total wait below, which is what
|
|
101
|
+
// permanently defers it while a list re-renders.
|
|
102
|
+
const driver = mount({}, 100);
|
|
103
|
+
driver.move({ keyboardFocusedMessageId: "m1" });
|
|
104
|
+
await driver.wait(60);
|
|
105
|
+
// A list passes an inline opener, so every render hands in a new function.
|
|
106
|
+
driver.move({ open: (messageId) => driver.opened.push(messageId) });
|
|
107
|
+
await driver.wait(60);
|
|
108
|
+
assert.deepEqual(driver.opened, ["m1"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not reload the row already in the reading pane", async () => {
|
|
112
|
+
const driver = mount({ openMessageId: "m1" });
|
|
113
|
+
driver.move({ keyboardFocusedMessageId: "m1" });
|
|
114
|
+
await driver.settle();
|
|
115
|
+
assert.deepEqual(driver.opened, []);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("opens nothing while rows are selected", async () => {
|
|
119
|
+
const driver = mount({ enabled: false });
|
|
120
|
+
driver.move({ keyboardFocusedMessageId: "m1" });
|
|
121
|
+
await driver.settle();
|
|
122
|
+
assert.deepEqual(driver.opened, []);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("spends the move made while following was off rather than deferring it", async () => {
|
|
126
|
+
const driver = mount({ enabled: false });
|
|
127
|
+
driver.move({ keyboardFocusedMessageId: "m1" });
|
|
128
|
+
await driver.settle();
|
|
129
|
+
|
|
130
|
+
// Clearing the selection must not load whatever row the cursor happens to
|
|
131
|
+
// be sitting on.
|
|
132
|
+
driver.move({ enabled: true });
|
|
133
|
+
await driver.settle();
|
|
134
|
+
assert.deepEqual(driver.opened, []);
|
|
135
|
+
|
|
136
|
+
// The next move is a fresh one and does load.
|
|
137
|
+
driver.move({ keyboardFocusedMessageId: "m2" });
|
|
138
|
+
await driver.settle();
|
|
139
|
+
assert.deepEqual(driver.opened, ["m2"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("opens nothing when the cursor moved some way other than the keyboard", async () => {
|
|
143
|
+
const driver = mount();
|
|
144
|
+
driver.move({ keyboardFocusedMessageId: undefined });
|
|
145
|
+
await driver.settle();
|
|
146
|
+
assert.deepEqual(driver.opened, []);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("FOLLOW_FOCUS_DELAY_MS", () => {
|
|
151
|
+
it("sits above key repeat and below what reads as a wait", () => {
|
|
152
|
+
assert.ok(FOLLOW_FOCUS_DELAY_MS >= 60);
|
|
153
|
+
assert.ok(FOLLOW_FOCUS_DELAY_MS <= 250);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFollowFocusOpen — the reading pane follows the keyboard cursor.
|
|
3
|
+
*
|
|
4
|
+
* Moving the cursor with j/k or the arrows loads the row it lands on, the way
|
|
5
|
+
* a desktop mail client does. Enter and a plain click keep opening exactly as
|
|
6
|
+
* they did; only a keyboard move drives this, so a click never opens twice and
|
|
7
|
+
* Tab walking into the list opens nothing.
|
|
8
|
+
*
|
|
9
|
+
* Two properties make it safe to run on every cursor move:
|
|
10
|
+
*
|
|
11
|
+
* - The load waits for the cursor to settle. Key repeat delivers a keydown
|
|
12
|
+
* every few tens of milliseconds, so holding j down costs one request for the
|
|
13
|
+
* row it stops on rather than one per row it passes.
|
|
14
|
+
* - Nothing here marks anything read. Read state is set by the dwell in
|
|
15
|
+
* `useMarkAsRead` — three seconds on the open message — which a fly-past
|
|
16
|
+
* never reaches and a deliberate open reaches unchanged.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useRef } from "react";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long the cursor must sit still before its row loads. Comfortably above
|
|
22
|
+
* the ~30ms of a repeating key, so a held key coalesces into a single load,
|
|
23
|
+
* and short enough that a single press reads as immediate.
|
|
24
|
+
*/
|
|
25
|
+
export const FOLLOW_FOCUS_DELAY_MS = 120;
|
|
26
|
+
|
|
27
|
+
interface UseFollowFocusOpenOptions {
|
|
28
|
+
/**
|
|
29
|
+
* The row a keyboard command last moved the cursor onto, and `undefined`
|
|
30
|
+
* whenever the cursor last moved some other way — a click, a row taking DOM
|
|
31
|
+
* focus, a thread opening, the list reshuffling under a refetch.
|
|
32
|
+
*/
|
|
33
|
+
keyboardFocusedMessageId: string | undefined;
|
|
34
|
+
/** The row already in the reading pane, so an open row does not reload. */
|
|
35
|
+
openMessageId: string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Whether the cursor may open rows at all. Desktop-only: on the single-pane
|
|
38
|
+
* tiers the reading pane replaces the list, so following the cursor would
|
|
39
|
+
* throw the user off the list they are scanning. Also off while rows are
|
|
40
|
+
* selected, where the cursor is building a selection rather than reading.
|
|
41
|
+
*/
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
/** Opens a row in the reading pane. */
|
|
44
|
+
open: (messageId: string) => void;
|
|
45
|
+
delayMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const useFollowFocusOpen = ({
|
|
49
|
+
keyboardFocusedMessageId,
|
|
50
|
+
openMessageId,
|
|
51
|
+
enabled,
|
|
52
|
+
open,
|
|
53
|
+
delayMs = FOLLOW_FOCUS_DELAY_MS,
|
|
54
|
+
}: UseFollowFocusOpenOptions): void => {
|
|
55
|
+
// Lists pass an inline opener, so its identity changes every render. Holding
|
|
56
|
+
// it in a ref keeps a re-render from re-arming — and therefore permanently
|
|
57
|
+
// deferring — the pending load.
|
|
58
|
+
const openRef = useRef(open);
|
|
59
|
+
openRef.current = open;
|
|
60
|
+
|
|
61
|
+
// The last cursor move made while following was off. Such a move is spent,
|
|
62
|
+
// not deferred: clearing a selection must not load whatever row the cursor
|
|
63
|
+
// happens to be sitting on, only the next move should.
|
|
64
|
+
const declinedRef = useRef<string | undefined>(undefined);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const messageId = keyboardFocusedMessageId;
|
|
68
|
+
if (!enabled) {
|
|
69
|
+
declinedRef.current = messageId;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (messageId === undefined) return;
|
|
73
|
+
if (messageId === openMessageId) return;
|
|
74
|
+
if (messageId === declinedRef.current) {
|
|
75
|
+
declinedRef.current = undefined;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const timer = setTimeout(() => openRef.current(messageId), delayMs);
|
|
79
|
+
return () => clearTimeout(timer);
|
|
80
|
+
}, [enabled, keyboardFocusedMessageId, openMessageId, delayMs]);
|
|
81
|
+
};
|