@remit/web-client 0.0.49 → 0.0.50
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 +5 -3
- package/src/components/mail/BriefPane.tsx +98 -2
- package/src/components/mail/DailyBrief.tsx +44 -12
- package/src/components/mail/FlaggedList.tsx +51 -27
- package/src/components/mail/FlaggedPane.tsx +101 -2
- package/src/components/mail/MailboxPane.tsx +28 -77
- package/src/components/mail/MessageRow.tsx +20 -8
- package/src/components/mail/ThreadListInteraction.test.ts +199 -0
- package/src/components/mail/ThreadListInteraction.tsx +408 -0
- package/src/hooks/useDeleteMessages.ts +16 -4
- package/src/hooks/useListCursor.test.ts +273 -0
- package/src/hooks/useListCursor.ts +226 -0
- package/src/hooks/useMarkAsRead.ts +15 -3
- package/src/hooks/useTriageLayer.ts +148 -0
|
@@ -93,9 +93,8 @@ import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
|
93
93
|
import { useThreadActions } from "@/hooks/useThreadActions";
|
|
94
94
|
import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
|
|
95
95
|
import { useToggleStar } from "@/hooks/useToggleStar";
|
|
96
|
-
import {
|
|
96
|
+
import { useTriageContext, useTriageLayer } from "@/hooks/useTriageLayer";
|
|
97
97
|
import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
98
|
-
import { adjacentMessageId } from "@/lib/adjacent-message";
|
|
99
98
|
import {
|
|
100
99
|
buildConversationTarget,
|
|
101
100
|
type ConversationTarget,
|
|
@@ -433,32 +432,13 @@ function MailboxPaneProvider({
|
|
|
433
432
|
],
|
|
434
433
|
);
|
|
435
434
|
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const listCommandsRef = useRef<MessageListCommands | null>(null);
|
|
444
|
-
// Whether a message list is mounted and serving commands, and whether it has
|
|
445
|
-
// a modal that owns the keyboard. Both drive which keys this route claims.
|
|
446
|
-
const [hasList, setHasList] = useState(false);
|
|
447
|
-
const [listBlocksKeyboard, setListBlocksKeyboard] = useState(false);
|
|
448
|
-
const handleTriageContextChange = useCallback(
|
|
449
|
-
(context: {
|
|
450
|
-
focusedMessageId: string | undefined;
|
|
451
|
-
selectedIds: string[];
|
|
452
|
-
hasList: boolean;
|
|
453
|
-
blocksKeyboard: boolean;
|
|
454
|
-
}) => {
|
|
455
|
-
setTriageFocusedId(context.focusedMessageId);
|
|
456
|
-
setTriageSelectedIds(context.selectedIds);
|
|
457
|
-
setHasList(context.hasList);
|
|
458
|
-
setListBlocksKeyboard(context.blocksKeyboard);
|
|
459
|
-
},
|
|
460
|
-
[],
|
|
461
|
-
);
|
|
435
|
+
const triage = useTriageContext();
|
|
436
|
+
const {
|
|
437
|
+
listCommandsRef,
|
|
438
|
+
onTriageContextChange: handleTriageContextChange,
|
|
439
|
+
focusedMessageId: triageFocusedId,
|
|
440
|
+
selectedIds: triageSelectedIds,
|
|
441
|
+
} = triage;
|
|
462
442
|
|
|
463
443
|
const focusedThread =
|
|
464
444
|
threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
|
|
@@ -564,22 +544,19 @@ function MailboxPaneProvider({
|
|
|
564
544
|
closeCompose,
|
|
565
545
|
]);
|
|
566
546
|
|
|
567
|
-
// Esc unwinds one step at a time: an active selection first
|
|
568
|
-
// thread.
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
}, [selectedMessageId, mailboxId, navigate]);
|
|
547
|
+
// Esc unwinds one step at a time: an active selection first (handled by the
|
|
548
|
+
// triage layer), then the open thread.
|
|
549
|
+
const closeThread = useCallback(() => {
|
|
550
|
+
navigate({
|
|
551
|
+
to: "/mail/$mailboxId",
|
|
552
|
+
params: { mailboxId },
|
|
553
|
+
search: (prev: Record<string, unknown>) => ({
|
|
554
|
+
...prev,
|
|
555
|
+
selectedMessageId: undefined,
|
|
556
|
+
selectedThreadId: undefined,
|
|
557
|
+
}),
|
|
558
|
+
});
|
|
559
|
+
}, [mailboxId, navigate]);
|
|
583
560
|
|
|
584
561
|
const messageIdsForFocusedThread = useCallback(
|
|
585
562
|
(thread: typeof focusedThread): string[] => {
|
|
@@ -661,7 +638,7 @@ function MailboxPaneProvider({
|
|
|
661
638
|
if (listCommandsRef.current?.requestDelete()) return;
|
|
662
639
|
const ids = triageTargetMessageIds();
|
|
663
640
|
if (ids.length > 0) triageDelete(ids);
|
|
664
|
-
}, [triageTargetMessageIds, triageDelete]);
|
|
641
|
+
}, [listCommandsRef, triageTargetMessageIds, triageDelete]);
|
|
665
642
|
|
|
666
643
|
const triageMarkJunk = useCallback(() => {
|
|
667
644
|
if (!junkMailboxId) return;
|
|
@@ -749,31 +726,13 @@ function MailboxPaneProvider({
|
|
|
749
726
|
!!composeState.outboxMessageId &&
|
|
750
727
|
!selectedThread;
|
|
751
728
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
enabled: !composeState.isOpen
|
|
729
|
+
const { goBack, nextMessageId, previousMessageId } = useTriageLayer({
|
|
730
|
+
context: triage,
|
|
731
|
+
orderedIds: threads.map((t) => t.messageId),
|
|
732
|
+
selectedMessageId,
|
|
733
|
+
enabled: !composeState.isOpen,
|
|
734
|
+
onClose: closeThread,
|
|
757
735
|
handlers: {
|
|
758
|
-
// List navigation and selection, registered only while a list is
|
|
759
|
-
// mounted to serve them. An unregistered action is never
|
|
760
|
-
// preventDefault-ed, so with no list the browser keeps Enter, Space and
|
|
761
|
-
// ⌘A — select-all-text in the reading pane still works.
|
|
762
|
-
...(hasList
|
|
763
|
-
? {
|
|
764
|
-
focusNext: () => listCommandsRef.current?.focusNext(),
|
|
765
|
-
focusPrevious: () => listCommandsRef.current?.focusPrevious(),
|
|
766
|
-
focusFirst: () => listCommandsRef.current?.focusFirst(),
|
|
767
|
-
focusLast: () => listCommandsRef.current?.focusLast(),
|
|
768
|
-
openFocused: () => listCommandsRef.current?.openFocused(),
|
|
769
|
-
toggleSelect: () => listCommandsRef.current?.toggleSelect(),
|
|
770
|
-
extendSelectDown: () => listCommandsRef.current?.extendSelectDown(),
|
|
771
|
-
extendSelectUp: () => listCommandsRef.current?.extendSelectUp(),
|
|
772
|
-
selectAll: () => listCommandsRef.current?.selectAll(),
|
|
773
|
-
toggleDensity: () => listCommandsRef.current?.toggleDensity(),
|
|
774
|
-
}
|
|
775
|
-
: {}),
|
|
776
|
-
back: goBack,
|
|
777
736
|
reply: triageReply,
|
|
778
737
|
replyAll: triageReplyAll,
|
|
779
738
|
forward: triageForward,
|
|
@@ -799,14 +758,6 @@ function MailboxPaneProvider({
|
|
|
799
758
|
bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
|
|
800
759
|
});
|
|
801
760
|
|
|
802
|
-
const orderedMessageIds = threads.map((t) => t.messageId);
|
|
803
|
-
const nextMessageId =
|
|
804
|
-
adjacentMessageId(orderedMessageIds, selectedMessageId, "next") ??
|
|
805
|
-
undefined;
|
|
806
|
-
const previousMessageId =
|
|
807
|
-
adjacentMessageId(orderedMessageIds, selectedMessageId, "previous") ??
|
|
808
|
-
undefined;
|
|
809
|
-
|
|
810
761
|
const ctx: MailboxPaneContextValue = {
|
|
811
762
|
mailboxId,
|
|
812
763
|
selectedMessageId,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
compactRowClass,
|
|
19
19
|
type Density,
|
|
20
20
|
mergeProps,
|
|
21
|
+
type RowToggleEvent,
|
|
21
22
|
type ThreadRowData,
|
|
22
23
|
useLongPress,
|
|
23
24
|
} from "@remit/ui";
|
|
@@ -26,6 +27,7 @@ import { Link } from "@tanstack/react-router";
|
|
|
26
27
|
import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
|
|
27
28
|
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
28
29
|
import { cn } from "@/lib/utils";
|
|
30
|
+
import { useThreadRowInteraction } from "./ThreadListInteraction";
|
|
29
31
|
|
|
30
32
|
interface MailboxLinkSearch {
|
|
31
33
|
selectedMessageId?: string;
|
|
@@ -85,27 +87,37 @@ export interface MessageRowProps {
|
|
|
85
87
|
const MessageRowComponent = ({
|
|
86
88
|
thread,
|
|
87
89
|
active = false,
|
|
88
|
-
focused
|
|
89
|
-
isTabStop
|
|
90
|
+
focused: focusedProp,
|
|
91
|
+
isTabStop: isTabStopProp,
|
|
90
92
|
density = "comfortable",
|
|
91
|
-
isDesktop
|
|
93
|
+
isDesktop: isDesktopProp,
|
|
92
94
|
badge,
|
|
93
|
-
selection,
|
|
95
|
+
selection: selectionProp,
|
|
94
96
|
inListbox = false,
|
|
95
97
|
linkMailboxId,
|
|
96
98
|
onClick,
|
|
97
|
-
onFocusRow,
|
|
99
|
+
onFocusRow: onFocusRowProp,
|
|
98
100
|
}: MessageRowProps) => {
|
|
99
101
|
const queryClient = useQueryClient();
|
|
100
102
|
const messageId = thread.id;
|
|
103
|
+
// A list that renders its own rows (the brief, Flagged) supplies the cursor
|
|
104
|
+
// and selection through context; the mailbox list passes them as props.
|
|
105
|
+
const fromContext = useThreadRowInteraction(messageId);
|
|
106
|
+
const focused = focusedProp ?? fromContext?.focused ?? false;
|
|
107
|
+
const isTabStop = isTabStopProp ?? fromContext?.isTabStop ?? false;
|
|
108
|
+
const selection = selectionProp ?? fromContext?.selection;
|
|
109
|
+
const onFocusRow = onFocusRowProp ?? fromContext?.onFocusRow;
|
|
110
|
+
// The cursor and the row must agree on the device: the cursor's multi-select
|
|
111
|
+
// mode is derived from it, and the row's tap semantics branch on it.
|
|
112
|
+
const isDesktop = isDesktopProp ?? fromContext?.isDesktop ?? true;
|
|
101
113
|
const isChecked = selection?.isChecked ?? false;
|
|
102
114
|
const isMultiSelectMode = selection?.isMultiSelectMode ?? false;
|
|
103
115
|
const onToggleCheck = selection?.onToggleCheck;
|
|
104
116
|
const onRowSelect = selection?.onRowSelect;
|
|
105
117
|
const onLongPress = selection?.onLongPress;
|
|
106
118
|
|
|
107
|
-
const
|
|
108
|
-
(e:
|
|
119
|
+
const handleToggleCheck = useCallback(
|
|
120
|
+
(e: RowToggleEvent) => {
|
|
109
121
|
e.preventDefault();
|
|
110
122
|
e.stopPropagation();
|
|
111
123
|
onToggleCheck?.(messageId);
|
|
@@ -242,7 +254,7 @@ const MessageRowComponent = ({
|
|
|
242
254
|
? {
|
|
243
255
|
checked: isChecked,
|
|
244
256
|
alwaysVisible: isMultiSelectMode,
|
|
245
|
-
onToggle:
|
|
257
|
+
onToggle: handleToggleCheck,
|
|
246
258
|
}
|
|
247
259
|
: undefined
|
|
248
260
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadListInteraction (#149) — the cursor walks the rows that are on screen,
|
|
3
|
+
* and delete asks before it trashes anything.
|
|
4
|
+
*
|
|
5
|
+
* The brief's sections cap themselves behind "Show N more" and collapse from
|
|
6
|
+
* their headers, so what the consumer passed and what is rendered are different
|
|
7
|
+
* lists. These cases mount rows directly and change them, which is the same
|
|
8
|
+
* thing from the provider's point of view.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import type { JSDOM } from "jsdom";
|
|
13
|
+
import { act, createElement, createRef, useState } from "react";
|
|
14
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
15
|
+
import type { MessageListCommands } from "./MessageList";
|
|
16
|
+
import { ThreadListInteraction } from "./ThreadListInteraction";
|
|
17
|
+
|
|
18
|
+
let dom: JSDOM;
|
|
19
|
+
let container: HTMLElement;
|
|
20
|
+
let root: Root;
|
|
21
|
+
|
|
22
|
+
before(async () => {
|
|
23
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
24
|
+
dom = new JSDOMCtor(
|
|
25
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
26
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
27
|
+
);
|
|
28
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
29
|
+
globalThis.document = dom.window.document;
|
|
30
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
31
|
+
globalThis.Element = dom.window.Element;
|
|
32
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
33
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
34
|
+
value: dom.window.navigator,
|
|
35
|
+
configurable: true,
|
|
36
|
+
});
|
|
37
|
+
(
|
|
38
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
39
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
after(() => dom.window.close());
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
container = dom.window.document.getElementById(
|
|
46
|
+
"root",
|
|
47
|
+
) as unknown as HTMLElement;
|
|
48
|
+
container.innerHTML = "";
|
|
49
|
+
root = createRoot(container);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
act(() => root.unmount());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const rowElements = (ids: string[]) =>
|
|
57
|
+
ids.map((id) =>
|
|
58
|
+
createElement("button", { key: id, type: "button", "data-message-id": id }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mount the provider over a set of rows, with a hook to change which rows are
|
|
63
|
+
* rendered afterwards — what "Show N more" and a collapsing section header do.
|
|
64
|
+
*/
|
|
65
|
+
function mountList(options: {
|
|
66
|
+
initialIds: string[];
|
|
67
|
+
onDeleteMessages?: (ids: string[]) => void;
|
|
68
|
+
}) {
|
|
69
|
+
const commandsRef = createRef<MessageListCommands | null>();
|
|
70
|
+
let setIds: ((ids: string[]) => void) | undefined;
|
|
71
|
+
const Harness = () => {
|
|
72
|
+
const [ids, set] = useState(options.initialIds);
|
|
73
|
+
setIds = set;
|
|
74
|
+
return createElement(
|
|
75
|
+
ThreadListInteraction,
|
|
76
|
+
{
|
|
77
|
+
selectedMessageId: undefined,
|
|
78
|
+
onOpen: () => undefined,
|
|
79
|
+
onDeleteMessages: options.onDeleteMessages,
|
|
80
|
+
commandsRef,
|
|
81
|
+
},
|
|
82
|
+
...rowElements(ids),
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
act(() => root.render(createElement(Harness)));
|
|
86
|
+
return {
|
|
87
|
+
commands: () => {
|
|
88
|
+
const commands = commandsRef.current;
|
|
89
|
+
if (!commands) throw new Error("commands not published");
|
|
90
|
+
return commands;
|
|
91
|
+
},
|
|
92
|
+
// The provider reads the rendered rows through a MutationObserver, whose
|
|
93
|
+
// callback lands on the microtask queue — an async act flushes both.
|
|
94
|
+
render: async (ids: string[]) => {
|
|
95
|
+
await act(async () => {
|
|
96
|
+
setIds?.(ids);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
focusedId: () =>
|
|
100
|
+
(dom.window.document.activeElement as HTMLElement | null)?.dataset
|
|
101
|
+
.messageId,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("ThreadListInteraction — the cursor follows the rendered rows", () => {
|
|
106
|
+
it("walks only the rows in the DOM", () => {
|
|
107
|
+
const list = mountList({ initialIds: ["m1", "m2", "m3"] });
|
|
108
|
+
|
|
109
|
+
act(() => list.commands().focusFirst());
|
|
110
|
+
assert.equal(list.focusedId(), "m1");
|
|
111
|
+
|
|
112
|
+
act(() => list.commands().focusLast());
|
|
113
|
+
assert.equal(list.focusedId(), "m3");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stops at the last rendered row, not the last row in the data", () => {
|
|
117
|
+
// The capped case: three rows are on screen, more exist behind the
|
|
118
|
+
// expander. The cursor must not step past what is rendered.
|
|
119
|
+
const list = mountList({ initialIds: ["m1", "m2", "m3"] });
|
|
120
|
+
|
|
121
|
+
act(() => list.commands().focusLast());
|
|
122
|
+
act(() => list.commands().focusNext());
|
|
123
|
+
assert.equal(list.focusedId(), "m3");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("picks up rows revealed by the expander", async () => {
|
|
127
|
+
const list = mountList({ initialIds: ["m1", "m2", "m3"] });
|
|
128
|
+
act(() => list.commands().focusLast());
|
|
129
|
+
assert.equal(list.focusedId(), "m3");
|
|
130
|
+
|
|
131
|
+
await list.render(["m1", "m2", "m3", "m4", "m5"]);
|
|
132
|
+
|
|
133
|
+
act(() => list.commands().focusLast());
|
|
134
|
+
assert.equal(list.focusedId(), "m5");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("withdraws its commands when every row leaves", async () => {
|
|
138
|
+
const list = mountList({ initialIds: ["m1"] });
|
|
139
|
+
assert.ok(list.commands());
|
|
140
|
+
await list.render([]);
|
|
141
|
+
assert.throws(() => list.commands(), /commands not published/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("ThreadListInteraction — delete confirms first", () => {
|
|
146
|
+
const confirmButton = () =>
|
|
147
|
+
Array.from(
|
|
148
|
+
dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
|
|
149
|
+
).find((b) => b.textContent === "Move to Trash");
|
|
150
|
+
|
|
151
|
+
it("asks before it trashes anything", () => {
|
|
152
|
+
const deleted: string[][] = [];
|
|
153
|
+
const list = mountList({
|
|
154
|
+
initialIds: ["m1", "m2"],
|
|
155
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
act(() => list.commands().focusFirst());
|
|
159
|
+
act(() => {
|
|
160
|
+
assert.equal(list.commands().requestDelete(), true);
|
|
161
|
+
});
|
|
162
|
+
assert.deepEqual(deleted, [], "nothing is deleted before confirming");
|
|
163
|
+
assert.ok(confirmButton(), "the confirmation is on screen");
|
|
164
|
+
|
|
165
|
+
act(() => confirmButton()?.click());
|
|
166
|
+
assert.deepEqual(deleted, [["m1"]]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("claims a second Delete rather than deleting behind the dialog", () => {
|
|
170
|
+
const deleted: string[][] = [];
|
|
171
|
+
const list = mountList({
|
|
172
|
+
initialIds: ["m1", "m2"],
|
|
173
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
act(() => list.commands().focusFirst());
|
|
177
|
+
act(() => {
|
|
178
|
+
list.commands().requestDelete();
|
|
179
|
+
});
|
|
180
|
+
act(() => {
|
|
181
|
+
assert.equal(
|
|
182
|
+
list.commands().requestDelete(),
|
|
183
|
+
true,
|
|
184
|
+
"the second press belongs to the dialog",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
assert.deepEqual(deleted, []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("declines the keypress when there is nothing to delete", () => {
|
|
191
|
+
const list = mountList({
|
|
192
|
+
initialIds: ["m1"],
|
|
193
|
+
onDeleteMessages: () => undefined,
|
|
194
|
+
});
|
|
195
|
+
act(() => {
|
|
196
|
+
assert.equal(list.commands().requestDelete(), false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|