@remit/web-client 0.0.49 → 0.0.51

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.
@@ -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 { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
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 [triageFocusedId, setTriageFocusedId] = useState<string | undefined>(
437
- undefined,
438
- );
439
- const [triageSelectedIds, setTriageSelectedIds] = useState<string[]>([]);
440
- // The mounted message list publishes its navigation/selection commands here.
441
- // Null whenever no list is mounted (reading-only phone view, drafts) — the
442
- // keyboard layer then simply has nothing to drive.
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, then the open
568
- // thread.
569
- const goBack = useCallback(() => {
570
- if (listCommandsRef.current?.clearSelection()) return;
571
- if (selectedMessageId) {
572
- navigate({
573
- to: "/mail/$mailboxId",
574
- params: { mailboxId },
575
- search: (prev: Record<string, unknown>) => ({
576
- ...prev,
577
- selectedMessageId: undefined,
578
- selectedThreadId: undefined,
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
- useTriageKeyboard({
753
- // A modal owns the keyboard outright. Suspending the layer is what keeps a
754
- // second Delete press from reaching a delete while the confirmation for
755
- // the first one is still on screen.
756
- enabled: !composeState.isOpen && !listBlocksKeyboard,
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 = false,
89
- isTabStop = false,
90
+ focused: focusedProp,
91
+ isTabStop: isTabStopProp,
90
92
  density = "comfortable",
91
- isDesktop = true,
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 handleCheckboxClick = useCallback(
108
- (e: MouseEvent) => {
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: handleCheckboxClick,
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
+ });