@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
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import { Button, Dialog } from "@remit/ui";
|
|
1
|
+
import { Button, buildSearchRule, Dialog } from "@remit/ui";
|
|
2
2
|
import { Loader2 } from "lucide-react";
|
|
3
3
|
import { useMemo } from "react";
|
|
4
4
|
import { useSearchFilterSeed } from "@/hooks/useSearchFilterSeed";
|
|
5
5
|
import { rulePredicate } from "@/lib/organize/rule-model";
|
|
6
|
-
import {
|
|
7
|
-
buildSearchRule,
|
|
8
|
-
convertSearchToRule,
|
|
9
|
-
} from "@/lib/organize/search-to-rule";
|
|
6
|
+
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
10
7
|
import type { ParsedSearchQuery } from "@/lib/search-tokens";
|
|
11
8
|
import { SearchFilterEditor } from "./SearchFilterEditor";
|
|
12
9
|
|
|
@@ -51,6 +48,8 @@ export function SearchFilterDialog({
|
|
|
51
48
|
open ? accountId : undefined,
|
|
52
49
|
literalPredicate,
|
|
53
50
|
);
|
|
51
|
+
// A search kept as a `HasWords` clause has no seed count and never will; the
|
|
52
|
+
// editor opens on the uncountable reason rather than a dead end.
|
|
54
53
|
|
|
55
54
|
if (!open) return null;
|
|
56
55
|
|
|
@@ -71,7 +70,7 @@ export function SearchFilterDialog({
|
|
|
71
70
|
</Button>
|
|
72
71
|
</div>
|
|
73
72
|
</div>
|
|
74
|
-
) : isPending
|
|
73
|
+
) : isPending ? (
|
|
75
74
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
76
75
|
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
77
76
|
<p className="text-sm font-medium text-fg">
|
|
@@ -120,10 +120,21 @@ describe("SearchFilterEditor — pre-filled conversion", () => {
|
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it("seeds the live count from the converted predicate", () => {
|
|
123
|
-
const dom = mount("receipts");
|
|
123
|
+
const dom = mount("from:receipts@stripe.com");
|
|
124
124
|
assert.match(dom.text(), /12 messages match/);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it("never asks the matcher to count a body-text rule it cannot evaluate", async () => {
|
|
128
|
+
// A free-text search converts to a `HasWords` clause and carries no anchor,
|
|
129
|
+
// so the vector-free matcher rejects it outright (organize.ts
|
|
130
|
+
// `assertNoBodyContentClause`). The count is unavailable and says so; the
|
|
131
|
+
// request is never sent.
|
|
132
|
+
const dom = mount("receipts");
|
|
133
|
+
await settlePreview(dom);
|
|
134
|
+
assert.match(dom.text(), /can't count matches/i);
|
|
135
|
+
assert.equal((http?.to("/organize/preview") ?? []).length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
127
138
|
it("does not offer the semantic widen on a search-derived rule", () => {
|
|
128
139
|
const dom = mount("receipts");
|
|
129
140
|
assert.doesNotMatch(dom.text(), /and similar/i);
|
|
@@ -186,7 +197,7 @@ describe("SearchFilterEditor — commit gate", () => {
|
|
|
186
197
|
it("stales the count on an edit and holds the save until it settles", async () => {
|
|
187
198
|
// The seed count (12) is passed directly; the first network preview is the
|
|
188
199
|
// re-count after the edit, so it returns 40.
|
|
189
|
-
const dom = mount("receipts", previewCounts([40]));
|
|
200
|
+
const dom = mount("from:receipts@stripe.com", previewCounts([40]));
|
|
190
201
|
dom.select(dom.byLabel("Destination folder"), "mbx-archive");
|
|
191
202
|
await dom.flush();
|
|
192
203
|
dom.type(dom.byLabel("Rule name"), "Receipts");
|
|
@@ -208,7 +219,7 @@ describe("SearchFilterEditor — commit gate", () => {
|
|
|
208
219
|
});
|
|
209
220
|
|
|
210
221
|
it("runs a one-time apply as a back-apply job when the scope is 'just once'", async () => {
|
|
211
|
-
const dom = mount("receipts");
|
|
222
|
+
const dom = mount("from:receipts@stripe.com");
|
|
212
223
|
dom.select(dom.byLabel("Destination folder"), "mbx-archive");
|
|
213
224
|
await dom.flush();
|
|
214
225
|
const radio = dom.query('input[name="rule-scope"][value="once"]');
|
|
@@ -223,4 +234,27 @@ describe("SearchFilterEditor — commit gate", () => {
|
|
|
223
234
|
);
|
|
224
235
|
assert.equal(jobs.length, 1);
|
|
225
236
|
});
|
|
237
|
+
|
|
238
|
+
it("holds the one-time apply for a body-text rule, offering the saved rule instead", async () => {
|
|
239
|
+
// The one-time apply runs the same vector-free matcher the count does. A
|
|
240
|
+
// `HasWords` clause has no reader there, so the apply is held with the
|
|
241
|
+
// reason stated — saved as a standing rule the same clause works, because
|
|
242
|
+
// the index-time matcher reads the whole body.
|
|
243
|
+
const dom = mount("receipts");
|
|
244
|
+
dom.select(dom.byLabel("Destination folder"), "mbx-archive");
|
|
245
|
+
await dom.flush();
|
|
246
|
+
const radio = dom.query('input[name="rule-scope"][value="once"]');
|
|
247
|
+
if (!radio) throw new Error("no once scope option");
|
|
248
|
+
dom.click(radio);
|
|
249
|
+
await dom.flush();
|
|
250
|
+
|
|
251
|
+
assert.equal(primaryButton(dom, "Apply now").disabled, true);
|
|
252
|
+
assert.match(dom.text(), /can't read message bodies/i);
|
|
253
|
+
assert.equal(
|
|
254
|
+
(http?.calls ?? []).filter(
|
|
255
|
+
(call) => call.path.endsWith("/organize") && call.method === "POST",
|
|
256
|
+
).length,
|
|
257
|
+
0,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
226
260
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
2
|
import {
|
|
3
|
+
buildSearchRule,
|
|
3
4
|
type FilterRule,
|
|
4
5
|
FilterRuleEditor,
|
|
5
6
|
type FolderOption,
|
|
7
|
+
type SearchConversion,
|
|
6
8
|
SearchConversionNoticeView,
|
|
7
9
|
} from "@remit/ui";
|
|
8
10
|
import { useQuery } from "@tanstack/react-query";
|
|
@@ -18,10 +20,6 @@ import {
|
|
|
18
20
|
ruleToDraft,
|
|
19
21
|
SUPPORTED_CLAUSE_FIELDS,
|
|
20
22
|
} from "@/lib/organize/rule-model";
|
|
21
|
-
import {
|
|
22
|
-
buildSearchRule,
|
|
23
|
-
type SearchConversion,
|
|
24
|
-
} from "@/lib/organize/search-to-rule";
|
|
25
23
|
import {
|
|
26
24
|
CommitError,
|
|
27
25
|
FilterSaved,
|
|
@@ -34,8 +32,14 @@ interface SearchFilterEditorProps {
|
|
|
34
32
|
accountId: string;
|
|
35
33
|
/** The converted search — its clauses seed the rule, its drops seed the notice. */
|
|
36
34
|
conversion: SearchConversion;
|
|
37
|
-
/**
|
|
38
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The converted literal predicate's live count, seeding the editor without a
|
|
37
|
+
* re-fetch. Absent when the predicate is one the vector-free matcher refuses —
|
|
38
|
+
* a `HasWords` clause from the search's free text — in which case the count
|
|
39
|
+
* region says so and the one-time apply is held; saving it as a standing rule
|
|
40
|
+
* still works, since the index-time matcher does read message bodies.
|
|
41
|
+
*/
|
|
42
|
+
seedCount?: number;
|
|
39
43
|
onClose: () => void;
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
3
|
+
threadOperationsListThreadsQueryKey,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
5
|
import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
6
|
import { BottomSheet } from "@remit/ui";
|
|
4
7
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
@@ -57,9 +60,14 @@ const FOLDER_OPTIONS: FolderOption[] = [
|
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
62
|
* A QueryClient pre-seeded with the account's mailboxes, so the editor's folder
|
|
60
|
-
* picker renders its real options without a network round-trip
|
|
63
|
+
* picker renders its real options without a network round-trip, and optionally
|
|
64
|
+
* with the thread rows the properties prefill reads its subjects off — the same
|
|
65
|
+
* rows the mailbox list renders behind the sheet.
|
|
61
66
|
*/
|
|
62
|
-
function seededClient(
|
|
67
|
+
function seededClient(
|
|
68
|
+
subjects?: Record<string, string>,
|
|
69
|
+
senderEmail = "receipts@example.com",
|
|
70
|
+
): QueryClient {
|
|
63
71
|
const client = new QueryClient({
|
|
64
72
|
defaultOptions: { queries: { retry: false } },
|
|
65
73
|
});
|
|
@@ -67,13 +75,32 @@ function seededClient(): QueryClient {
|
|
|
67
75
|
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
68
76
|
{ items: MAILBOXES },
|
|
69
77
|
);
|
|
78
|
+
if (subjects) {
|
|
79
|
+
client.setQueryData(
|
|
80
|
+
threadOperationsListThreadsQueryKey({ path: { mailboxId: "mbx-inbox" } }),
|
|
81
|
+
{
|
|
82
|
+
items: Object.entries(subjects).map(([messageId, subject]) => ({
|
|
83
|
+
messageId,
|
|
84
|
+
threadId: messageId,
|
|
85
|
+
subject,
|
|
86
|
+
fromEmail: senderEmail,
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
}
|
|
70
91
|
return client;
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
/** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
|
|
74
|
-
function SheetStage({
|
|
95
|
+
function SheetStage({
|
|
96
|
+
children,
|
|
97
|
+
subjects,
|
|
98
|
+
}: {
|
|
99
|
+
children: ReactNode;
|
|
100
|
+
subjects?: Record<string, string>;
|
|
101
|
+
}) {
|
|
75
102
|
return (
|
|
76
|
-
<QueryClientProvider client={seededClient()}>
|
|
103
|
+
<QueryClientProvider client={seededClient(subjects)}>
|
|
77
104
|
<ErrorBannerProvider>
|
|
78
105
|
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
79
106
|
<div className="divide-y divide-line opacity-50">
|
|
@@ -247,6 +274,60 @@ export const SenderFallbackStanding: Story = {
|
|
|
247
274
|
),
|
|
248
275
|
};
|
|
249
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Matching on properties instead of similarity — the third way to build a rule,
|
|
279
|
+
* with no semantics in it at all. Every selected message came from one sender,
|
|
280
|
+
* so the rule opens on a single editable `From` chip. The mode control stays on
|
|
281
|
+
* screen: "Anything similar" is one tap away and is still where the flow opens.
|
|
282
|
+
*/
|
|
283
|
+
export const MatchOnPropertiesSender: Story = {
|
|
284
|
+
name: "Match on Properties (sender)",
|
|
285
|
+
render: () => (
|
|
286
|
+
<SheetStage>
|
|
287
|
+
<OrganizeRuleEditor
|
|
288
|
+
accountId={ACCOUNT_ID}
|
|
289
|
+
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
290
|
+
seedCount={47}
|
|
291
|
+
seedMatchMode="properties"
|
|
292
|
+
senders={["receipts@example.com"]}
|
|
293
|
+
onClose={() => undefined}
|
|
294
|
+
/>
|
|
295
|
+
</SheetStage>
|
|
296
|
+
),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The same mode where the selection's senders differ: there is no one sender to
|
|
301
|
+
* name, so the prefill drops to the part the subjects share — "Your receipt
|
|
302
|
+
* from" out of three differently-numbered receipts — as an ordinary `Subject`
|
|
303
|
+
* chip the user can widen or narrow.
|
|
304
|
+
*/
|
|
305
|
+
export const MatchOnPropertiesSubject: Story = {
|
|
306
|
+
name: "Match on Properties (subject)",
|
|
307
|
+
render: () => (
|
|
308
|
+
<SheetStage
|
|
309
|
+
subjects={{
|
|
310
|
+
"msg-1": "Your receipt from Blue Bottle #4821",
|
|
311
|
+
"msg-2": "Re: Your receipt from Ritual Coffee #119",
|
|
312
|
+
"msg-3": "Your receipt from Sightglass #77",
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<OrganizeRuleEditor
|
|
316
|
+
accountId={ACCOUNT_ID}
|
|
317
|
+
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
318
|
+
seedCount={62}
|
|
319
|
+
seedMatchMode="properties"
|
|
320
|
+
senders={[
|
|
321
|
+
"receipts@bluebottle.example",
|
|
322
|
+
"hello@ritual.example",
|
|
323
|
+
"no-reply@sightglass.example",
|
|
324
|
+
]}
|
|
325
|
+
onClose={() => undefined}
|
|
326
|
+
/>
|
|
327
|
+
</SheetStage>
|
|
328
|
+
),
|
|
329
|
+
};
|
|
330
|
+
|
|
250
331
|
/** The "Something else" fallback: shortcuts derived from folders + an input. */
|
|
251
332
|
export const SomethingElse: Story = {
|
|
252
333
|
render: () => (
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modifier-click selection on a message row.
|
|
3
|
+
*
|
|
4
|
+
* The rule under test is that a modifier is an input signal, not a layout one:
|
|
5
|
+
* shift, cmd and ctrl select a row at every window width, because they can only
|
|
6
|
+
* come from a real keyboard. Gating them on the desktop media query is what left
|
|
7
|
+
* a half-screen browser or a tablet opening the message instead of selecting it.
|
|
8
|
+
*
|
|
9
|
+
* A row is an `<a href>`, so what "selection took the press" means concretely is
|
|
10
|
+
* that the browser's own gesture — new window, new tab, context menu — is
|
|
11
|
+
* prevented, and the click the same gesture delivers is claimed rather than
|
|
12
|
+
* opening the row on top of the selection.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import { createElement, type MouseEvent } from "react";
|
|
18
|
+
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
19
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
20
|
+
import { isModified, useModifierSelect } from "./useModifierSelect";
|
|
21
|
+
|
|
22
|
+
let harness: DomHarness | undefined;
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
harness?.close();
|
|
26
|
+
harness = undefined;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface Log {
|
|
30
|
+
/** Every (messageId, modifiers) selection was offered. */
|
|
31
|
+
offers: SelectionModifiers[];
|
|
32
|
+
/** Clicks that reached the row's open handler. */
|
|
33
|
+
opens: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ProbeProps {
|
|
37
|
+
log: Log;
|
|
38
|
+
/** Whether selection takes the press it is offered. */
|
|
39
|
+
takes: boolean;
|
|
40
|
+
/** A row with no selection at all — the reading pane's, for instance. */
|
|
41
|
+
unselectable?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ROW = "[data-row]";
|
|
45
|
+
|
|
46
|
+
function Probe({ log, takes, unselectable }: ProbeProps) {
|
|
47
|
+
const select = useModifierSelect(
|
|
48
|
+
"m1",
|
|
49
|
+
unselectable
|
|
50
|
+
? undefined
|
|
51
|
+
: (_messageId, modifiers) => {
|
|
52
|
+
log.offers.push(modifiers);
|
|
53
|
+
return takes;
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
return createElement(
|
|
57
|
+
"a",
|
|
58
|
+
{
|
|
59
|
+
// A fragment rather than a route: still an `<a href>`, which is what
|
|
60
|
+
// makes a modified click a browser gesture, without jsdom trying to
|
|
61
|
+
// navigate on the clicks the test deliberately leaves unclaimed.
|
|
62
|
+
href: "#m1",
|
|
63
|
+
"data-row": "",
|
|
64
|
+
onMouseDown: select.onMouseDown,
|
|
65
|
+
onContextMenu: select.onContextMenu,
|
|
66
|
+
onClick: (e: MouseEvent) => {
|
|
67
|
+
if (select.claimClick(e)) return;
|
|
68
|
+
log.opens += 1;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"Quarterly numbers are in",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mount = (props: ProbeProps, viewportWidth: number): DomHarness => {
|
|
76
|
+
harness = createDomHarness({ viewportWidth });
|
|
77
|
+
harness.render(createElement(Probe, props));
|
|
78
|
+
return harness;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const emptyLog = (): Log => ({ offers: [], opens: 0 });
|
|
82
|
+
|
|
83
|
+
/** Dispatches a real mouse event and answers whether the default was taken. */
|
|
84
|
+
const press = (
|
|
85
|
+
dom: DomHarness,
|
|
86
|
+
type: "mousedown" | "click" | "contextmenu",
|
|
87
|
+
init: MouseEventInit = {},
|
|
88
|
+
): boolean => {
|
|
89
|
+
const event = new dom.window.MouseEvent(type, {
|
|
90
|
+
bubbles: true,
|
|
91
|
+
cancelable: true,
|
|
92
|
+
button: 0,
|
|
93
|
+
...init,
|
|
94
|
+
});
|
|
95
|
+
dom.dispatch(dom.query(ROW) as Element, event);
|
|
96
|
+
return event.defaultPrevented;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The full gesture a browser delivers: the press, then the click after it. */
|
|
100
|
+
const gesture = (
|
|
101
|
+
dom: DomHarness,
|
|
102
|
+
init: MouseEventInit,
|
|
103
|
+
): { pressTaken: boolean; clickTaken: boolean } => ({
|
|
104
|
+
pressTaken: press(dom, "mousedown", init),
|
|
105
|
+
clickTaken: press(dom, "click", init),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Both single-pane tiers and the multi-pane one. The narrow widths are the
|
|
109
|
+
// regression: they used to fall through to the touch branch, which reads no
|
|
110
|
+
// modifiers at all.
|
|
111
|
+
const WIDTHS = [390, 800, 1280];
|
|
112
|
+
|
|
113
|
+
describe("useModifierSelect", () => {
|
|
114
|
+
for (const width of WIDTHS) {
|
|
115
|
+
it(`takes a shift-press for selection at ${width}px`, () => {
|
|
116
|
+
const log = emptyLog();
|
|
117
|
+
const dom = mount({ log, takes: true }, width);
|
|
118
|
+
|
|
119
|
+
const { pressTaken, clickTaken } = gesture(dom, { shiftKey: true });
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(log.offers, [
|
|
122
|
+
{ shiftKey: true, metaKey: false, ctrlKey: false },
|
|
123
|
+
]);
|
|
124
|
+
assert.equal(pressTaken, true, "the browser's new-window gesture is off");
|
|
125
|
+
assert.equal(clickTaken, true, "the click the gesture delivers is spent");
|
|
126
|
+
assert.equal(
|
|
127
|
+
log.opens,
|
|
128
|
+
0,
|
|
129
|
+
"the row does not open on top of the selection",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it(`takes a cmd-press for selection at ${width}px`, () => {
|
|
134
|
+
const log = emptyLog();
|
|
135
|
+
const dom = mount({ log, takes: true }, width);
|
|
136
|
+
|
|
137
|
+
const { pressTaken } = gesture(dom, { metaKey: true });
|
|
138
|
+
|
|
139
|
+
assert.deepEqual(log.offers, [
|
|
140
|
+
{ shiftKey: false, metaKey: true, ctrlKey: false },
|
|
141
|
+
]);
|
|
142
|
+
assert.equal(pressTaken, true);
|
|
143
|
+
assert.equal(log.opens, 0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it(`leaves a plain click alone at ${width}px`, () => {
|
|
147
|
+
const log = emptyLog();
|
|
148
|
+
const dom = mount({ log, takes: true }, width);
|
|
149
|
+
|
|
150
|
+
const { pressTaken, clickTaken } = gesture(dom, {});
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(log.offers, [], "a tap carries no modifier to read");
|
|
153
|
+
assert.equal(pressTaken, false);
|
|
154
|
+
assert.equal(clickTaken, false);
|
|
155
|
+
assert.equal(log.opens, 1, "the row opens, as it always did");
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it("claims a modified click on its own, for engines that deliver no usable press", () => {
|
|
160
|
+
const log = emptyLog();
|
|
161
|
+
const dom = mount({ log, takes: true }, 390);
|
|
162
|
+
|
|
163
|
+
const clickTaken = press(dom, "click", { metaKey: true });
|
|
164
|
+
|
|
165
|
+
assert.equal(clickTaken, true);
|
|
166
|
+
assert.equal(log.opens, 0);
|
|
167
|
+
assert.deepEqual(log.offers, [
|
|
168
|
+
{ shiftKey: false, metaKey: true, ctrlKey: false },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("leaves the browser's gesture standing when selection declines the press", () => {
|
|
173
|
+
const log = emptyLog();
|
|
174
|
+
const dom = mount({ log, takes: false }, 1280);
|
|
175
|
+
|
|
176
|
+
const { pressTaken, clickTaken } = gesture(dom, { metaKey: true });
|
|
177
|
+
|
|
178
|
+
assert.equal(pressTaken, false, "cmd-click still opens a new tab");
|
|
179
|
+
assert.equal(clickTaken, false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reads no modifier on a row that cannot be selected", () => {
|
|
183
|
+
const log = emptyLog();
|
|
184
|
+
const dom = mount({ log, takes: true, unselectable: true }, 1280);
|
|
185
|
+
|
|
186
|
+
const { pressTaken, clickTaken } = gesture(dom, { shiftKey: true });
|
|
187
|
+
|
|
188
|
+
assert.equal(pressTaken, false);
|
|
189
|
+
assert.equal(clickTaken, false);
|
|
190
|
+
assert.deepEqual(log.offers, []);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("ignores a press from any button but the primary one", () => {
|
|
194
|
+
const log = emptyLog();
|
|
195
|
+
const dom = mount({ log, takes: true }, 1280);
|
|
196
|
+
|
|
197
|
+
const pressTaken = press(dom, "mousedown", { button: 2, ctrlKey: true });
|
|
198
|
+
|
|
199
|
+
assert.equal(pressTaken, false, "a right-click keeps its context menu");
|
|
200
|
+
assert.deepEqual(log.offers, []);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("suppresses the context menu a ctrl-press already spent on selection", () => {
|
|
204
|
+
const log = emptyLog();
|
|
205
|
+
const dom = mount({ log, takes: true }, 1280);
|
|
206
|
+
|
|
207
|
+
assert.equal(press(dom, "contextmenu", { ctrlKey: true }), true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("leaves a plain right-click its context menu", () => {
|
|
211
|
+
const log = emptyLog();
|
|
212
|
+
const dom = mount({ log, takes: true }, 1280);
|
|
213
|
+
|
|
214
|
+
assert.equal(press(dom, "contextmenu", {}), false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("leaves the context menu alone on a row that cannot be selected", () => {
|
|
218
|
+
const log = emptyLog();
|
|
219
|
+
const dom = mount({ log, takes: true, unselectable: true }, 1280);
|
|
220
|
+
|
|
221
|
+
assert.equal(press(dom, "contextmenu", { ctrlKey: true }), false);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("isModified", () => {
|
|
226
|
+
it("is true for any of the three keys a keyboard can add", () => {
|
|
227
|
+
assert.equal(
|
|
228
|
+
isModified({ shiftKey: true, metaKey: false, ctrlKey: false }),
|
|
229
|
+
true,
|
|
230
|
+
);
|
|
231
|
+
assert.equal(
|
|
232
|
+
isModified({ shiftKey: false, metaKey: true, ctrlKey: false }),
|
|
233
|
+
true,
|
|
234
|
+
);
|
|
235
|
+
assert.equal(
|
|
236
|
+
isModified({ shiftKey: false, metaKey: false, ctrlKey: true }),
|
|
237
|
+
true,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("is false for the bare press a tap delivers", () => {
|
|
242
|
+
assert.equal(
|
|
243
|
+
isModified({ shiftKey: false, metaKey: false, ctrlKey: false }),
|
|
244
|
+
false,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modifier-click selection for a message row, shared by every row shape.
|
|
3
|
+
*
|
|
4
|
+
* Selection on modifiers is not gated on the layout tier. `isDesktop` is a media
|
|
5
|
+
* query at 1024px: it says how the app is laid out, not what is driving it.
|
|
6
|
+
* While the modifiers were read only on the desktop tier, every narrower window
|
|
7
|
+
* — a half-screen browser, a tablet — fell through to the touch branch, which
|
|
8
|
+
* never looks at them, and shift, cmd and ctrl click all just opened the
|
|
9
|
+
* message. A modifier can only come from a real keyboard, so a row honours it at
|
|
10
|
+
* every width; a tap carries none and is unaffected.
|
|
11
|
+
*
|
|
12
|
+
* The press, not the click, is where a modified click is taken. A mailbox row is
|
|
13
|
+
* an `<a href>`, and a modified click on an anchor is a browser gesture: shift
|
|
14
|
+
* opens a new window, cmd a new tab, ctrl raises the context menu. WebKit
|
|
15
|
+
* commits to that gesture from the press, so a `click` handler calling
|
|
16
|
+
* `preventDefault` is already too late there. `claimClick` still handles the
|
|
17
|
+
* click for engines that deliver it that way.
|
|
18
|
+
*/
|
|
19
|
+
import { type MouseEvent, useCallback, useRef } from "react";
|
|
20
|
+
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
21
|
+
|
|
22
|
+
export const modifiersOf = (e: MouseEvent): SelectionModifiers => ({
|
|
23
|
+
shiftKey: e.shiftKey,
|
|
24
|
+
metaKey: e.metaKey,
|
|
25
|
+
ctrlKey: e.ctrlKey,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const isModified = (m: SelectionModifiers): boolean =>
|
|
29
|
+
m.shiftKey || m.metaKey || m.ctrlKey;
|
|
30
|
+
|
|
31
|
+
export interface ModifierSelect {
|
|
32
|
+
/** Takes a modified press for selection before the browser acts on it. */
|
|
33
|
+
onMouseDown: (e: MouseEvent<HTMLElement>) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Suppresses the context menu macOS raises from a ctrl-click the press
|
|
36
|
+
* already spent on selection. A plain right-click keeps its menu.
|
|
37
|
+
*/
|
|
38
|
+
onContextMenu: (e: MouseEvent) => void;
|
|
39
|
+
/**
|
|
40
|
+
* True when the click belongs to a modified gesture selection has taken, in
|
|
41
|
+
* which case it is already consumed and the row must not open.
|
|
42
|
+
*/
|
|
43
|
+
claimClick: (e: MouseEvent) => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const consume = (e: MouseEvent): void => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
e.stopPropagation();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const useModifierSelect = (
|
|
52
|
+
messageId: string,
|
|
53
|
+
onRowSelect?: (messageId: string, modifiers: SelectionModifiers) => boolean,
|
|
54
|
+
): ModifierSelect => {
|
|
55
|
+
// Set while a modified press has already been handed to selection, so the
|
|
56
|
+
// click the same gesture still delivers cannot open the row on top of it.
|
|
57
|
+
const claimedRef = useRef(false);
|
|
58
|
+
|
|
59
|
+
const onMouseDown = useCallback(
|
|
60
|
+
(e: MouseEvent<HTMLElement>) => {
|
|
61
|
+
claimedRef.current = false;
|
|
62
|
+
// Only the primary button; a right-click keeps its context menu.
|
|
63
|
+
if (e.button !== 0) return;
|
|
64
|
+
const modifiers = modifiersOf(e);
|
|
65
|
+
if (!isModified(modifiers)) return;
|
|
66
|
+
// The browser's own default is only worth taking once selection has
|
|
67
|
+
// actually taken the press. On a row without selection, cmd-click still
|
|
68
|
+
// opens a new tab.
|
|
69
|
+
if (!onRowSelect?.(messageId, modifiers)) return;
|
|
70
|
+
consume(e);
|
|
71
|
+
claimedRef.current = true;
|
|
72
|
+
// Preventing the press also drops the focus it would have moved, so put
|
|
73
|
+
// it back: the roving cursor follows the row the user clicked, and it is
|
|
74
|
+
// the origin a later shift-click ranges from.
|
|
75
|
+
e.currentTarget.focus();
|
|
76
|
+
// Shift-click otherwise drags a native text selection across the rows it
|
|
77
|
+
// spans — the row highlight is the selection the user asked for.
|
|
78
|
+
if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
|
|
79
|
+
},
|
|
80
|
+
[onRowSelect, messageId],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const onContextMenu = useCallback(
|
|
84
|
+
(e: MouseEvent) => {
|
|
85
|
+
if (!e.ctrlKey || !onRowSelect) return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
},
|
|
88
|
+
[onRowSelect],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const claimClick = useCallback(
|
|
92
|
+
(e: MouseEvent): boolean => {
|
|
93
|
+
if (claimedRef.current) {
|
|
94
|
+
claimedRef.current = false;
|
|
95
|
+
consume(e);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const modifiers = modifiersOf(e);
|
|
99
|
+
if (!isModified(modifiers)) return false;
|
|
100
|
+
if (!onRowSelect?.(messageId, modifiers)) return false;
|
|
101
|
+
consume(e);
|
|
102
|
+
if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
[onRowSelect, messageId],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return { onMouseDown, onContextMenu, claimClick };
|
|
109
|
+
};
|