@remit/web-client 0.0.40 → 0.0.42
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/components/mail/MailboxPane.tsx +4 -1
- package/src/components/mail/MessageList.selection.test.ts +52 -0
- package/src/components/mail/MessageList.tsx +61 -33
- package/src/lib/dedupe-thread-messages.test.ts +78 -0
- package/src/lib/dedupe-thread-messages.ts +26 -0
- package/src/lib/selection-mode.test.ts +44 -0
- package/src/lib/selection-mode.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
buildConversationTarget,
|
|
100
100
|
type ConversationTarget,
|
|
101
101
|
} from "@/lib/conversation-target";
|
|
102
|
+
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
102
103
|
import { readIntelligencePref } from "@/lib/intelligence-pref";
|
|
103
104
|
import { useMailContext } from "@/lib/mail-context";
|
|
104
105
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
@@ -370,7 +371,9 @@ function MailboxPaneProvider({
|
|
|
370
371
|
});
|
|
371
372
|
|
|
372
373
|
const rawThreads = dropDeletedThreads(
|
|
373
|
-
|
|
374
|
+
dedupeThreadMessages(
|
|
375
|
+
threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
|
|
376
|
+
),
|
|
374
377
|
);
|
|
375
378
|
|
|
376
379
|
const [filterCategory, setFilterCategory] = useState("all");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Selection mode has one source of truth and one exit (#115). `MessageList`
|
|
9
|
+
* wires the DOM, the virtualizer, routing and several data hooks together, so
|
|
10
|
+
* — as with this package's other component-level rules (see
|
|
11
|
+
* `../../index.css.test.ts`) — the rule is enforced by reading the source
|
|
12
|
+
* rather than rendering the tree. The decisions themselves are unit-tested in
|
|
13
|
+
* `../../lib/selection-mode.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
18
|
+
|
|
19
|
+
describe("MessageList selection mode", () => {
|
|
20
|
+
it("keeps no selection-mode flag of its own", () => {
|
|
21
|
+
assert.doesNotMatch(source, /useState[^\n]*[iI]sMultiSelectMode/);
|
|
22
|
+
assert.doesNotMatch(source, /setIsMultiSelectMode/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("derives the mode from the selection count", () => {
|
|
26
|
+
assert.match(
|
|
27
|
+
source,
|
|
28
|
+
/const isMultiSelectMode = deriveIsMultiSelectMode\(\s*selectedCount,/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("calls the selection hook's clearSelection from exactly one place", () => {
|
|
33
|
+
const calls = source.match(/\bclearSelection\(\)/g) ?? [];
|
|
34
|
+
assert.equal(calls.length, 1);
|
|
35
|
+
assert.match(
|
|
36
|
+
source,
|
|
37
|
+
/const exitSelection = useCallback\(\(\) => \{[^}]*clearSelection\(\);/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("exits selection when the mailbox changes", () => {
|
|
42
|
+
assert.match(source, /previousMailboxIdRef\.current === mailboxId/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("turns the back gesture into an exit, and only while selecting", () => {
|
|
46
|
+
assert.match(
|
|
47
|
+
source,
|
|
48
|
+
/shouldExitSelectionOnNavigate\(action, hasSelection\)/,
|
|
49
|
+
);
|
|
50
|
+
assert.match(source, /disabled: !hasSelection/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
MessageListPane,
|
|
6
6
|
SelectionTopBar,
|
|
7
7
|
} from "@remit/ui";
|
|
8
|
-
import { useNavigate } from "@tanstack/react-router";
|
|
8
|
+
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
9
9
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
10
10
|
import { Search, Sparkles } from "lucide-react";
|
|
11
11
|
import type { RefObject } from "react";
|
|
@@ -34,6 +34,10 @@ import {
|
|
|
34
34
|
} from "@/lib/escalation-label";
|
|
35
35
|
import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
|
|
36
36
|
import { tabStopId } from "@/lib/list-focus";
|
|
37
|
+
import {
|
|
38
|
+
deriveIsMultiSelectMode,
|
|
39
|
+
shouldExitSelectionOnNavigate,
|
|
40
|
+
} from "@/lib/selection-mode";
|
|
37
41
|
import { cn } from "@/lib/utils";
|
|
38
42
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
39
43
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
@@ -200,7 +204,6 @@ export const MessageList = ({
|
|
|
200
204
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
201
205
|
const navigate = useNavigate();
|
|
202
206
|
const isDesktop = useIsDesktop();
|
|
203
|
-
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
|
204
207
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
205
208
|
const isSearching = !!searchQuery?.trim();
|
|
206
209
|
|
|
@@ -247,6 +250,11 @@ export const MessageList = ({
|
|
|
247
250
|
intersectWith,
|
|
248
251
|
} = useSelection();
|
|
249
252
|
|
|
253
|
+
// The selection count is the only source of truth for whether the list is
|
|
254
|
+
// in multi-select mode (#115). A separate flag needs an effect to reconcile
|
|
255
|
+
// it back to the count, and across that render the two disagree.
|
|
256
|
+
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
257
|
+
|
|
250
258
|
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
251
259
|
// `source: "ids"` snapshots the concrete ids at request time so a selection
|
|
252
260
|
// change behind the dialog can't retarget the delete — every keyboard/desktop
|
|
@@ -274,6 +282,18 @@ export const MessageList = ({
|
|
|
274
282
|
predicateKey,
|
|
275
283
|
searchQuery: searchPredicate ?? {},
|
|
276
284
|
});
|
|
285
|
+
|
|
286
|
+
// The one way selection mode ends (#115): cancel, a completed delete or
|
|
287
|
+
// move, a plain click that collapses a range, switching mailboxes, the back
|
|
288
|
+
// gesture. Nothing calls the selection hook's `clearSelection` directly, so
|
|
289
|
+
// an escalated selection can never survive the exit as a stale phase.
|
|
290
|
+
const escalationPhaseKind = escalation.phase.kind;
|
|
291
|
+
const clearEscalation = escalation.clear;
|
|
292
|
+
const exitSelection = useCallback(() => {
|
|
293
|
+
if (escalationPhaseKind === "escalated") clearEscalation();
|
|
294
|
+
clearSelection();
|
|
295
|
+
}, [clearSelection, clearEscalation, escalationPhaseKind]);
|
|
296
|
+
|
|
277
297
|
// Non-null exactly once a bounded/escalated run just ended with some ids
|
|
278
298
|
// still not confirmed deleted: the count that DID succeed in that run, so
|
|
279
299
|
// the partial-failure notice can say "N moved to Trash" alongside "Retry".
|
|
@@ -316,13 +336,6 @@ export const MessageList = ({
|
|
|
316
336
|
const commandsAvailable = !isLoading && threads.length > 0;
|
|
317
337
|
const confirmOpen = pendingDelete !== null;
|
|
318
338
|
|
|
319
|
-
// Auto-exit multi-select when selection becomes empty
|
|
320
|
-
useEffect(() => {
|
|
321
|
-
if (isMultiSelectMode && selectedCount === 0) {
|
|
322
|
-
setIsMultiSelectMode(false);
|
|
323
|
-
}
|
|
324
|
-
}, [isMultiSelectMode, selectedCount]);
|
|
325
|
-
|
|
326
339
|
// Single choke point for what selection looks like once a chunked/escalated
|
|
327
340
|
// run ends, for any reason (issue #92 requirement 10). A clean run with
|
|
328
341
|
// nothing left over exits selection mode and hands off to a transient
|
|
@@ -338,7 +351,7 @@ export const MessageList = ({
|
|
|
338
351
|
`${formatNumber(outcome.done)} moved to Trash. Your mail server is still catching up.`,
|
|
339
352
|
);
|
|
340
353
|
setLastRunSucceeded(null);
|
|
341
|
-
|
|
354
|
+
exitSelection();
|
|
342
355
|
return;
|
|
343
356
|
}
|
|
344
357
|
if (retryIds.length > 0) {
|
|
@@ -348,7 +361,7 @@ export const MessageList = ({
|
|
|
348
361
|
}
|
|
349
362
|
setLastRunSucceeded(null);
|
|
350
363
|
},
|
|
351
|
-
[
|
|
364
|
+
[exitSelection, selectAll],
|
|
352
365
|
);
|
|
353
366
|
|
|
354
367
|
const virtualizer = useVirtualizer({
|
|
@@ -437,7 +450,7 @@ export const MessageList = ({
|
|
|
437
450
|
// Plain click: collapse any multi-selection and let navigation proceed.
|
|
438
451
|
// The clicked row becomes the next anchor for a subsequent shift-click,
|
|
439
452
|
// but is NOT added to the checkbox set (no toolbar on a plain open).
|
|
440
|
-
|
|
453
|
+
exitSelection();
|
|
441
454
|
setAnchor(messageId);
|
|
442
455
|
return false;
|
|
443
456
|
},
|
|
@@ -446,7 +459,7 @@ export const MessageList = ({
|
|
|
446
459
|
focusedMessageId,
|
|
447
460
|
selectRange,
|
|
448
461
|
toggleCheck,
|
|
449
|
-
|
|
462
|
+
exitSelection,
|
|
450
463
|
setAnchor,
|
|
451
464
|
],
|
|
452
465
|
);
|
|
@@ -611,7 +624,7 @@ export const MessageList = ({
|
|
|
611
624
|
}
|
|
612
625
|
|
|
613
626
|
onDeleteMessages?.(ids);
|
|
614
|
-
|
|
627
|
+
exitSelection();
|
|
615
628
|
focusBeforeConfirmRef.current = null;
|
|
616
629
|
setPendingDelete(null);
|
|
617
630
|
|
|
@@ -632,7 +645,7 @@ export const MessageList = ({
|
|
|
632
645
|
pendingDelete,
|
|
633
646
|
threads,
|
|
634
647
|
onDeleteMessages,
|
|
635
|
-
|
|
648
|
+
exitSelection,
|
|
636
649
|
navigate,
|
|
637
650
|
mailboxId,
|
|
638
651
|
runChunkedConfirmDelete,
|
|
@@ -664,9 +677,9 @@ export const MessageList = ({
|
|
|
664
677
|
(destinationMailboxId: string) => {
|
|
665
678
|
if (!onMoveMessages || selectedCount === 0) return;
|
|
666
679
|
onMoveMessages(Array.from(selectedIds), destinationMailboxId);
|
|
667
|
-
|
|
680
|
+
exitSelection();
|
|
668
681
|
},
|
|
669
|
-
[onMoveMessages, selectedCount, selectedIds,
|
|
682
|
+
[onMoveMessages, selectedCount, selectedIds, exitSelection],
|
|
670
683
|
);
|
|
671
684
|
|
|
672
685
|
// Cross-account guard: every selected thread row must belong to the
|
|
@@ -708,23 +721,17 @@ export const MessageList = ({
|
|
|
708
721
|
[toggleReadFor],
|
|
709
722
|
);
|
|
710
723
|
|
|
711
|
-
// Mobile:
|
|
724
|
+
// Mobile: a long press enters multi-select mode by starting a selection —
|
|
725
|
+
// the mode follows from the count, there is no flag to set.
|
|
712
726
|
const handleLongPress = useCallback(
|
|
713
727
|
(messageId: string) => {
|
|
714
728
|
if (!isDesktop) {
|
|
715
|
-
setIsMultiSelectMode(true);
|
|
716
729
|
select(messageId);
|
|
717
730
|
}
|
|
718
731
|
},
|
|
719
732
|
[isDesktop, select],
|
|
720
733
|
);
|
|
721
734
|
|
|
722
|
-
// Cancel multi-select mode
|
|
723
|
-
const handleCancelMultiSelect = useCallback(() => {
|
|
724
|
-
setIsMultiSelectMode(false);
|
|
725
|
-
clearSelection();
|
|
726
|
-
}, [clearSelection]);
|
|
727
|
-
|
|
728
735
|
// Mobile bar's Trash tap: an escalated selection has no materialized ids to
|
|
729
736
|
// hand `requestDelete`, so it opens the predicate confirmation instead.
|
|
730
737
|
const handleMobileDelete = useCallback(() => {
|
|
@@ -745,11 +752,8 @@ export const MessageList = ({
|
|
|
745
752
|
escalation.stop();
|
|
746
753
|
return;
|
|
747
754
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
}
|
|
751
|
-
handleCancelMultiSelect();
|
|
752
|
-
}, [escalation, handleCancelMultiSelect]);
|
|
755
|
+
exitSelection();
|
|
756
|
+
}, [escalation, exitSelection]);
|
|
753
757
|
|
|
754
758
|
// The escalation notice's "Clear selection" action: drop back to the
|
|
755
759
|
// bounded (all-loaded) selection without touching selection mode itself.
|
|
@@ -861,6 +865,30 @@ export const MessageList = ({
|
|
|
861
865
|
intersectWith(threads.map((t) => t.messageId));
|
|
862
866
|
}, [threads, intersectWith, escalation.phase, escalation.isDeleting]);
|
|
863
867
|
|
|
868
|
+
// Switching mailboxes exits selection outright, instead of leaving it to the
|
|
869
|
+
// intersect effect above to empty the set by coincidence — a different
|
|
870
|
+
// mailbox's threads happen to share none of the old ids.
|
|
871
|
+
const previousMailboxIdRef = useRef(mailboxId);
|
|
872
|
+
useEffect(() => {
|
|
873
|
+
if (previousMailboxIdRef.current === mailboxId) return;
|
|
874
|
+
previousMailboxIdRef.current = mailboxId;
|
|
875
|
+
exitSelection();
|
|
876
|
+
}, [mailboxId, exitSelection]);
|
|
877
|
+
|
|
878
|
+
// The back gesture (Android's back button, the browser's back) leaves
|
|
879
|
+
// selection mode rather than the route. Only `BACK` is intercepted, so a
|
|
880
|
+
// navigation the app itself starts — opening a message, switching mailboxes
|
|
881
|
+
// — is never blocked, and the blocker is off entirely with nothing selected.
|
|
882
|
+
useBlocker({
|
|
883
|
+
shouldBlockFn: ({ action }) => {
|
|
884
|
+
if (!shouldExitSelectionOnNavigate(action, hasSelection)) return false;
|
|
885
|
+
exitSelection();
|
|
886
|
+
return true;
|
|
887
|
+
},
|
|
888
|
+
enableBeforeUnload: false,
|
|
889
|
+
disabled: !hasSelection,
|
|
890
|
+
});
|
|
891
|
+
|
|
864
892
|
// Load more when scrolling near the bottom
|
|
865
893
|
useEffect(() => {
|
|
866
894
|
const scrollElement = parentRef.current;
|
|
@@ -918,7 +946,7 @@ export const MessageList = ({
|
|
|
918
946
|
selectAll: handleSelectAll,
|
|
919
947
|
clearSelection: () => {
|
|
920
948
|
if (!hasSelection) return false;
|
|
921
|
-
|
|
949
|
+
exitSelection();
|
|
922
950
|
return true;
|
|
923
951
|
},
|
|
924
952
|
requestDelete: handleDeleteKey,
|
|
@@ -940,7 +968,7 @@ export const MessageList = ({
|
|
|
940
968
|
extendRangeUp,
|
|
941
969
|
handleSelectAll,
|
|
942
970
|
hasSelection,
|
|
943
|
-
|
|
971
|
+
exitSelection,
|
|
944
972
|
handleDeleteKey,
|
|
945
973
|
toggleDensity,
|
|
946
974
|
]);
|
|
@@ -972,7 +1000,7 @@ export const MessageList = ({
|
|
|
972
1000
|
<SelectionToolbar
|
|
973
1001
|
selectedCount={selectedCount}
|
|
974
1002
|
onDelete={handleDelete}
|
|
975
|
-
onClearSelection={
|
|
1003
|
+
onClearSelection={exitSelection}
|
|
976
1004
|
onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
977
1005
|
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
978
1006
|
onOrganize={() => setOrganizeOpen(true)}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { dedupeThreadMessages } from "./dedupe-thread-messages.js";
|
|
5
|
+
|
|
6
|
+
function threadMessage(
|
|
7
|
+
overrides: Partial<RemitImapThreadMessageResponse> = {},
|
|
8
|
+
): RemitImapThreadMessageResponse {
|
|
9
|
+
return {
|
|
10
|
+
threadId: "t1",
|
|
11
|
+
threadMessageId: "tm1",
|
|
12
|
+
messageId: "m1",
|
|
13
|
+
accountConfigId: "cfg_1",
|
|
14
|
+
mailboxId: "mb1",
|
|
15
|
+
fromName: "Sender",
|
|
16
|
+
fromEmail: "sender@example.com",
|
|
17
|
+
subject: "Subject",
|
|
18
|
+
snippet: "Snippet",
|
|
19
|
+
sentDate: 1767225600,
|
|
20
|
+
isRead: false,
|
|
21
|
+
isDeleted: false,
|
|
22
|
+
hasAttachment: false,
|
|
23
|
+
hasStars: false,
|
|
24
|
+
star: "none",
|
|
25
|
+
senderTrust: "unknown",
|
|
26
|
+
createdAt: 0,
|
|
27
|
+
updatedAt: 0,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("dedupeThreadMessages (#166)", () => {
|
|
33
|
+
test("collapses an overlapping page to one row per threadMessageId", () => {
|
|
34
|
+
const pageOne = [
|
|
35
|
+
threadMessage({ threadMessageId: "tm1", messageId: "m1" }),
|
|
36
|
+
threadMessage({ threadMessageId: "tm2", messageId: "m2" }),
|
|
37
|
+
];
|
|
38
|
+
const pageTwo = [
|
|
39
|
+
threadMessage({ threadMessageId: "tm2", messageId: "m2" }),
|
|
40
|
+
threadMessage({ threadMessageId: "tm3", messageId: "m3" }),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const got = dedupeThreadMessages([...pageOne, ...pageTwo]);
|
|
44
|
+
|
|
45
|
+
assert.deepStrictEqual(
|
|
46
|
+
got.map((item) => item.threadMessageId),
|
|
47
|
+
["tm1", "tm2", "tm3"],
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("keeps the first occurrence's data when duplicates disagree", () => {
|
|
52
|
+
const items = [
|
|
53
|
+
threadMessage({ threadMessageId: "tm1", isRead: false }),
|
|
54
|
+
threadMessage({ threadMessageId: "tm1", isRead: true }),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const got = dedupeThreadMessages(items);
|
|
58
|
+
|
|
59
|
+
assert.equal(got.length, 1);
|
|
60
|
+
assert.equal(got[0].isRead, false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("leaves non-overlapping pages unaffected", () => {
|
|
64
|
+
const items = [
|
|
65
|
+
threadMessage({ threadMessageId: "tm1", messageId: "m1" }),
|
|
66
|
+
threadMessage({ threadMessageId: "tm2", messageId: "m2" }),
|
|
67
|
+
threadMessage({ threadMessageId: "tm3", messageId: "m3" }),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const got = dedupeThreadMessages(items);
|
|
71
|
+
|
|
72
|
+
assert.deepStrictEqual(got, items);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns an empty array for empty input", () => {
|
|
76
|
+
assert.deepStrictEqual(dedupeThreadMessages([]), []);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure helper: collapse a flattened list of thread messages to one row per
|
|
5
|
+
* `threadMessageId`, keeping the first occurrence.
|
|
6
|
+
*
|
|
7
|
+
* Belt-and-braces guard for issue #166. The web client appends every fetched
|
|
8
|
+
* page as-is and relies on the keyset cursor never handing back an overlapping
|
|
9
|
+
* page. `threadMessageId` is unique per message, so two rows sharing one are
|
|
10
|
+
* the same message — dropping the later row removes a duplicate, never a
|
|
11
|
+
* distinct result. Applied where pages are flattened into the list, so every
|
|
12
|
+
* consumer of the list sees a clean array and no render site has to remember
|
|
13
|
+
* to dedupe.
|
|
14
|
+
*/
|
|
15
|
+
export const dedupeThreadMessages = (
|
|
16
|
+
items: RemitImapThreadMessageResponse[],
|
|
17
|
+
): RemitImapThreadMessageResponse[] => {
|
|
18
|
+
const seen = new Set<string>();
|
|
19
|
+
const deduped: RemitImapThreadMessageResponse[] = [];
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
if (seen.has(item.threadMessageId)) continue;
|
|
22
|
+
seen.add(item.threadMessageId);
|
|
23
|
+
deduped.push(item);
|
|
24
|
+
}
|
|
25
|
+
return deduped;
|
|
26
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
deriveIsMultiSelectMode,
|
|
5
|
+
shouldExitSelectionOnNavigate,
|
|
6
|
+
} from "./selection-mode.js";
|
|
7
|
+
|
|
8
|
+
describe("deriveIsMultiSelectMode", () => {
|
|
9
|
+
test("no selection is not multi-select mode", () => {
|
|
10
|
+
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("any selection on touch is multi-select mode", () => {
|
|
14
|
+
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
15
|
+
assert.equal(deriveIsMultiSelectMode(42, false), true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("desktop selection drives the desktop toolbar, not multi-select mode", () => {
|
|
19
|
+
assert.equal(deriveIsMultiSelectMode(1, true), false);
|
|
20
|
+
assert.equal(deriveIsMultiSelectMode(0, true), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("dropping the last selected id leaves the mode in the same call", () => {
|
|
24
|
+
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
25
|
+
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("shouldExitSelectionOnNavigate", () => {
|
|
30
|
+
test("back while selecting exits selection instead of navigating", () => {
|
|
31
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("back with nothing selected is left alone", () => {
|
|
35
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", false), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("forward, push, replace and go are never blocked", () => {
|
|
39
|
+
assert.equal(shouldExitSelectionOnNavigate("FORWARD", true), false);
|
|
40
|
+
assert.equal(shouldExitSelectionOnNavigate("PUSH", true), false);
|
|
41
|
+
assert.equal(shouldExitSelectionOnNavigate("REPLACE", true), false);
|
|
42
|
+
assert.equal(shouldExitSelectionOnNavigate("GO", true), false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection-mode rules for the message list (#115, refs #92 requirements 9
|
|
3
|
+
* and 10). Pure so "one source of truth, one exit" is testable without a DOM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
|
|
7
|
+
export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether the list is in multi-select mode: a function of the selection count,
|
|
11
|
+
* never a stored flag, so the two can never disagree. Multi-select is the
|
|
12
|
+
* touch affordance (long press, always-visible checkboxes, the selection top
|
|
13
|
+
* bar); on desktop a selection drives the desktop toolbar instead and rows
|
|
14
|
+
* keep their ordinary hover behaviour.
|
|
15
|
+
*/
|
|
16
|
+
export const deriveIsMultiSelectMode = (
|
|
17
|
+
selectedCount: number,
|
|
18
|
+
isDesktop: boolean,
|
|
19
|
+
): boolean => !isDesktop && selectedCount > 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a history navigation should exit selection mode instead of leaving
|
|
23
|
+
* the route. Only the back gesture is intercepted, so a navigation the app
|
|
24
|
+
* itself starts (opening a message, switching mailboxes) is never blocked.
|
|
25
|
+
*/
|
|
26
|
+
export const shouldExitSelectionOnNavigate = (
|
|
27
|
+
action: NavigationAction,
|
|
28
|
+
hasSelection: boolean,
|
|
29
|
+
): boolean => action === "BACK" && hasSelection;
|