@remit/web-client 0.0.85 → 0.0.87
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 +155 -34
- 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 +3 -1
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
- 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/components/settings/TlsRootCaDownload.render.test.ts +37 -0
- package/src/components/settings/TlsRootCaDownload.tsx +33 -0
- 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/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 +90 -0
- package/src/lib/organize/property-prefill.test.ts +173 -0
- package/src/lib/organize/property-prefill.ts +151 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +110 -31
- package/src/lib/organize/search-to-rule.ts +12 -4
- package/src/lib/organize/sender-fallback.ts +11 -1
- 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
- package/src/routes/settings/advanced.tsx +2 -0
- package/src/runtime-config.test.ts +26 -0
- package/src/runtime-config.ts +15 -0
|
@@ -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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-render smoke test for `TlsRootCaDownload`: it must render only when
|
|
3
|
+
* this deployment's runtime config reports TLS_MODE=internal, since every
|
|
4
|
+
* other mode either has no local root CA (acme, tailscale) or serves plain
|
|
5
|
+
* HTTP (off).
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, describe, it } from "node:test";
|
|
9
|
+
import React, { createElement } from "react";
|
|
10
|
+
import { renderToString } from "react-dom/server";
|
|
11
|
+
import { TlsRootCaDownload } from "./TlsRootCaDownload";
|
|
12
|
+
|
|
13
|
+
// See MessageToolbar.render.test.ts: the SSR test loader transpiles remit-ui
|
|
14
|
+
// `.tsx` (ButtonLink, here) with the classic JSX runtime, which needs a
|
|
15
|
+
// global `React`.
|
|
16
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
globalThis.__REMIT_CONFIG__ = undefined;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("TlsRootCaDownload", () => {
|
|
23
|
+
it("renders the download row when tlsMode is internal", () => {
|
|
24
|
+
globalThis.__REMIT_CONFIG__ = { tlsMode: "internal" };
|
|
25
|
+
const html = renderToString(createElement(TlsRootCaDownload) as never);
|
|
26
|
+
assert.match(html, /TLS root certificate/);
|
|
27
|
+
assert.match(html, /href="\/tls-root-ca\.crt"/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
for (const mode of ["acme", "tailscale", "off", undefined]) {
|
|
31
|
+
it(`renders nothing when tlsMode is ${mode ?? "unset (default off)"}`, () => {
|
|
32
|
+
globalThis.__REMIT_CONFIG__ = mode ? { tlsMode: mode } : {};
|
|
33
|
+
const html = renderToString(createElement(TlsRootCaDownload) as never);
|
|
34
|
+
assert.equal(html, "");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings > Advanced, shown only for TLS_MODE=internal (deploy/vps/README.md,
|
|
3
|
+
* "TLS", has the rationale). Downloads the same root CA `remit cert` exports,
|
|
4
|
+
* served by deploy/vps/caddy/internal.caddy — reachable in-app instead of
|
|
5
|
+
* over SSH.
|
|
6
|
+
*/
|
|
7
|
+
import { ButtonLink } from "@remit/ui";
|
|
8
|
+
import { ShieldCheck } from "lucide-react";
|
|
9
|
+
import { getRuntimeConfig } from "@/runtime-config";
|
|
10
|
+
|
|
11
|
+
export function TlsRootCaDownload() {
|
|
12
|
+
if (getRuntimeConfig().tlsMode !== "internal") return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="border-t border-line pt-4 mt-4">
|
|
16
|
+
<p className="text-sm font-medium text-fg mb-1">TLS root certificate</p>
|
|
17
|
+
<p className="text-sm text-fg-muted mb-2">
|
|
18
|
+
This deployment signs its own certificate. Import it into the trust
|
|
19
|
+
store of each device you browse from, and the browser warning stops for
|
|
20
|
+
good.
|
|
21
|
+
</p>
|
|
22
|
+
<ButtonLink
|
|
23
|
+
href="/tls-root-ca.crt"
|
|
24
|
+
download
|
|
25
|
+
variant="secondary"
|
|
26
|
+
size="sm"
|
|
27
|
+
icon={<ShieldCheck className="size-3.5" />}
|
|
28
|
+
>
|
|
29
|
+
Download root certificate
|
|
30
|
+
</ButtonLink>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -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
|
+
};
|